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"
33import type {
44 EnvironmentId,
55 ModelSelection,
6 ProjectReadFileResult,
67 ProviderInteractionMode,
78 ProviderOptionSelection,
89 RuntimeMode,
......@@ -13,8 +14,14 @@ import {
1314 DEFAULT_PROVIDER_INTERACTION_MODE,
1415 DEFAULT_RUNTIME_MODE,
1516 MessageId,
17 T3_PROJECT_FILE_NAME,
1618 ThreadId,
1719} from "@t3tools/contracts";
20import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
21import {
22 isDefaultThreadEnvModeSettled,
23 resolveDefaultThreadEnvMode,
24} from "@t3tools/shared/threadEnvMode";
1825import * as Arr from "effect/Array";
1926import { pipe } from "effect/Function";
2027
......@@ -30,6 +37,8 @@ import {
3037} from "../../lib/modelOptions";
3138import { scopedProjectKey } from "../../lib/scopedEntities";
3239import { appAtomRegistry } from "../../state/atom-registry";
40import { projectEnvironment } from "../../state/projects";
41import { useEnvironmentQuery } from "../../state/query";
3342import {
3443 appendComposerDraftAttachments,
3544 clearComposerDraft,
......@@ -345,10 +354,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
345354 const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
346355 const prompt = selectedProjectDraft.text;
347356 const attachments = selectedProjectDraft.attachments;
348 // The server's configured default decides the mode until the user picks one
349 // explicitly — same resolution web uses for new draft threads.
350 const defaultWorkspaceMode: WorkspaceMode =
351 selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local";
357 // Default mode until the user picks one explicitly — same resolution web
358 // uses for new draft threads: per-project setting, then the repo's
359 // checked-in t3.json, then the server's configured default.
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 });
352386 const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode;
353387 const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null;
354388 const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null;
......@@ -618,7 +652,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
618652 }, [refreshBranches, selectedProject]);
619653
620654 useEffect(() => {
621 if (workspaceMode !== "worktree" || selectedBranchName !== null) {
655 if (
656 !defaultWorkspaceModeSettled ||
657 workspaceMode !== "worktree" ||
658 selectedBranchName !== null
659 ) {
622660 return;
623661 }
624662 // The default may only exist as origin/<default> (isRemote), which
......@@ -630,7 +668,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
630668 if (preferredBranch) {
631669 selectBranch(preferredBranch);
632670 }
633 }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]);
671 }, [
672 allBranchRefs,
673 availableBranches,
674 defaultWorkspaceModeSettled,
675 selectBranch,
676 selectedBranchName,
677 workspaceMode,
678 ]);
634679
635680 const setRuntimeMode = useCallback(
636681 (value: RuntimeMode) => {
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4
......@@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
495495 title: event.payload.title,
496496 workspaceRoot: event.payload.workspaceRoot,
497497 defaultModelSelection: event.payload.defaultModelSelection,
498 defaultThreadEnvMode: null,
498499 scripts: event.payload.scripts,
499500 createdAt: event.payload.createdAt,
500501 updatedAt: event.payload.updatedAt,
......@@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
518519 ...(event.payload.defaultModelSelection !== undefined
519520 ? { defaultModelSelection: event.payload.defaultModelSelection }
520521 : {}),
522 ...(event.payload.defaultThreadEnvMode !== undefined
523 ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }
524 : {}),
521525 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
522526 updatedAt: event.payload.updatedAt,
523527 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -284,6 +284,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
284284 runOnWorktreeCreate: false,
285285 },
286286 ],
287 defaultThreadEnvMode: null,
287288 createdAt: "2026-02-24T00:00:00.000Z",
288289 updatedAt: "2026-02-24T00:00:01.000Z",
289290 deletedAt: null,
......@@ -402,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
402403 runOnWorktreeCreate: false,
403404 },
404405 ],
406 defaultThreadEnvMode: null,
405407 createdAt: "2026-02-24T00:00:00.000Z",
406408 updatedAt: "2026-02-24T00:00:01.000Z",
407409 },
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+7
......@@ -316,6 +316,7 @@ function mapProjectShellRow(
316316 workspaceRoot: row.workspaceRoot,
317317 repositoryIdentity,
318318 defaultModelSelection: row.defaultModelSelection,
319 defaultThreadEnvMode: row.defaultThreadEnvMode,
319320 scripts: row.scripts,
320321 createdAt: row.createdAt,
321322 updatedAt: row.updatedAt,
......@@ -391,6 +392,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
391392 title,
392393 workspace_root AS "workspaceRoot",
393394 default_model_selection_json AS "defaultModelSelection",
395 default_thread_env_mode AS "defaultThreadEnvMode",
394396 scripts_json AS "scripts",
395397 created_at AS "createdAt",
396398 updated_at AS "updatedAt",
......@@ -844,6 +846,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
844846 title,
845847 workspace_root AS "workspaceRoot",
846848 default_model_selection_json AS "defaultModelSelection",
849 default_thread_env_mode AS "defaultThreadEnvMode",
847850 scripts_json AS "scripts",
848851 created_at AS "createdAt",
849852 updated_at AS "updatedAt",
......@@ -866,6 +869,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
866869 title,
867870 workspace_root AS "workspaceRoot",
868871 default_model_selection_json AS "defaultModelSelection",
872 default_thread_env_mode AS "defaultThreadEnvMode",
869873 scripts_json AS "scripts",
870874 created_at AS "createdAt",
871875 updated_at AS "updatedAt",
......@@ -1542,6 +1546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15421546 workspaceRoot: row.workspaceRoot,
15431547 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
15441548 defaultModelSelection: row.defaultModelSelection,
1549 defaultThreadEnvMode: row.defaultThreadEnvMode,
15451550 scripts: row.scripts,
15461551 createdAt: row.createdAt,
15471552 updatedAt: row.updatedAt,
......@@ -1671,6 +1676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16711676 title: row.title,
16721677 workspaceRoot: row.workspaceRoot,
16731678 defaultModelSelection: row.defaultModelSelection,
1679 defaultThreadEnvMode: row.defaultThreadEnvMode,
16741680 scripts: row.scripts,
16751681 createdAt: row.createdAt,
16761682 updatedAt: row.updatedAt,
......@@ -2162,6 +2168,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
21622168 workspaceRoot: option.value.workspaceRoot,
21632169 repositoryIdentity,
21642170 defaultModelSelection: option.value.defaultModelSelection,
2171 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
21652172 scripts: option.value.scripts,
21662173 createdAt: option.value.createdAt,
21672174 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"
287287 ...(command.defaultModelSelection !== undefined
288288 ? { defaultModelSelection: command.defaultModelSelection }
289289 : {}),
290 ...(command.defaultThreadEnvMode !== undefined
291 ? { defaultThreadEnvMode: command.defaultThreadEnvMode }
292 : {}),
290293 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
291294 updatedAt: occurredAt,
292295 },
apps/server/src/orchestration/projector.ts+4
......@@ -214,6 +214,7 @@ export function projectEvent(
214214 title: payload.title,
215215 workspaceRoot: payload.workspaceRoot,
216216 defaultModelSelection: payload.defaultModelSelection,
217 defaultThreadEnvMode: null,
217218 scripts: payload.scripts,
218219 createdAt: payload.createdAt,
219220 updatedAt: payload.updatedAt,
......@@ -246,6 +247,9 @@ export function projectEvent(
246247 ...(payload.defaultModelSelection !== undefined
247248 ? { defaultModelSelection: payload.defaultModelSelection }
248249 : {}),
250 ...(payload.defaultThreadEnvMode !== undefined
251 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
252 : {}),
249253 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
250254 updatedAt: payload.updatedAt,
251255 }
apps/server/src/persistence/Layers/ProjectionProjects.ts+5
......@@ -35,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
3535 title,
3636 workspace_root,
3737 default_model_selection_json,
38 default_thread_env_mode,
3839 scripts_json,
3940 created_at,
4041 updated_at,
......@@ -45,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
4546 ${row.title},
4647 ${row.workspaceRoot},
4748 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
49 ${row.defaultThreadEnvMode},
4850 ${JSON.stringify(row.scripts)},
4951 ${row.createdAt},
5052 ${row.updatedAt},
......@@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
5557 title = excluded.title,
5658 workspace_root = excluded.workspace_root,
5759 default_model_selection_json = excluded.default_model_selection_json,
60 default_thread_env_mode = excluded.default_thread_env_mode,
5861 scripts_json = excluded.scripts_json,
5962 created_at = excluded.created_at,
6063 updated_at = excluded.updated_at,
......@@ -72,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
7275 title,
7376 workspace_root AS "workspaceRoot",
7477 default_model_selection_json AS "defaultModelSelection",
78 default_thread_env_mode AS "defaultThreadEnvMode",
7579 scripts_json AS "scripts",
7680 created_at AS "createdAt",
7781 updated_at AS "updatedAt",
......@@ -91,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
9195 title,
9296 workspace_root AS "workspaceRoot",
9397 default_model_selection_json AS "defaultModelSelection",
98 default_thread_env_mode AS "defaultThreadEnvMode",
9499 scripts_json AS "scripts",
95100 created_at AS "createdAt",
96101 updated_at AS "updatedAt",
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1
......@@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
3333 instanceId: ProviderInstanceId.make("codex"),
3434 model: "gpt-5.4",
3535 },
36 defaultThreadEnvMode: null,
3637 scripts: [],
3738 createdAt: "2026-03-24T00:00:00.000Z",
3839 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
5151import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts";
5252import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
5353import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
54import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
5455
5556/**
5657 * Migration loader with all migrations defined inline.
......@@ -101,6 +102,7 @@ export const migrationEntries = [
101102 [36, "ProjectionThreadsPinned", Migration0036],
102103 [37, "ProjectionTurnsKeysetIndex", Migration0037],
103104 [38, "ProjectionThreadsPinOrderKey", Migration0038],
105 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
104106] as const;
105107
106108export 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 @@
66 *
77 * @module ProjectionProjectRepository
88 */
9import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts";
9import {
10 IsoDateTime,
11 ModelSelection,
12 ProjectId,
13 ProjectScript,
14 ThreadEnvMode,
15} from "@t3tools/contracts";
1016import * as Option from "effect/Option";
1117import * as Schema from "effect/Schema";
1218import * as Context from "effect/Context";
......@@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({
1925 title: Schema.String,
2026 workspaceRoot: Schema.String,
2127 defaultModelSelection: Schema.NullOr(ModelSelection),
28 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
2229 scripts: Schema.Array(ProjectScript),
2330 createdAt: IsoDateTime,
2431 updatedAt: IsoDateTime,
apps/web/src/components/settings/ProjectSettingsPanel.tsx+62
......@@ -17,7 +17,9 @@ import type {
1717 ProviderDriverKind,
1818 SidebarProjectGroupingMode,
1919 T3ProjectFileScript,
20 ThreadEnvMode,
2021} from "@t3tools/contracts";
22import { resolveEnvModeLabel } from "../BranchToolbar.logic";
2123import { createModelSelection } from "@t3tools/shared/model";
2224import { useLocation, useNavigate } from "@tanstack/react-router";
2325import * as Cause from "effect/Cause";
......@@ -325,6 +327,7 @@ function ProjectDetail({
325327 async (
326328 input: Partial<{
327329 defaultModelSelection: ModelSelection | null;
330 defaultThreadEnvMode: ThreadEnvMode | null;
328331 scripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>;
329332 }>,
330333 failureTitle: string,
......@@ -377,6 +380,17 @@ function ProjectDetail({
377380 [updateAllMembers],
378381 );
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
380394 // ----- scripts -----
381395 const scripts = representative.scripts;
382396 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
......@@ -385,6 +399,10 @@ function ProjectDetail({
385399 const [isSavingScripts, setIsSavingScripts] = useState(false);
386400 const savingScriptsRef = useRef(false);
387401 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";
388406 const importableScripts = useMemo(
389407 () =>
390408 t3File.scripts.filter(
......@@ -761,6 +779,50 @@ function ProjectDetail({
761779 />
762780 </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
764826 <SettingsSection
765827 id="project-scripts"
766828 title="Scripts"
apps/web/src/components/settings/settingsSearch.test.ts+2-1
......@@ -45,7 +45,8 @@ describe("searchSettings", () => {
4545
4646 it("matches normalized title substrings", () => {
4747 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([]);
4950 });
5051
5152 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 = [
186186 title: "Project default model",
187187 to: "/settings/projects",
188188 },
189 {
190 id: "project-new-thread-workspace",
191 title: "Project new-thread workspace",
192 to: "/settings/projects",
193 },
189194 {
190195 id: "project-scripts",
191196 title: "Project scripts",
apps/web/src/hooks/useHandleNewThread.ts+108-32
......@@ -20,13 +20,34 @@ import {
2020 getProjectOrderKey,
2121 selectProjectGroupingSettings,
2222} from "../logicalProject";
23import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
2324import { readThreadShell, useProjects, useThread } from "../state/entities";
2425import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
26import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
2527import { primaryServerSettingsAtom } from "../state/server";
2628import { resolveThreadRouteTarget } from "../threadRoutes";
2729import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
2830import { 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
3051export function useNewThreadHandler() {
3152 const projects = useProjects();
3253 // New-thread defaults are a user preference, and the settings UI only ever
......@@ -105,6 +126,22 @@ export function useNewThreadHandler() {
105126 candidate.id === projectRef.projectId &&
106127 candidate.environmentId === projectRef.environmentId,
107128 );
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 };
108145 const logicalProjectKey = project
109146 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
110147 : scopedProjectKey(projectRef);
......@@ -146,25 +183,37 @@ export function useNewThreadHandler() {
146183 // preserved. When the draft is already open and no options were
147184 // passed, leave it alone entirely — the user may have just picked a
148185 // branch in the composer.
149 const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode;
150 const workspaceContext = hasExplicitWorkspaceOption
151 ? {
152 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
153 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
154 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
155 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
156 }
157 : isDraftAlreadyOpen
158 ? null
159 : {
160 branch: null,
161 worktreePath: null,
162 envMode: defaultEnvMode,
163 startFromOrigin: resolveNewDraftStartFromOrigin({
164 envMode: defaultEnvMode,
165 newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin,
166 }),
167 };
186 let workspaceContext: NewThreadWorkspaceOptions | null = null;
187 if (hasExplicitWorkspaceOption) {
188 workspaceContext = pickExplicitWorkspaceOptions(options);
189 } else if (!isDraftAlreadyOpen) {
190 const defaultEnvMode = await resolveDefaultEnvMode();
191 // The await yields. If the draft was opened (a concurrent
192 // invocation's navigation landed) or promoted to a real thread
193 // in the meantime, this invocation is a stale loser: resetting
194 // context, remapping (which wipes branch/worktree on a member
195 // change), or navigating would all clobber state written after
196 // the snapshot above. Bail out entirely — the winner already
197 // did this work.
198 const routeTargetNow = getCurrentRouteTarget();
199 const openedMeanwhile =
200 routeTargetNow?.kind === "draft" &&
201 routeTargetNow.draftId === reusableStoredDraftThread.draftId;
202 const promotedMeanwhile =
203 storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null;
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 }
168217 if (workspaceContext) {
169218 setDraftThreadContext(reusableStoredDraftThread.draftId, {
170219 ...workspaceContext,
......@@ -195,9 +244,13 @@ export function useNewThreadHandler() {
195244 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
196245 },
197246 );
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();
198251 if (
199 currentRouteTarget?.kind === "draft" &&
200 currentRouteTarget.draftId === reusableStoredDraftThread.draftId
252 routeTargetAfterWrites?.kind === "draft" &&
253 routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId
201254 ) {
202255 return;
203256 }
......@@ -221,22 +274,14 @@ export function useNewThreadHandler() {
221274 hasEnvModeOption ||
222275 hasStartFromOriginOption
223276 ) {
224 setDraftThreadContext(currentRouteTarget.draftId, {
225 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
226 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
227 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
228 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
229 });
277 setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options));
230278 }
231279 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, {
232280 threadId: latestActiveDraftThread.threadId,
233281 createdAt: latestActiveDraftThread.createdAt,
234282 runtimeMode: latestActiveDraftThread.runtimeMode,
235283 interactionMode: latestActiveDraftThread.interactionMode,
236 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
237 ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}),
238 ...(hasEnvModeOption ? { envMode: options?.envMode } : {}),
239 ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}),
284 ...pickExplicitWorkspaceOptions(options),
240285 });
241286 return Promise.resolve();
242287 }
......@@ -244,8 +289,39 @@ export function useNewThreadHandler() {
244289 const draftId = newDraftId();
245290 const threadId = newThreadId();
246291 const createdAt = new Date().toISOString();
247 const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode;
248292 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 }
249325 setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, {
250326 threadId,
251327 createdAt,
apps/web/src/hooks/useT3ProjectFileScripts.ts+13-10
......@@ -1,17 +1,14 @@
11import {
22 T3_PROJECT_FILE_NAME,
33 type EnvironmentId,
4 type T3ProjectFile,
45 type T3ProjectFileScript,
56} from "@t3tools/contracts";
6import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";
7import * as Exit from "effect/Exit";
8import * as Schema from "effect/Schema";
7import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
98import { useMemo } from "react";
109
1110import { useProjectFileQuery } from "~/components/files/projectFilesQueryState";
1211
13const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson);
14
1512const NO_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
1613
1714export interface T3ProjectFileState {
......@@ -23,6 +20,8 @@ export interface T3ProjectFileState {
2320 * - `loading`: the file query has not settled yet.
2421 */
2522 status: "loading" | "missing" | "invalid" | "valid";
23 /** The decoded file when status is `valid`, null otherwise. */
24 file: T3ProjectFile | null;
2625 scripts: ReadonlyArray<T3ProjectFileScript>;
2726}
2827
......@@ -39,13 +38,17 @@ export function useT3ProjectFileState(
3938 const isPending = query.isPending;
4039 return useMemo(() => {
4140 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;
4346 }
44 const decoded = decodeT3ProjectFile(contents);
45 if (Exit.isFailure(decoded)) {
46 return { status: "invalid", scripts: NO_SCRIPTS } as const;
47 const file = parseT3ProjectFile(contents);
48 if (file === null) {
49 return { status: "invalid", file: null, scripts: NO_SCRIPTS } as const;
4750 }
48 return { status: "valid", scripts: decoded.value.scripts ?? NO_SCRIPTS } as const;
51 return { status: "valid", file, scripts: file.scripts ?? NO_SCRIPTS } as const;
4952 }, [contents, isPending]);
5053}
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({
1818 os: ExecutionEnvironmentPlatformOs,
1919 arch: ExecutionEnvironmentPlatformArch,
2020});
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;
2129export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
2230
2331/** 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";
44import * as SchemaTransformation from "effect/SchemaTransformation";
55import * as Struct from "effect/Struct";
66import { ProviderOptionSelections } from "./model.ts";
7import { RepositoryIdentity } from "./environment.ts";
7import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
88import {
99 ApprovalRequestId,
1010 CheckpointRef,
......@@ -217,6 +217,9 @@ export const OrchestrationProject = Schema.Struct({
217217 workspaceRoot: TrimmedNonEmptyString,
218218 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
219219 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)),
220223 scripts: Schema.Array(ProjectScript),
221224 createdAt: IsoDateTime,
222225 updatedAt: IsoDateTime,
......@@ -411,6 +414,7 @@ export const OrchestrationProjectShell = Schema.Struct({
411414 workspaceRoot: TrimmedNonEmptyString,
412415 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
413416 defaultModelSelection: Schema.NullOr(ModelSelection),
417 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
414418 scripts: Schema.Array(ProjectScript),
415419 createdAt: IsoDateTime,
416420 updatedAt: IsoDateTime,
......@@ -623,6 +627,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({
623627 title: Schema.optional(TrimmedNonEmptyString),
624628 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
625629 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
630 // Absent = leave unchanged; null = clear the override.
631 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
626632 scripts: Schema.optional(Schema.Array(ProjectScript)),
627633});
628634
......@@ -1078,6 +1084,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
10781084 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
10791085 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
10801086 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
1087 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
10811088 scripts: Schema.optional(Schema.Array(ProjectScript)),
10821089 updatedAt: IsoDateTime,
10831090});
packages/contracts/src/settings.ts+4-2
......@@ -3,6 +3,7 @@ import * as Duration from "effect/Duration";
33import * as Schema from "effect/Schema";
44import * as SchemaTransformation from "effect/SchemaTransformation";
55import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";
6import { ThreadEnvMode } from "./environment.ts";
67import {
78 DEFAULT_TEXT_GENERATION_MODEL,
89 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
......@@ -206,8 +207,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS
206207
207208// ── Server Settings (server-authoritative) ────────────────────
208209
209export const ThreadEnvMode = Schema.Literals(["local", "worktree"]);
210export type ThreadEnvMode = typeof ThreadEnvMode.Type;
210// Moved to environment.ts so orchestration contracts can use it without an
211// import cycle; re-exported here for compatibility with deep imports.
212export { ThreadEnvMode } from "./environment.ts";
211213
212214const makeBinaryPathSetting = (fallback: string) =>
213215 TrimmedString.pipe(
packages/contracts/src/t3ProjectFile.test.ts+6
......@@ -52,4 +52,10 @@ describe("T3ProjectFile", () => {
5252 decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }),
5353 ).toThrow();
5454 });
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 });
5561});
packages/contracts/src/t3ProjectFile.ts+7
......@@ -1,6 +1,7 @@
11import * as Schema from "effect/Schema";
22import * as SchemaTransformation from "effect/SchemaTransformation";
33
4import { ThreadEnvMode } from "./environment.ts";
45import { ProjectScriptIcon } from "./orchestration.ts";
56
67/** File name of the checked-in T3 project file, resolved at the workspace root. */
......@@ -73,6 +74,12 @@ export const T3ProjectFile = Schema.Struct({
7374 T3_PROJECT_FILE_PATH_MAX_LENGTH,
7475 ),
7576 ),
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 ),
7683 scripts: Schema.optionalKey(
7784 Schema.Array(T3ProjectFileScript)
7885 .annotate({
packages/shared/package.json+4
......@@ -91,6 +91,10 @@
9191 "types": "./src/projectScripts.ts",
9292 "import": "./src/projectScripts.ts"
9393 },
94 "./threadEnvMode": {
95 "types": "./src/threadEnvMode.ts",
96 "import": "./src/threadEnvMode.ts"
97 },
9498 "./t3ProjectFile": {
9599 "types": "./src/t3ProjectFile.ts",
96100 "import": "./src/t3ProjectFile.ts"
packages/shared/src/t3ProjectFile.test.ts+25-2
......@@ -1,7 +1,11 @@
11import * as Schema from "effect/Schema";
22import { 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
610const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson);
711
......@@ -27,9 +31,15 @@ describe("buildT3ProjectFileJsonSchema", () => {
2731 required?: ReadonlyArray<string>;
2832 };
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 ]);
3140 expect(schema.required).toBeUndefined();
3241 expect(schema.properties.iconPath?.description).toContain("Workspace-relative path");
42 expect(schema.properties.defaultThreadEnvMode?.description).toContain("new threads start");
3343
3444 const script = schema.properties.scripts?.items;
3545 expect(script?.required).toEqual(["name", "command"]);
......@@ -67,3 +77,16 @@ describe("T3ProjectFileFromJson", () => {
6777 expect(() => decodeJson("{ not json")).toThrow();
6878 });
6979});
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 @@
1import * as Exit from "effect/Exit";
12import * as Schema from "effect/Schema";
23
34import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts";
......@@ -10,6 +11,18 @@ import { fromLenientJson } from "./schemaJson.ts";
1011 */
1112export 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
1326/**
1427 * Build the publishable JSON Schema document for `t3.json` (draft 2020-12).
1528 *
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}