authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-08 22:44:58-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-08 19:44:58-07:00
log6dbffa022d9e44091d0edc496181011e3acfb695
treee25de219a2ee8e408270fc0719a11bf2fc1d1356
parent5bb8c0366447b1bb94c0ac41f2d4cd06730ffb94
signature Signed by PGP key B5690EEEBB952194

feat: pick worktree or current checkout per project (#5766)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

28 files changed, 610 insertions(+), 55 deletions(-)

apps/mobile/src/features/threads/new-task-flow-provider.tsx+51-6
...@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"...@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
5 ModelSelection,5 ModelSelection,
6 ProjectReadFileResult,
6 ProviderInteractionMode,7 ProviderInteractionMode,
7 ProviderOptionSelection,8 ProviderOptionSelection,
8 RuntimeMode,9 RuntimeMode,
...@@ -13,8 +14,14 @@ import {...@@ -13,8 +14,14 @@ import {
13 DEFAULT_PROVIDER_INTERACTION_MODE,14 DEFAULT_PROVIDER_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,15 DEFAULT_RUNTIME_MODE,
15 MessageId,16 MessageId,
17 T3_PROJECT_FILE_NAME,
16 ThreadId,18 ThreadId,
17} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
21import {
22 isDefaultThreadEnvModeSettled,
23 resolveDefaultThreadEnvMode,
24} from "@t3tools/shared/threadEnvMode";
18import * as Arr from "effect/Array";25import * as Arr from "effect/Array";
19import { pipe } from "effect/Function";26import { pipe } from "effect/Function";
2027
...@@ -30,6 +37,8 @@ import {...@@ -30,6 +37,8 @@ import {
30} from "../../lib/modelOptions";37} from "../../lib/modelOptions";
31import { scopedProjectKey } from "../../lib/scopedEntities";38import { scopedProjectKey } from "../../lib/scopedEntities";
32import { appAtomRegistry } from "../../state/atom-registry";39import { appAtomRegistry } from "../../state/atom-registry";
40import { projectEnvironment } from "../../state/projects";
41import { useEnvironmentQuery } from "../../state/query";
33import {42import {
34 appendComposerDraftAttachments,43 appendComposerDraftAttachments,
35 clearComposerDraft,44 clearComposerDraft,
...@@ -345,10 +354,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -345,10 +354,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
345 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);354 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
346 const prompt = selectedProjectDraft.text;355 const prompt = selectedProjectDraft.text;
347 const attachments = selectedProjectDraft.attachments;356 const attachments = selectedProjectDraft.attachments;
348 // The server's configured default decides the mode until the user picks one357 // Default mode until the user picks one explicitly — same resolution web
349 // explicitly — same resolution web uses for new draft threads.358 // uses for new draft threads: per-project setting, then the repo's
350 const defaultWorkspaceMode: WorkspaceMode =359 // checked-in t3.json, then the server's configured default.
351 selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local";360 const t3ProjectFileQuery = useEnvironmentQuery(
361 selectedProject !== null && selectedProject.workspaceRoot !== ""
362 ? projectEnvironment.readFile({
363 environmentId: selectedProject.environmentId,
364 input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME },
365 })
366 : null,
367 );
368 const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null;
369 const t3ProjectFileDefaultMode = useMemo(() => {
370 if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null;
371 return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null;
372 }, [t3ProjectFileData]);
373 const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({
374 projectSetting: selectedProject?.defaultThreadEnvMode,
375 projectFile: t3ProjectFileDefaultMode,
376 globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local",
377 });
378 // While unsettled the resolved default is provisional. Nothing may write
379 // it into the draft during that window (the auto-branch effect does), or
380 // the frozen interim value beats the t3.json default once it loads.
381 const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({
382 explicitMode: selectedProjectDraft.workspaceSelection?.mode,
383 projectSetting: selectedProject?.defaultThreadEnvMode,
384 projectFilePending: t3ProjectFileQuery.isPending,
385 });
352 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;386 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
353 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;387 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
354 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;388 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
...@@ -618,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -618,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
618 }, [refreshBranches, selectedProject]);652 }, [refreshBranches, selectedProject]);
619653
620 useEffect(() => {654 useEffect(() => {
621 if (workspaceMode !== "worktree" || selectedBranchName !== null) {655 if (
656 !defaultWorkspaceModeSettled ||
657 workspaceMode !== "worktree" ||
658 selectedBranchName !== null
659 ) {
622 return;660 return;
623 }661 }
624 // The default may only exist as origin/<default> (isRemote), which662 // The default may only exist as origin/<default> (isRemote), which
...@@ -630,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -630,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
630 if (preferredBranch) {668 if (preferredBranch) {
631 selectBranch(preferredBranch);669 selectBranch(preferredBranch);
632 }670 }
633 }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]);671 }, [
672 allBranchRefs,
673 availableBranches,
674 defaultWorkspaceModeSettled,
675 selectBranch,
676 selectedBranchName,
677 workspaceMode,
678 ]);
634679
635 const setRuntimeMode = useCallback(680 const setRuntimeMode = useCallback(
636 (value: RuntimeMode) => {681 (value: RuntimeMode) => {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
...@@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
495 title: event.payload.title,495 title: event.payload.title,
496 workspaceRoot: event.payload.workspaceRoot,496 workspaceRoot: event.payload.workspaceRoot,
497 defaultModelSelection: event.payload.defaultModelSelection,497 defaultModelSelection: event.payload.defaultModelSelection,
498 defaultThreadEnvMode: null,
498 scripts: event.payload.scripts,499 scripts: event.payload.scripts,
499 createdAt: event.payload.createdAt,500 createdAt: event.payload.createdAt,
500 updatedAt: event.payload.updatedAt,501 updatedAt: event.payload.updatedAt,
...@@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
518 ...(event.payload.defaultModelSelection !== undefined519 ...(event.payload.defaultModelSelection !== undefined
519 ? { defaultModelSelection: event.payload.defaultModelSelection }520 ? { defaultModelSelection: event.payload.defaultModelSelection }
520 : {}),521 : {}),
522 ...(event.payload.defaultThreadEnvMode !== undefined
523 ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }
524 : {}),
521 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),525 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
522 updatedAt: event.payload.updatedAt,526 updatedAt: event.payload.updatedAt,
523 });527 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
...@@ -284,6 +284,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -284,6 +284,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
284 runOnWorktreeCreate: false,284 runOnWorktreeCreate: false,
285 },285 },
286 ],286 ],
287 defaultThreadEnvMode: null,
287 createdAt: "2026-02-24T00:00:00.000Z",288 createdAt: "2026-02-24T00:00:00.000Z",
288 updatedAt: "2026-02-24T00:00:01.000Z",289 updatedAt: "2026-02-24T00:00:01.000Z",
289 deletedAt: null,290 deletedAt: null,
...@@ -402,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -402,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
402 runOnWorktreeCreate: false,403 runOnWorktreeCreate: false,
403 },404 },
404 ],405 ],
406 defaultThreadEnvMode: null,
405 createdAt: "2026-02-24T00:00:00.000Z",407 createdAt: "2026-02-24T00:00:00.000Z",
406 updatedAt: "2026-02-24T00:00:01.000Z",408 updatedAt: "2026-02-24T00:00:01.000Z",
407 },409 },
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+7
...@@ -316,6 +316,7 @@ function mapProjectShellRow(...@@ -316,6 +316,7 @@ function mapProjectShellRow(
316 workspaceRoot: row.workspaceRoot,316 workspaceRoot: row.workspaceRoot,
317 repositoryIdentity,317 repositoryIdentity,
318 defaultModelSelection: row.defaultModelSelection,318 defaultModelSelection: row.defaultModelSelection,
319 defaultThreadEnvMode: row.defaultThreadEnvMode,
319 scripts: row.scripts,320 scripts: row.scripts,
320 createdAt: row.createdAt,321 createdAt: row.createdAt,
321 updatedAt: row.updatedAt,322 updatedAt: row.updatedAt,
...@@ -391,6 +392,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -391,6 +392,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
391 title,392 title,
392 workspace_root AS "workspaceRoot",393 workspace_root AS "workspaceRoot",
393 default_model_selection_json AS "defaultModelSelection",394 default_model_selection_json AS "defaultModelSelection",
395 default_thread_env_mode AS "defaultThreadEnvMode",
394 scripts_json AS "scripts",396 scripts_json AS "scripts",
395 created_at AS "createdAt",397 created_at AS "createdAt",
396 updated_at AS "updatedAt",398 updated_at AS "updatedAt",
...@@ -844,6 +846,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -844,6 +846,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
844 title,846 title,
845 workspace_root AS "workspaceRoot",847 workspace_root AS "workspaceRoot",
846 default_model_selection_json AS "defaultModelSelection",848 default_model_selection_json AS "defaultModelSelection",
849 default_thread_env_mode AS "defaultThreadEnvMode",
847 scripts_json AS "scripts",850 scripts_json AS "scripts",
848 created_at AS "createdAt",851 created_at AS "createdAt",
849 updated_at AS "updatedAt",852 updated_at AS "updatedAt",
...@@ -866,6 +869,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -866,6 +869,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
866 title,869 title,
867 workspace_root AS "workspaceRoot",870 workspace_root AS "workspaceRoot",
868 default_model_selection_json AS "defaultModelSelection",871 default_model_selection_json AS "defaultModelSelection",
872 default_thread_env_mode AS "defaultThreadEnvMode",
869 scripts_json AS "scripts",873 scripts_json AS "scripts",
870 created_at AS "createdAt",874 created_at AS "createdAt",
871 updated_at AS "updatedAt",875 updated_at AS "updatedAt",
...@@ -1542,6 +1546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1542,6 +1546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1542 workspaceRoot: row.workspaceRoot,1546 workspaceRoot: row.workspaceRoot,
1543 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,1547 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
1544 defaultModelSelection: row.defaultModelSelection,1548 defaultModelSelection: row.defaultModelSelection,
1549 defaultThreadEnvMode: row.defaultThreadEnvMode,
1545 scripts: row.scripts,1550 scripts: row.scripts,
1546 createdAt: row.createdAt,1551 createdAt: row.createdAt,
1547 updatedAt: row.updatedAt,1552 updatedAt: row.updatedAt,
...@@ -1671,6 +1676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1671,6 +1676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1671 title: row.title,1676 title: row.title,
1672 workspaceRoot: row.workspaceRoot,1677 workspaceRoot: row.workspaceRoot,
1673 defaultModelSelection: row.defaultModelSelection,1678 defaultModelSelection: row.defaultModelSelection,
1679 defaultThreadEnvMode: row.defaultThreadEnvMode,
1674 scripts: row.scripts,1680 scripts: row.scripts,
1675 createdAt: row.createdAt,1681 createdAt: row.createdAt,
1676 updatedAt: row.updatedAt,1682 updatedAt: row.updatedAt,
...@@ -2162,6 +2168,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2162,6 +2168,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2162 workspaceRoot: option.value.workspaceRoot,2168 workspaceRoot: option.value.workspaceRoot,
2163 repositoryIdentity,2169 repositoryIdentity,
2164 defaultModelSelection: option.value.defaultModelSelection,2170 defaultModelSelection: option.value.defaultModelSelection,
2171 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
2165 scripts: option.value.scripts,2172 scripts: option.value.scripts,
2166 createdAt: option.value.createdAt,2173 createdAt: option.value.createdAt,
2167 updatedAt: option.value.updatedAt,2174 updatedAt: option.value.updatedAt,
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts created+103
...@@ -0,0 +1,103 @@
1import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5
6import { decideOrchestrationCommand } from "./decider.ts";
7import { createEmptyReadModel, projectEvent } from "./projector.ts";
8
9const now = "2026-01-01T00:00:00.000Z";
10const projectId = ProjectId.make("project-env-mode");
11
12const seedProjectCreated = (sequence: number): OrchestrationEvent => ({
13 sequence,
14 eventId: EventId.make(`evt-project-env-mode-${sequence}`),
15 aggregateKind: "project",
16 aggregateId: projectId,
17 type: "project.created",
18 occurredAt: now,
19 commandId: CommandId.make(`cmd-project-env-mode-${sequence}`),
20 causationEventId: null,
21 correlationId: CommandId.make(`cmd-project-env-mode-${sequence}`),
22 metadata: {},
23 payload: {
24 projectId,
25 title: "Env mode",
26 workspaceRoot: "/tmp/env-mode",
27 defaultModelSelection: null,
28 scripts: [],
29 createdAt: now,
30 updatedAt: now,
31 },
32});
33
34it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => {
35 it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () =>
36 Effect.gen(function* () {
37 const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1));
38 expect(readModel.projects[0]?.defaultThreadEnvMode).toBeNull();
39
40 const result = yield* decideOrchestrationCommand({
41 command: {
42 type: "project.meta.update",
43 commandId: CommandId.make("cmd-project-env-mode-set"),
44 projectId,
45 defaultThreadEnvMode: "worktree",
46 },
47 readModel,
48 });
49
50 const event = Array.isArray(result) ? result[0] : result;
51 expect(event.type).toBe("project.meta-updated");
52 expect((event.payload as { defaultThreadEnvMode?: unknown }).defaultThreadEnvMode).toBe(
53 "worktree",
54 );
55
56 const updated = yield* projectEvent(readModel, { ...event, sequence: 2 });
57 expect(updated.projects[0]?.defaultThreadEnvMode).toBe("worktree");
58 }),
59 );
60
61 it.effect("omits the field when unset and clears it on explicit null", () =>
62 Effect.gen(function* () {
63 const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1));
64
65 const unrelated = yield* decideOrchestrationCommand({
66 command: {
67 type: "project.meta.update",
68 commandId: CommandId.make("cmd-project-env-mode-title"),
69 projectId,
70 title: "Renamed",
71 },
72 readModel,
73 });
74 const unrelatedEvent = Array.isArray(unrelated) ? unrelated[0] : unrelated;
75 expect("defaultThreadEnvMode" in (unrelatedEvent.payload as object)).toBe(false);
76
77 const set = yield* decideOrchestrationCommand({
78 command: {
79 type: "project.meta.update",
80 commandId: CommandId.make("cmd-project-env-mode-set"),
81 projectId,
82 defaultThreadEnvMode: "worktree",
83 },
84 readModel,
85 });
86 const setEvent = Array.isArray(set) ? set[0] : set;
87 const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 });
88
89 const clear = yield* decideOrchestrationCommand({
90 command: {
91 type: "project.meta.update",
92 commandId: CommandId.make("cmd-project-env-mode-clear"),
93 projectId,
94 defaultThreadEnvMode: null,
95 },
96 readModel: afterSet,
97 });
98 const clearEvent = Array.isArray(clear) ? clear[0] : clear;
99 const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 });
100 expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull();
101 }),
102 );
103});
apps/server/src/orchestration/decider.ts+3
...@@ -287,6 +287,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -287,6 +287,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
287 ...(command.defaultModelSelection !== undefined287 ...(command.defaultModelSelection !== undefined
288 ? { defaultModelSelection: command.defaultModelSelection }288 ? { defaultModelSelection: command.defaultModelSelection }
289 : {}),289 : {}),
290 ...(command.defaultThreadEnvMode !== undefined
291 ? { defaultThreadEnvMode: command.defaultThreadEnvMode }
292 : {}),
290 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),293 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
291 updatedAt: occurredAt,294 updatedAt: occurredAt,
292 },295 },
apps/server/src/orchestration/projector.ts+4
...@@ -214,6 +214,7 @@ export function projectEvent(...@@ -214,6 +214,7 @@ export function projectEvent(
214 title: payload.title,214 title: payload.title,
215 workspaceRoot: payload.workspaceRoot,215 workspaceRoot: payload.workspaceRoot,
216 defaultModelSelection: payload.defaultModelSelection,216 defaultModelSelection: payload.defaultModelSelection,
217 defaultThreadEnvMode: null,
217 scripts: payload.scripts,218 scripts: payload.scripts,
218 createdAt: payload.createdAt,219 createdAt: payload.createdAt,
219 updatedAt: payload.updatedAt,220 updatedAt: payload.updatedAt,
...@@ -246,6 +247,9 @@ export function projectEvent(...@@ -246,6 +247,9 @@ export function projectEvent(
246 ...(payload.defaultModelSelection !== undefined247 ...(payload.defaultModelSelection !== undefined
247 ? { defaultModelSelection: payload.defaultModelSelection }248 ? { defaultModelSelection: payload.defaultModelSelection }
248 : {}),249 : {}),
250 ...(payload.defaultThreadEnvMode !== undefined
251 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
252 : {}),
249 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),253 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
250 updatedAt: payload.updatedAt,254 updatedAt: payload.updatedAt,
251 }255 }
apps/server/src/persistence/Layers/ProjectionProjects.ts+5
...@@ -35,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -35,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
35 title,35 title,
36 workspace_root,36 workspace_root,
37 default_model_selection_json,37 default_model_selection_json,
38 default_thread_env_mode,
38 scripts_json,39 scripts_json,
39 created_at,40 created_at,
40 updated_at,41 updated_at,
...@@ -45,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -45,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
45 ${row.title},46 ${row.title},
46 ${row.workspaceRoot},47 ${row.workspaceRoot},
47 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},48 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
49 ${row.defaultThreadEnvMode},
48 ${JSON.stringify(row.scripts)},50 ${JSON.stringify(row.scripts)},
49 ${row.createdAt},51 ${row.createdAt},
50 ${row.updatedAt},52 ${row.updatedAt},
...@@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
55 title = excluded.title,57 title = excluded.title,
56 workspace_root = excluded.workspace_root,58 workspace_root = excluded.workspace_root,
57 default_model_selection_json = excluded.default_model_selection_json,59 default_model_selection_json = excluded.default_model_selection_json,
60 default_thread_env_mode = excluded.default_thread_env_mode,
58 scripts_json = excluded.scripts_json,61 scripts_json = excluded.scripts_json,
59 created_at = excluded.created_at,62 created_at = excluded.created_at,
60 updated_at = excluded.updated_at,63 updated_at = excluded.updated_at,
...@@ -72,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -72,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
72 title,75 title,
73 workspace_root AS "workspaceRoot",76 workspace_root AS "workspaceRoot",
74 default_model_selection_json AS "defaultModelSelection",77 default_model_selection_json AS "defaultModelSelection",
78 default_thread_env_mode AS "defaultThreadEnvMode",
75 scripts_json AS "scripts",79 scripts_json AS "scripts",
76 created_at AS "createdAt",80 created_at AS "createdAt",
77 updated_at AS "updatedAt",81 updated_at AS "updatedAt",
...@@ -91,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -91,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
91 title,95 title,
92 workspace_root AS "workspaceRoot",96 workspace_root AS "workspaceRoot",
93 default_model_selection_json AS "defaultModelSelection",97 default_model_selection_json AS "defaultModelSelection",
98 default_thread_env_mode AS "defaultThreadEnvMode",
94 scripts_json AS "scripts",99 scripts_json AS "scripts",
95 created_at AS "createdAt",100 created_at AS "createdAt",
96 updated_at AS "updatedAt",101 updated_at AS "updatedAt",
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1
...@@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {...@@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
33 instanceId: ProviderInstanceId.make("codex"),33 instanceId: ProviderInstanceId.make("codex"),
34 model: "gpt-5.4",34 model: "gpt-5.4",
35 },35 },
36 defaultThreadEnvMode: null,
36 scripts: [],37 scripts: [],
37 createdAt: "2026-03-24T00:00:00.000Z",38 createdAt: "2026-03-24T00:00:00.000Z",
38 updatedAt: "2026-03-24T00:00:00.000Z",39 updatedAt: "2026-03-24T00:00:00.000Z",
apps/server/src/persistence/Migrations.ts+2
...@@ -51,6 +51,7 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts...@@ -51,6 +51,7 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts
51import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";51import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
52import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";52import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";53import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
5455
55/**56/**
56 * Migration loader with all migrations defined inline.57 * Migration loader with all migrations defined inline.
...@@ -101,6 +102,7 @@ export const migrationEntries = [...@@ -101,6 +102,7 @@ export const migrationEntries = [
101 [36, "ProjectionThreadsPinned", Migration0036],102 [36, "ProjectionThreadsPinned", Migration0036],
102 [37, "ProjectionTurnsKeysetIndex", Migration0037],103 [37, "ProjectionTurnsKeysetIndex", Migration0037],
103 [38, "ProjectionThreadsPinOrderKey", Migration0038],104 [38, "ProjectionThreadsPinOrderKey", Migration0038],
105 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
104] as const;106] as const;
105107
106export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);108export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_projects)
8 `;
9
10 if (!columns.some((column) => column.name === "default_thread_env_mode")) {
11 yield* sql`
12 ALTER TABLE projection_projects
13 ADD COLUMN default_thread_env_mode TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionProjects.ts+8-1
...@@ -6,7 +6,13 @@...@@ -6,7 +6,13 @@
6 *6 *
7 * @module ProjectionProjectRepository7 * @module ProjectionProjectRepository
8 */8 */
9import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts";9import {
10 IsoDateTime,
11 ModelSelection,
12 ProjectId,
13 ProjectScript,
14 ThreadEnvMode,
15} from "@t3tools/contracts";
10import * as Option from "effect/Option";16import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
12import * as Context from "effect/Context";18import * as Context from "effect/Context";
...@@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({...@@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({
19 title: Schema.String,25 title: Schema.String,
20 workspaceRoot: Schema.String,26 workspaceRoot: Schema.String,
21 defaultModelSelection: Schema.NullOr(ModelSelection),27 defaultModelSelection: Schema.NullOr(ModelSelection),
28 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
22 scripts: Schema.Array(ProjectScript),29 scripts: Schema.Array(ProjectScript),
23 createdAt: IsoDateTime,30 createdAt: IsoDateTime,
24 updatedAt: IsoDateTime,31 updatedAt: IsoDateTime,
apps/web/src/components/settings/ProjectSettingsPanel.tsx+62
...@@ -17,7 +17,9 @@ import type {...@@ -17,7 +17,9 @@ import type {
17 ProviderDriverKind,17 ProviderDriverKind,
18 SidebarProjectGroupingMode,18 SidebarProjectGroupingMode,
19 T3ProjectFileScript,19 T3ProjectFileScript,
20 ThreadEnvMode,
20} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { resolveEnvModeLabel } from "../BranchToolbar.logic";
21import { createModelSelection } from "@t3tools/shared/model";23import { createModelSelection } from "@t3tools/shared/model";
22import { useLocation, useNavigate } from "@tanstack/react-router";24import { useLocation, useNavigate } from "@tanstack/react-router";
23import * as Cause from "effect/Cause";25import * as Cause from "effect/Cause";
...@@ -325,6 +327,7 @@ function ProjectDetail({...@@ -325,6 +327,7 @@ function ProjectDetail({
325 async (327 async (
326 input: Partial<{328 input: Partial<{
327 defaultModelSelection: ModelSelection | null;329 defaultModelSelection: ModelSelection | null;
330 defaultThreadEnvMode: ThreadEnvMode | null;
328 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;331 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
329 }>,332 }>,
330 failureTitle: string,333 failureTitle: string,
...@@ -377,6 +380,17 @@ function ProjectDetail({...@@ -377,6 +380,17 @@ function ProjectDetail({
377 [updateAllMembers],380 [updateAllMembers],
378 );381 );
379382
383 // ----- new-thread workspace mode -----
384 const storedEnvMode = representative.defaultThreadEnvMode ?? null;
385 const setDefaultThreadEnvMode = useCallback(
386 (mode: ThreadEnvMode | null) =>
387 void updateAllMembers(
388 { defaultThreadEnvMode: mode },
389 "Failed to update new-thread workspace",
390 ),
391 [updateAllMembers],
392 );
393
380 // ----- scripts -----394 // ----- scripts -----
381 const scripts = representative.scripts;395 const scripts = representative.scripts;
382 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);396 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
...@@ -385,6 +399,10 @@ function ProjectDetail({...@@ -385,6 +399,10 @@ function ProjectDetail({
385 const [isSavingScripts, setIsSavingScripts] = useState(false);399 const [isSavingScripts, setIsSavingScripts] = useState(false);
386 const savingScriptsRef = useRef(false);400 const savingScriptsRef = useRef(false);
387 const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot);401 const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot);
402 // What the "Default" option resolves to while no override is set: the
403 // repo's t3.json value when present, otherwise the global setting.
404 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;
405 const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";
388 const importableScripts = useMemo(406 const importableScripts = useMemo(
389 () =>407 () =>
390 t3File.scripts.filter(408 t3File.scripts.filter(
...@@ -761,6 +779,50 @@ function ProjectDetail({...@@ -761,6 +779,50 @@ function ProjectDetail({
761 />779 />
762 </SettingsSection>780 </SettingsSection>
763781
782 <SettingsSection title="New threads">
783 <SettingsRow
784 id="project-new-thread-workspace"
785 title="Workspace"
786 description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group."
787 resetAction={
788 storedEnvMode !== null ? (
789 <SettingResetButton
790 label="project workspace default"
791 onClick={() => setDefaultThreadEnvMode(null)}
792 />
793 ) : null
794 }
795 control={
796 <Select
797 value={storedEnvMode ?? "inherit"}
798 onValueChange={(value) => {
799 if (value === "worktree" || value === "local") {
800 setDefaultThreadEnvMode(value);
801 } else if (value === "inherit") {
802 setDefaultThreadEnvMode(null);
803 }
804 }}
805 >
806 <SelectTrigger aria-label="New-thread workspace">
807 <SelectValue>
808 {storedEnvMode === null
809 ? `Default (${resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})`
810 : resolveEnvModeLabel(storedEnvMode)}
811 </SelectValue>
812 </SelectTrigger>
813 <SelectPopup align="end" alignItemWithTrigger={false}>
814 <SelectItem value="inherit">
815 Default ({inheritedEnvModeSource}:{" "}
816 {resolveEnvModeLabel(inheritedEnvMode).toLowerCase()})
817 </SelectItem>
818 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
819 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
820 </SelectPopup>
821 </Select>
822 }
823 />
824 </SettingsSection>
825
764 <SettingsSection826 <SettingsSection
765 id="project-scripts"827 id="project-scripts"
766 title="Scripts"828 title="Scripts"
apps/web/src/components/settings/settingsSearch.test.ts+2-1
...@@ -45,7 +45,8 @@ describe("searchSettings", () => {...@@ -45,7 +45,8 @@ describe("searchSettings", () => {
4545
46 it("matches normalized title substrings", () => {46 it("matches normalized title substrings", () => {
47 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);47 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
48 expect(searchSettings("work")).toEqual([]);48 expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]);
49 expect(searchSettings("xyzzy")).toEqual([]);
49 });50 });
5051
51 it("keeps catalog order for multiple title matches", () => {52 it("keeps catalog order for multiple title matches", () => {
apps/web/src/components/settings/settingsSearch.ts+5
...@@ -186,6 +186,11 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -186,6 +186,11 @@ export const SETTINGS_SEARCH_ITEMS = [
186 title: "Project default model",186 title: "Project default model",
187 to: "/settings/projects",187 to: "/settings/projects",
188 },188 },
189 {
190 id: "project-new-thread-workspace",
191 title: "Project new-thread workspace",
192 to: "/settings/projects",
193 },
189 {194 {
190 id: "project-scripts",195 id: "project-scripts",
191 title: "Project scripts",196 title: "Project scripts",
apps/web/src/hooks/useHandleNewThread.ts+108-32
...@@ -20,13 +20,34 @@ import {...@@ -20,13 +20,34 @@ import {
20 getProjectOrderKey,20 getProjectOrderKey,
21 selectProjectGroupingSettings,21 selectProjectGroupingSettings,
22} from "../logicalProject";22} from "../logicalProject";
23import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
23import { readThreadShell, useProjects, useThread } from "../state/entities";24import { readThreadShell, useProjects, useThread } from "../state/entities";
24import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";25import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
26import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
25import { primaryServerSettingsAtom } from "../state/server";27import { primaryServerSettingsAtom } from "../state/server";
26import { resolveThreadRouteTarget } from "../threadRoutes";28import { resolveThreadRouteTarget } from "../threadRoutes";
27import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";29import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
28import { useClientSettings } from "./useSettings";30import { useClientSettings } from "./useSettings";
2931
32interface NewThreadWorkspaceOptions {
33 branch?: string | null;
34 worktreePath?: string | null;
35 envMode?: DraftThreadEnvMode;
36 startFromOrigin?: boolean;
37}
38
39// The workspace options the caller passed explicitly, shaped for the draft
40// store: absent keys stay absent so they never overwrite existing draft
41// state. Every reuse path applies exactly this set.
42function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undefined) {
43 return {
44 ...(options?.branch !== undefined ? { branch: options.branch } : {}),
45 ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath } : {}),
46 ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}),
47 ...(options?.startFromOrigin !== undefined ? { startFromOrigin: options.startFromOrigin } : {}),
48 };
49}
50
30export function useNewThreadHandler() {51export function useNewThreadHandler() {
31 const projects = useProjects();52 const projects = useProjects();
32 // New-thread defaults are a user preference, and the settings UI only ever53 // New-thread defaults are a user preference, and the settings UI only ever
...@@ -105,6 +126,22 @@ export function useNewThreadHandler() {...@@ -105,6 +126,22 @@ export function useNewThreadHandler() {
105 candidate.id === projectRef.projectId &&126 candidate.id === projectRef.projectId &&
106 candidate.environmentId === projectRef.environmentId,127 candidate.environmentId === projectRef.environmentId,
107 );128 );
129 // The shared resolver owns the priority order. The t3.json read is
130 // skipped entirely when a higher-priority source decides, and its
131 // query atom caches per project after the first call.
132 const resolveDefaultEnvMode = async (): Promise<DraftThreadEnvMode> => {
133 const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null;
134 return resolveDefaultThreadEnvMode({
135 projectSetting: project?.defaultThreadEnvMode,
136 projectFile: consultProjectFile
137 ? await readT3ProjectFileDefaultThreadEnvMode(
138 project.environmentId,
139 project.workspaceRoot,
140 )
141 : null,
142 globalDefault: primaryServerSettings.defaultThreadEnvMode,
143 });
144 };
108 const logicalProjectKey = project145 const logicalProjectKey = project
109 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)146 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
110 : scopedProjectKey(projectRef);147 : scopedProjectKey(projectRef);
...@@ -146,25 +183,37 @@ export function useNewThreadHandler() {...@@ -146,25 +183,37 @@ export function useNewThreadHandler() {
146 // preserved. When the draft is already open and no options were183 // preserved. When the draft is already open and no options were
147 // passed, leave it alone entirely — the user may have just picked a184 // passed, leave it alone entirely — the user may have just picked a
148 // branch in the composer.185 // branch in the composer.
149 const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode;186 let workspaceContext: NewThreadWorkspaceOptions | null = null;
150 const workspaceContext = hasExplicitWorkspaceOption187 if (hasExplicitWorkspaceOption) {
151 ? {188 workspaceContext = pickExplicitWorkspaceOptions(options);
152 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),189 } else if (!isDraftAlreadyOpen) {
153 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),190 const defaultEnvMode = await resolveDefaultEnvMode();
154 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),191 // The await yields. If the draft was opened (a concurrent
155 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),192 // invocation's navigation landed) or promoted to a real thread
156 }193 // in the meantime, this invocation is a stale loser: resetting
157 : isDraftAlreadyOpen194 // context, remapping (which wipes branch/worktree on a member
158 ? null195 // change), or navigating would all clobber state written after
159 : {196 // the snapshot above. Bail out entirely — the winner already
160 branch: null,197 // did this work.
161 worktreePath: null,198 const routeTargetNow = getCurrentRouteTarget();
162 envMode: defaultEnvMode,199 const openedMeanwhile =
163 startFromOrigin: resolveNewDraftStartFromOrigin({200 routeTargetNow?.kind === "draft" &&
164 envMode: defaultEnvMode,201 routeTargetNow.draftId === reusableStoredDraftThread.draftId;
165 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,202 const promotedMeanwhile =
166 }),203 storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null;
167 };204 if (openedMeanwhile || promotedMeanwhile) {
205 return;
206 }
207 workspaceContext = {
208 branch: null,
209 worktreePath: null,
210 envMode: defaultEnvMode,
211 startFromOrigin: resolveNewDraftStartFromOrigin({
212 envMode: defaultEnvMode,
213 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
214 }),
215 };
216 }
168 if (workspaceContext) {217 if (workspaceContext) {
169 setDraftThreadContext(reusableStoredDraftThread.draftId, {218 setDraftThreadContext(reusableStoredDraftThread.draftId, {
170 ...workspaceContext,219 ...workspaceContext,
...@@ -195,9 +244,13 @@ export function useNewThreadHandler() {...@@ -195,9 +244,13 @@ export function useNewThreadHandler() {
195 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),244 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
196 },245 },
197 );246 );
247 // Re-read the route: the snapshot from before the await is stale
248 // once a concurrent invocation's navigation lands, and navigating
249 // again would push a duplicate history entry.
250 const routeTargetAfterWrites = getCurrentRouteTarget();
198 if (251 if (
199 currentRouteTarget?.kind === "draft" &&252 routeTargetAfterWrites?.kind === "draft" &&
200 currentRouteTarget.draftId === reusableStoredDraftThread.draftId253 routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId
201 ) {254 ) {
202 return;255 return;
203 }256 }
...@@ -221,22 +274,14 @@ export function useNewThreadHandler() {...@@ -221,22 +274,14 @@ export function useNewThreadHandler() {
221 hasEnvModeOption ||274 hasEnvModeOption ||
222 hasStartFromOriginOption275 hasStartFromOriginOption
223 ) {276 ) {
224 setDraftThreadContext(currentRouteTarget.draftId, {277 setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options));
225 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
226 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
227 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
228 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
229 });
230 }278 }
231 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {279 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
232 threadId: latestActiveDraftThread.threadId,280 threadId: latestActiveDraftThread.threadId,
233 createdAt: latestActiveDraftThread.createdAt,281 createdAt: latestActiveDraftThread.createdAt,
234 runtimeMode: latestActiveDraftThread.runtimeMode,282 runtimeMode: latestActiveDraftThread.runtimeMode,
235 interactionMode: latestActiveDraftThread.interactionMode,283 interactionMode: latestActiveDraftThread.interactionMode,
236 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),284 ...pickExplicitWorkspaceOptions(options),
237 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
238 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
239 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
240 });285 });
241 return Promise.resolve();286 return Promise.resolve();
242 }287 }
...@@ -244,8 +289,39 @@ export function useNewThreadHandler() {...@@ -244,8 +289,39 @@ export function useNewThreadHandler() {
244 const draftId = newDraftId();289 const draftId = newDraftId();
245 const threadId = newThreadId();290 const threadId = newThreadId();
246 const createdAt = new Date().toISOString();291 const createdAt = new Date().toISOString();
247 const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode;
248 return (async () => {292 return (async () => {
293 const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode());
294 // The await yields, so a concurrent invocation may have registered a
295 // draft for this logical project in the meantime. Registering ours
296 // too would evict that draft while its navigation is in flight —
297 // reuse the winner instead, like the synchronous path above does.
298 const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey);
299 if (
300 racedDraft &&
301 readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null
302 ) {
303 // Same remap the reuse paths above perform: point the draft at the
304 // caller's project member and apply explicit workspace options if
305 // the caller passed any. Without explicit options the winner's
306 // context stands untouched — the winner's navigation is landing,
307 // which is the isDraftAlreadyOpen "leave it alone" case. Writing
308 // this invocation's defaults here instead would clobber the
309 // winner's explicit picks and could pair its worktreePath with a
310 // contradictory envMode.
311 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, {
312 threadId: racedDraft.threadId,
313 createdAt: racedDraft.createdAt,
314 runtimeMode: racedDraft.runtimeMode,
315 interactionMode: racedDraft.interactionMode,
316 ...pickExplicitWorkspaceOptions(options),
317 });
318 await router.navigate({
319 to: "/draft/$draftId",
320 params: { draftId: racedDraft.draftId },
321 replace: options?.replace ?? false,
322 });
323 return;
324 }
249 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {325 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
250 threadId,326 threadId,
251 createdAt,327 createdAt,
apps/web/src/hooks/useT3ProjectFileScripts.ts+13-10
...@@ -1,17 +1,14 @@...@@ -1,17 +1,14 @@
1import {1import {
2 T3_PROJECT_FILE_NAME,2 T3_PROJECT_FILE_NAME,
3 type EnvironmentId,3 type EnvironmentId,
4 type T3ProjectFile,
4 type T3ProjectFileScript,5 type T3ProjectFileScript,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";7import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
7import * as Exit from "effect/Exit";
8import * as Schema from "effect/Schema";
9import { useMemo } from "react";8import { useMemo } from "react";
109
11import { useProjectFileQuery } from "~/components/files/projectFilesQueryState";10import { useProjectFileQuery } from "~/components/files/projectFilesQueryState";
1211
13const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson);
14
15const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];12const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
1613
17export interface T3ProjectFileState {14export interface T3ProjectFileState {
...@@ -23,6 +20,8 @@ export interface T3ProjectFileState {...@@ -23,6 +20,8 @@ export interface T3ProjectFileState {
23 * - `loading`: the file query has not settled yet.20 * - `loading`: the file query has not settled yet.
24 */21 */
25 status: "loading" | "missing" | "invalid" | "valid";22 status: "loading" | "missing" | "invalid" | "valid";
23 /** The decoded file when status is `valid`, null otherwise. */
24 file: T3ProjectFile | null;
26 scripts: ReadonlyArray<T3ProjectFileScript>;25 scripts: ReadonlyArray<T3ProjectFileScript>;
27}26}
2827
...@@ -39,13 +38,17 @@ export function useT3ProjectFileState(...@@ -39,13 +38,17 @@ export function useT3ProjectFileState(
39 const isPending = query.isPending;38 const isPending = query.isPending;
40 return useMemo(() => {39 return useMemo(() => {
41 if (contents === null) {40 if (contents === null) {
42 return { status: isPending ? "loading" : "missing", scripts: NO_SCRIPTS } as const;41 return {
42 status: isPending ? "loading" : "missing",
43 file: null,
44 scripts: NO_SCRIPTS,
45 } as const;
43 }46 }
44 const decoded = decodeT3ProjectFile(contents);47 const file = parseT3ProjectFile(contents);
45 if (Exit.isFailure(decoded)) {48 if (file === null) {
46 return { status: "invalid", scripts: NO_SCRIPTS } as const;49 return { status: "invalid", file: null, scripts: NO_SCRIPTS } as const;
47 }50 }
48 return { status: "valid", scripts: decoded.value.scripts ?? NO_SCRIPTS } as const;51 return { status: "valid", file, scripts: file.scripts ?? NO_SCRIPTS } as const;
49 }, [contents, isPending]);52 }, [contents, isPending]);
50}53}
5154
apps/web/src/lib/t3ProjectFileDefaults.ts created+38
...@@ -0,0 +1,38 @@
1import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t3tools/contracts";
2import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
3import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
4
5import {
6 getProjectFileQueryAtom,
7 resolveProjectFileQueryData,
8} from "~/components/files/projectFilesQueryState";
9import { appAtomRegistry } from "~/rpc/atomRegistry";
10
11/**
12 * Read `defaultThreadEnvMode` from the project's checked-in `t3.json`.
13 *
14 * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread
15 * path, which resolves defaults at call time rather than render time. The
16 * file query atom caches per (environment, cwd), so repeat calls don't
17 * re-fetch. Optimistic in-app writes overlay the query result, matching what
18 * `useProjectFileQuery` renders. Missing, truncated, or invalid files
19 * resolve to null.
20 */
21export async function readT3ProjectFileDefaultThreadEnvMode(
22 environmentId: EnvironmentId,
23 workspaceRoot: string,
24): Promise<ThreadEnvMode | null> {
25 const result = await executeAtomQuery(
26 appAtomRegistry,
27 getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME),
28 { reportDefect: false, reportFailure: false },
29 );
30 const data = resolveProjectFileQueryData(
31 environmentId,
32 workspaceRoot,
33 T3_PROJECT_FILE_NAME,
34 result._tag === "Success" ? result.value : null,
35 );
36 if (data === null || data.truncated) return null;
37 return parseT3ProjectFile(data.contents)?.defaultThreadEnvMode ?? null;
38}
packages/contracts/src/environment.ts+8
...@@ -18,6 +18,14 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({...@@ -18,6 +18,14 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({
18 os: ExecutionEnvironmentPlatformOs,18 os: ExecutionEnvironmentPlatformOs,
19 arch: ExecutionEnvironmentPlatformArch,19 arch: ExecutionEnvironmentPlatformArch,
20});20});
21
22/**
23 * Where a new thread runs: the project's current checkout ("local") or a
24 * fresh git worktree ("worktree"). Lives here (not settings.ts) so
25 * orchestration contracts can reference it without an import cycle.
26 */
27export const ThreadEnvMode = Schema.Literals(["local", "worktree"]);
28export type ThreadEnvMode = typeof ThreadEnvMode.Type;
21export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;29export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
2230
23/** How a server can replace itself with another version when asked over RPC.31/** How a server can replace itself with another version when asked over RPC.
packages/contracts/src/orchestration.ts+8-1
...@@ -4,7 +4,7 @@ import * as SchemaIssue from "effect/SchemaIssue";...@@ -4,7 +4,7 @@ import * as SchemaIssue from "effect/SchemaIssue";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import * as Struct from "effect/Struct";5import * as Struct from "effect/Struct";
6import { ProviderOptionSelections } from "./model.ts";6import { ProviderOptionSelections } from "./model.ts";
7import { RepositoryIdentity } from "./environment.ts";7import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
8import {8import {
9 ApprovalRequestId,9 ApprovalRequestId,
10 CheckpointRef,10 CheckpointRef,
...@@ -217,6 +217,9 @@ export const OrchestrationProject = Schema.Struct({...@@ -217,6 +217,9 @@ export const OrchestrationProject = Schema.Struct({
217 workspaceRoot: TrimmedNonEmptyString,217 workspaceRoot: TrimmedNonEmptyString,
218 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),218 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
219 defaultModelSelection: Schema.NullOr(ModelSelection),219 defaultModelSelection: Schema.NullOr(ModelSelection),
220 // Per-project override for where new threads start. Null/absent means
221 // "no override": clients fall back to t3.json, then the global setting.
222 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
220 scripts: Schema.Array(ProjectScript),223 scripts: Schema.Array(ProjectScript),
221 createdAt: IsoDateTime,224 createdAt: IsoDateTime,
222 updatedAt: IsoDateTime,225 updatedAt: IsoDateTime,
...@@ -411,6 +414,7 @@ export const OrchestrationProjectShell = Schema.Struct({...@@ -411,6 +414,7 @@ export const OrchestrationProjectShell = Schema.Struct({
411 workspaceRoot: TrimmedNonEmptyString,414 workspaceRoot: TrimmedNonEmptyString,
412 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),415 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
413 defaultModelSelection: Schema.NullOr(ModelSelection),416 defaultModelSelection: Schema.NullOr(ModelSelection),
417 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
414 scripts: Schema.Array(ProjectScript),418 scripts: Schema.Array(ProjectScript),
415 createdAt: IsoDateTime,419 createdAt: IsoDateTime,
416 updatedAt: IsoDateTime,420 updatedAt: IsoDateTime,
...@@ -623,6 +627,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({...@@ -623,6 +627,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({
623 title: Schema.optional(TrimmedNonEmptyString),627 title: Schema.optional(TrimmedNonEmptyString),
624 workspaceRoot: Schema.optional(TrimmedNonEmptyString),628 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
625 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),629 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
630 // Absent = leave unchanged; null = clear the override.
631 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
626 scripts: Schema.optional(Schema.Array(ProjectScript)),632 scripts: Schema.optional(Schema.Array(ProjectScript)),
627});633});
628634
...@@ -1078,6 +1084,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({...@@ -1078,6 +1084,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
1078 workspaceRoot: Schema.optional(TrimmedNonEmptyString),1084 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
1079 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),1085 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
1080 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),1086 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
1087 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
1081 scripts: Schema.optional(Schema.Array(ProjectScript)),1088 scripts: Schema.optional(Schema.Array(ProjectScript)),
1082 updatedAt: IsoDateTime,1089 updatedAt: IsoDateTime,
1083});1090});
packages/contracts/src/settings.ts+4-2
...@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";...@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { ThreadEnvMode } from "./environment.ts";
6import {7import {
7 DEFAULT_TEXT_GENERATION_MODEL,8 DEFAULT_TEXT_GENERATION_MODEL,
8 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,9 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
...@@ -206,8 +207,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS...@@ -206,8 +207,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS
206207
207// ── Server Settings (server-authoritative) ────────────────────208// ── Server Settings (server-authoritative) ────────────────────
208209
209export const ThreadEnvMode = Schema.Literals(["local", "worktree"]);210// Moved to environment.ts so orchestration contracts can use it without an
210export type ThreadEnvMode = typeof ThreadEnvMode.Type;211// import cycle; re-exported here for compatibility with deep imports.
212export { ThreadEnvMode } from "./environment.ts";
211213
212const makeBinaryPathSetting = (fallback: string) =>214const makeBinaryPathSetting = (fallback: string) =>
213 TrimmedString.pipe(215 TrimmedString.pipe(
packages/contracts/src/t3ProjectFile.test.ts+6
...@@ -52,4 +52,10 @@ describe("T3ProjectFile", () => {...@@ -52,4 +52,10 @@ describe("T3ProjectFile", () => {
52 decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }),52 decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }),
53 ).toThrow();53 ).toThrow();
54 });54 });
55
56 it("decodes defaultThreadEnvMode and rejects unknown modes", () => {
57 expect(decode({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe("worktree");
58 expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local");
59 expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow();
60 });
55});61});
packages/contracts/src/t3ProjectFile.ts+7
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import * as SchemaTransformation from "effect/SchemaTransformation";2import * as SchemaTransformation from "effect/SchemaTransformation";
33
4import { ThreadEnvMode } from "./environment.ts";
4import { ProjectScriptIcon } from "./orchestration.ts";5import { ProjectScriptIcon } from "./orchestration.ts";
56
6/** File name of the checked-in T3 project file, resolved at the workspace root. */7/** File name of the checked-in T3 project file, resolved at the workspace root. */
...@@ -73,6 +74,12 @@ export const T3ProjectFile = Schema.Struct({...@@ -73,6 +74,12 @@ export const T3ProjectFile = Schema.Struct({
73 T3_PROJECT_FILE_PATH_MAX_LENGTH,74 T3_PROJECT_FILE_PATH_MAX_LENGTH,
74 ),75 ),
75 ),76 ),
77 defaultThreadEnvMode: Schema.optionalKey(
78 ThreadEnvMode.annotate({
79 description:
80 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.',
81 }),
82 ),
76 scripts: Schema.optionalKey(83 scripts: Schema.optionalKey(
77 Schema.Array(T3ProjectFileScript)84 Schema.Array(T3ProjectFileScript)
78 .annotate({85 .annotate({
packages/shared/package.json+4
...@@ -91,6 +91,10 @@...@@ -91,6 +91,10 @@
91 "types": "./src/projectScripts.ts",91 "types": "./src/projectScripts.ts",
92 "import": "./src/projectScripts.ts"92 "import": "./src/projectScripts.ts"
93 },93 },
94 "./threadEnvMode": {
95 "types": "./src/threadEnvMode.ts",
96 "import": "./src/threadEnvMode.ts"
97 },
94 "./t3ProjectFile": {98 "./t3ProjectFile": {
95 "types": "./src/t3ProjectFile.ts",99 "types": "./src/t3ProjectFile.ts",
96 "import": "./src/t3ProjectFile.ts"100 "import": "./src/t3ProjectFile.ts"
packages/shared/src/t3ProjectFile.test.ts+25-2
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { buildT3ProjectFileJsonSchema, T3ProjectFileFromJson } from "./t3ProjectFile.ts";4import {
5 buildT3ProjectFileJsonSchema,
6 parseT3ProjectFile,
7 T3ProjectFileFromJson,
8} from "./t3ProjectFile.ts";
59
6const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson);10const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson);
711
...@@ -27,9 +31,15 @@ describe("buildT3ProjectFileJsonSchema", () => {...@@ -27,9 +31,15 @@ describe("buildT3ProjectFileJsonSchema", () => {
27 required?: ReadonlyArray<string>;31 required?: ReadonlyArray<string>;
28 };32 };
2933
30 expect(Object.keys(schema.properties).sort()).toEqual(["$schema", "iconPath", "scripts"]);34 expect(Object.keys(schema.properties).sort()).toEqual([
35 "$schema",
36 "defaultThreadEnvMode",
37 "iconPath",
38 "scripts",
39 ]);
31 expect(schema.required).toBeUndefined();40 expect(schema.required).toBeUndefined();
32 expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");41 expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");
42 expect(schema.properties.defaultThreadEnvMode?.description).toContain("new threads start");
3343
34 const script = schema.properties.scripts?.items;44 const script = schema.properties.scripts?.items;
35 expect(script?.required).toEqual(["name", "command"]);45 expect(script?.required).toEqual(["name", "command"]);
...@@ -67,3 +77,16 @@ describe("T3ProjectFileFromJson", () => {...@@ -67,3 +77,16 @@ describe("T3ProjectFileFromJson", () => {
67 expect(() => decodeJson("{ not json")).toThrow();77 expect(() => decodeJson("{ not json")).toThrow();
68 });78 });
69});79});
80
81describe("parseT3ProjectFile", () => {
82 it("returns the decoded file for valid contents", () => {
83 expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "worktree" }')).toEqual({
84 defaultThreadEnvMode: "worktree",
85 });
86 });
87
88 it("returns null for malformed or invalid contents", () => {
89 expect(parseT3ProjectFile("{ not json")).toBeNull();
90 expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "spaceship" }')).toBeNull();
91 });
92});
packages/shared/src/t3ProjectFile.ts+13
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as Exit from "effect/Exit";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
23
3import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts";4import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts";
...@@ -10,6 +11,18 @@ import { fromLenientJson } from "./schemaJson.ts";...@@ -10,6 +11,18 @@ import { fromLenientJson } from "./schemaJson.ts";
10 */11 */
11export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile);12export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile);
1213
14const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson);
15
16/**
17 * Decode raw `t3.json` contents, treating invalid or malformed files as
18 * absent. Clients use this to read optional defaults (scripts, thread env
19 * mode) without surfacing decode errors to the user.
20 */
21export function parseT3ProjectFile(contents: string): T3ProjectFile | null {
22 const decoded = decodeT3ProjectFile(contents);
23 return Exit.isSuccess(decoded) ? decoded.value : null;
24}
25
13/**26/**
14 * Build the publishable JSON Schema document for `t3.json` (draft 2020-12).27 * Build the publishable JSON Schema document for `t3.json` (draft 2020-12).
15 *28 *
packages/shared/src/threadEnvMode.test.ts created+65
...@@ -0,0 +1,65 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode } from "./threadEnvMode.ts";
4
5describe("resolveDefaultThreadEnvMode", () => {
6 it("prefers the project setting over t3.json over the global default", () => {
7 expect(
8 resolveDefaultThreadEnvMode({
9 projectSetting: "local",
10 projectFile: "worktree",
11 globalDefault: "worktree",
12 }),
13 ).toBe("local");
14 expect(
15 resolveDefaultThreadEnvMode({
16 projectSetting: null,
17 projectFile: "local",
18 globalDefault: "worktree",
19 }),
20 ).toBe("local");
21 expect(
22 resolveDefaultThreadEnvMode({
23 projectSetting: undefined,
24 projectFile: null,
25 globalDefault: "worktree",
26 }),
27 ).toBe("worktree");
28 });
29});
30
31describe("isDefaultThreadEnvModeSettled", () => {
32 it("settles on an explicit pick or project setting even while the file loads", () => {
33 expect(
34 isDefaultThreadEnvModeSettled({
35 explicitMode: "local",
36 projectSetting: null,
37 projectFilePending: true,
38 }),
39 ).toBe(true);
40 expect(
41 isDefaultThreadEnvModeSettled({
42 explicitMode: undefined,
43 projectSetting: "worktree",
44 projectFilePending: true,
45 }),
46 ).toBe(true);
47 });
48
49 it("stays unsettled only while a consulted file read is pending", () => {
50 expect(
51 isDefaultThreadEnvModeSettled({
52 explicitMode: undefined,
53 projectSetting: null,
54 projectFilePending: true,
55 }),
56 ).toBe(false);
57 expect(
58 isDefaultThreadEnvModeSettled({
59 explicitMode: undefined,
60 projectSetting: null,
61 projectFilePending: false,
62 }),
63 ).toBe(true);
64 });
65});
packages/shared/src/threadEnvMode.ts created+36
...@@ -0,0 +1,36 @@
1import type { ThreadEnvMode } from "@t3tools/contracts";
2
3/**
4 * Canonical priority order for a project's default thread env mode:
5 * per-project setting > checked-in t3.json > global server setting.
6 *
7 * An explicit composer pick outranks all of these; callers apply it before
8 * consulting the defaults. Web resolves the sources imperatively at draft
9 * creation, mobile reactively — both must route through this function so the
10 * platforms cannot disagree on the order.
11 */
12export function resolveDefaultThreadEnvMode(sources: {
13 readonly projectSetting: ThreadEnvMode | null | undefined;
14 readonly projectFile: ThreadEnvMode | null | undefined;
15 readonly globalDefault: ThreadEnvMode;
16}): ThreadEnvMode {
17 return sources.projectSetting ?? sources.projectFile ?? sources.globalDefault;
18}
19
20/**
21 * True once the resolved default can no longer change: an explicit pick or a
22 * source that outranks t3.json decided, or the file read settled. While
23 * false, nothing may persist the provisional default (for example into a
24 * draft's workspace selection) — it could differ from the final value.
25 */
26export function isDefaultThreadEnvModeSettled(sources: {
27 readonly explicitMode: ThreadEnvMode | undefined;
28 readonly projectSetting: ThreadEnvMode | null | undefined;
29 readonly projectFilePending: boolean;
30}): boolean {
31 return (
32 sources.explicitMode !== undefined ||
33 sources.projectSetting != null ||
34 !sources.projectFilePending
35 );
36}