From b27c3d1d415aa6d80ca0dc64094a653ef8fca591 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 17:55:10 -0700 Subject: [PATCH 01/10] feat: per-project default for new worktree vs current checkout Adds an optional defaultThreadEnvMode per project (project.meta.update -> projection column) and an optional defaultThreadEnvMode field in t3.json. New threads resolve: explicit pick > project setting > t3.json > global. No settings UI yet; that ships separately. Co-Authored-By: Claude Fable 5 --- .../threads/new-task-flow-provider.tsx | 28 ++++- .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 7 ++ .../decider.projectThreadEnvMode.test.ts | 103 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 3 + apps/server/src/orchestration/projector.ts | 4 + .../persistence/Layers/ProjectionProjects.ts | 5 + .../Layers/ProjectionRepositories.test.ts | 1 + apps/server/src/persistence/Migrations.ts | 2 + ..._ProjectionProjectsDefaultThreadEnvMode.ts | 16 +++ .../Services/ProjectionProjects.ts | 9 +- apps/web/src/hooks/useHandleNewThread.ts | 38 +++++-- apps/web/src/hooks/useT3ProjectFileScripts.ts | 19 ++-- apps/web/src/lib/t3ProjectFileDefaults.ts | 27 +++++ packages/contracts/src/environment.ts | 8 ++ packages/contracts/src/orchestration.ts | 9 +- packages/contracts/src/settings.ts | 6 +- packages/contracts/src/t3ProjectFile.test.ts | 6 + packages/contracts/src/t3ProjectFile.ts | 7 ++ packages/shared/src/t3ProjectFile.test.ts | 27 ++++- packages/shared/src/t3ProjectFile.ts | 13 +++ 22 files changed, 315 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts create mode 100644 apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts create mode 100644 apps/web/src/lib/t3ProjectFileDefaults.ts diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index e3170eef000..21f3237de43 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { EnvironmentId, ModelSelection, + ProjectReadFileResult, ProviderInteractionMode, ProviderOptionSelection, RuntimeMode, @@ -13,8 +14,10 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, MessageId, + T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -30,6 +33,8 @@ import { } from "../../lib/modelOptions"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, @@ -345,10 +350,27 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; - // The server's configured default decides the mode until the user picks one - // explicitly — same resolution web uses for new draft threads. + // Default mode until the user picks one explicitly — same resolution web + // uses for new draft threads: per-project setting, then the repo's + // checked-in t3.json, then the server's configured default. + const t3ProjectFileQuery = useEnvironmentQuery( + selectedProject !== null && selectedProject.workspaceRoot !== "" + ? projectEnvironment.readFile({ + environmentId: selectedProject.environmentId, + input: { cwd: selectedProject.workspaceRoot, relativePath: T3_PROJECT_FILE_NAME }, + }) + : null, + ); + const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; + const t3ProjectFileDefaultMode = useMemo(() => { + if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; + return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; + }, [t3ProjectFileData]); const defaultWorkspaceMode: WorkspaceMode = - selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; + selectedProject?.defaultThreadEnvMode ?? + t3ProjectFileDefaultMode ?? + selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? + "local"; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 38a70240d97..ef6a4fc9bd9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, + defaultThreadEnvMode: null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c89124751b5..c466819d6bc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -284,6 +284,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", deletedAt: null, @@ -402,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runOnWorktreeCreate: false, }, ], + defaultThreadEnvMode: null, createdAt: "2026-02-24T00:00:00.000Z", updatedAt: "2026-02-24T00:00:01.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e744574a73c..18ac95bccb9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -316,6 +316,7 @@ function mapProjectShellRow( workspaceRoot: row.workspaceRoot, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -391,6 +392,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -844,6 +846,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -866,6 +869,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1542,6 +1546,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1671,6 +1676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, + defaultThreadEnvMode: row.defaultThreadEnvMode, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2162,6 +2168,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: option.value.workspaceRoot, repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, + defaultThreadEnvMode: option.value.defaultThreadEnvMode, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts new file mode 100644 index 00000000000..afee13343ad --- /dev/null +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -0,0 +1,103 @@ +import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-01-01T00:00:00.000Z"; +const projectId = ProjectId.make("project-env-mode"); + +const seedProjectCreated = (sequence: number): OrchestrationEvent => ({ + sequence, + eventId: EventId.make(`evt-project-env-mode-${sequence}`), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: now, + commandId: CommandId.make(`cmd-project-env-mode-${sequence}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-project-env-mode-${sequence}`), + metadata: {}, + payload: { + projectId, + title: "Env mode", + workspaceRoot: "/tmp/env-mode", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, +}); + +it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { + it.effect("propagates defaultThreadEnvMode through meta.update into the read model", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + expect(readModel.projects[0]?.defaultThreadEnvMode).toBeNull(); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + + const event = Array.isArray(result) ? result[0] : result; + expect(event.type).toBe("project.meta-updated"); + expect((event.payload as { defaultThreadEnvMode?: unknown }).defaultThreadEnvMode).toBe( + "worktree", + ); + + const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); + expect(updated.projects[0]?.defaultThreadEnvMode).toBe("worktree"); + }), + ); + + it.effect("omits the field when unset and clears it on explicit null", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + + const unrelated = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-title"), + projectId, + title: "Renamed", + }, + readModel, + }); + const unrelatedEvent = Array.isArray(unrelated) ? unrelated[0] : unrelated; + expect("defaultThreadEnvMode" in (unrelatedEvent.payload as object)).toBe(false); + + const set = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-set"), + projectId, + defaultThreadEnvMode: "worktree", + }, + readModel, + }); + const setEvent = Array.isArray(set) ? set[0] : set; + const afterSet = yield* projectEvent(readModel, { ...setEvent, sequence: 2 }); + + const clear = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-env-mode-clear"), + projectId, + defaultThreadEnvMode: null, + }, + readModel: afterSet, + }); + const clearEvent = Array.isArray(clear) ? clear[0] : clear; + const afterClear = yield* projectEvent(afterSet, { ...clearEvent, sequence: 3 }); + expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3de2592c884..7d749d8f7dc 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -287,6 +287,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), + ...(command.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: command.defaultThreadEnvMode } + : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 5acf3ee6968..8ba7dbed5db 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -214,6 +214,7 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, + defaultThreadEnvMode: null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -246,6 +247,9 @@ export function projectEvent( ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), + ...(payload.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e..c8598bfc7d6 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -35,6 +35,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, default_model_selection_json, + default_thread_env_mode, scripts_json, created_at, updated_at, @@ -45,6 +46,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.title}, ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${row.defaultThreadEnvMode}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -55,6 +57,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, + default_thread_env_mode = excluded.default_thread_env_mode, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -72,6 +75,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -91,6 +95,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 71d7df566fd..bebd8fbb4a7 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + defaultThreadEnvMode: null, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 733c52fab3e..16f8a658cfb 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -51,6 +51,7 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; +import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; /** * Migration loader with all migrations defined inline. @@ -101,6 +102,7 @@ export const migrationEntries = [ [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], [38, "ProjectionThreadsPinOrderKey", Migration0038], + [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts new file mode 100644 index 00000000000..2ac6f78e6de --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "default_thread_env_mode")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN default_thread_env_mode TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a269..6dbd76fc4ba 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,13 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { + IsoDateTime, + ModelSelection, + ProjectId, + ProjectScript, + ThreadEnvMode, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({ title: Schema.String, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 547d8287012..748a140fe9c 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -22,6 +22,7 @@ import { } from "../logicalProject"; import { readThreadShell, useProjects, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; +import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -105,6 +106,20 @@ export function useNewThreadHandler() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + // Default env mode resolves per project: the project's own setting, + // then the repo's checked-in t3.json, then the global setting. The + // t3.json read is skipped entirely when the project setting decides, + // and its query atom caches per project after the first call. + const resolveDefaultEnvMode = async (): Promise => { + if (project?.defaultThreadEnvMode != null) return project.defaultThreadEnvMode; + const fromProjectFile = project + ? await readT3ProjectFileDefaultThreadEnvMode( + project.environmentId, + project.workspaceRoot, + ) + : null; + return fromProjectFile ?? primaryServerSettings.defaultThreadEnvMode; + }; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); @@ -146,7 +161,6 @@ export function useNewThreadHandler() { // preserved. When the draft is already open and no options were // passed, leave it alone entirely — the user may have just picked a // branch in the composer. - const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; const workspaceContext = hasExplicitWorkspaceOption ? { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), @@ -156,15 +170,19 @@ export function useNewThreadHandler() { } : isDraftAlreadyOpen ? null - : { - branch: null, - worktreePath: null, - envMode: defaultEnvMode, - startFromOrigin: resolveNewDraftStartFromOrigin({ + : await (async () => { + const defaultEnvMode = await resolveDefaultEnvMode(); + return { + branch: null, + worktreePath: null, envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, - }), - }; + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: defaultEnvMode, + newWorktreesStartFromOrigin: + primaryServerSettings.newWorktreesStartFromOrigin, + }), + }; + })(); if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, { ...workspaceContext, @@ -244,8 +262,8 @@ export function useNewThreadHandler() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); - const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode; return (async () => { + const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, diff --git a/apps/web/src/hooks/useT3ProjectFileScripts.ts b/apps/web/src/hooks/useT3ProjectFileScripts.ts index 25d015b9468..ce2940153cd 100644 --- a/apps/web/src/hooks/useT3ProjectFileScripts.ts +++ b/apps/web/src/hooks/useT3ProjectFileScripts.ts @@ -1,17 +1,14 @@ import { T3_PROJECT_FILE_NAME, type EnvironmentId, + type T3ProjectFile, type T3ProjectFileScript, } from "@t3tools/contracts"; -import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile"; -import * as Exit from "effect/Exit"; -import * as Schema from "effect/Schema"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { useMemo } from "react"; import { useProjectFileQuery } from "~/components/files/projectFilesQueryState"; -const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); - const NO_SCRIPTS: ReadonlyArray = []; export interface T3ProjectFileState { @@ -23,6 +20,8 @@ export interface T3ProjectFileState { * - `loading`: the file query has not settled yet. */ status: "loading" | "missing" | "invalid" | "valid"; + /** The decoded file when status is `valid`, null otherwise. */ + file: T3ProjectFile | null; scripts: ReadonlyArray; } @@ -39,13 +38,13 @@ export function useT3ProjectFileState( const isPending = query.isPending; return useMemo(() => { if (contents === null) { - return { status: isPending ? "loading" : "missing", scripts: NO_SCRIPTS } as const; + return { status: isPending ? "loading" : "missing", file: null, scripts: NO_SCRIPTS } as const; } - const decoded = decodeT3ProjectFile(contents); - if (Exit.isFailure(decoded)) { - return { status: "invalid", scripts: NO_SCRIPTS } as const; + const file = parseT3ProjectFile(contents); + if (file === null) { + return { status: "invalid", file: null, scripts: NO_SCRIPTS } as const; } - return { status: "valid", scripts: decoded.value.scripts ?? NO_SCRIPTS } as const; + return { status: "valid", file, scripts: file.scripts ?? NO_SCRIPTS } as const; }, [contents, isPending]); } diff --git a/apps/web/src/lib/t3ProjectFileDefaults.ts b/apps/web/src/lib/t3ProjectFileDefaults.ts new file mode 100644 index 00000000000..189fba45455 --- /dev/null +++ b/apps/web/src/lib/t3ProjectFileDefaults.ts @@ -0,0 +1,27 @@ +import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t3tools/contracts"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; + +import { getProjectFileQueryAtom } from "~/components/files/projectFilesQueryState"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; + +/** + * Read `defaultThreadEnvMode` from the project's checked-in `t3.json`. + * + * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread + * path, which resolves defaults at call time rather than render time. The + * file query atom caches per (environment, cwd), so repeat calls don't + * re-fetch. Missing, truncated, or invalid files resolve to null. + */ +export async function readT3ProjectFileDefaultThreadEnvMode( + environmentId: EnvironmentId, + workspaceRoot: string, +): Promise { + const result = await executeAtomQuery( + appAtomRegistry, + getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME), + { reportDefect: false, reportFailure: false }, + ); + if (result._tag !== "Success" || result.value.truncated) return null; + return parseT3ProjectFile(result.value.contents)?.defaultThreadEnvMode ?? null; +} diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 329ff911503..d8330394ce0 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -18,6 +18,14 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ os: ExecutionEnvironmentPlatformOs, arch: ExecutionEnvironmentPlatformArch, }); + +/** + * Where a new thread runs: the project's current checkout ("local") or a + * fresh git worktree ("worktree"). Lives here (not settings.ts) so + * orchestration contracts can reference it without an import cycle. + */ +export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); +export type ThreadEnvMode = typeof ThreadEnvMode.Type; export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; /** How a server can replace itself with another version when asked over RPC. diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 87270d98c1f..ff2ec075432 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -4,7 +4,7 @@ import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import * as Struct from "effect/Struct"; import { ProviderOptionSelections } from "./model.ts"; -import { RepositoryIdentity } from "./environment.ts"; +import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ApprovalRequestId, CheckpointRef, @@ -217,6 +217,9 @@ export const OrchestrationProject = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), + // Per-project override for where new threads start. Null/absent means + // "no override": clients fall back to t3.json, then the global setting. + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -411,6 +414,7 @@ export const OrchestrationProjectShell = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -623,6 +627,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + // Absent = leave unchanged; null = clear the override. + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -1072,6 +1078,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 17ae0e08683..388205649c8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ThreadEnvMode } from "./environment.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, @@ -206,8 +207,9 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientS // ── Server Settings (server-authoritative) ──────────────────── -export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); -export type ThreadEnvMode = typeof ThreadEnvMode.Type; +// Moved to environment.ts so orchestration contracts can use it without an +// import cycle; re-exported here for compatibility with deep imports. +export { ThreadEnvMode } from "./environment.ts"; const makeBinaryPathSetting = (fallback: string) => TrimmedString.pipe( diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index aadafa10d99..ed19c6d6988 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -52,4 +52,10 @@ describe("T3ProjectFile", () => { decode({ scripts: [{ name: "Dev", command: "pnpm dev", icon: "rocket" }] }), ).toThrow(); }); + + it("decodes defaultThreadEnvMode and rejects unknown modes", () => { + expect(decode({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe("worktree"); + expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); + expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); + }); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 755e3f73928..5062a1a370b 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; +import { ThreadEnvMode } from "./environment.ts"; import { ProjectScriptIcon } from "./orchestration.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ @@ -73,6 +74,12 @@ export const T3ProjectFile = Schema.Struct({ T3_PROJECT_FILE_PATH_MAX_LENGTH, ), ), + defaultThreadEnvMode: Schema.optionalKey( + ThreadEnvMode.annotate({ + description: + '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.', + }), + ), scripts: Schema.optionalKey( Schema.Array(T3ProjectFileScript) .annotate({ diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index 7ca1f72eb09..a1986ff35f9 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -1,7 +1,11 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { buildT3ProjectFileJsonSchema, T3ProjectFileFromJson } from "./t3ProjectFile.ts"; +import { + buildT3ProjectFileJsonSchema, + parseT3ProjectFile, + T3ProjectFileFromJson, +} from "./t3ProjectFile.ts"; const decodeJson = Schema.decodeUnknownSync(T3ProjectFileFromJson); @@ -27,9 +31,15 @@ describe("buildT3ProjectFileJsonSchema", () => { required?: ReadonlyArray; }; - expect(Object.keys(schema.properties).sort()).toEqual(["$schema", "iconPath", "scripts"]); + expect(Object.keys(schema.properties).sort()).toEqual([ + "$schema", + "defaultThreadEnvMode", + "iconPath", + "scripts", + ]); expect(schema.required).toBeUndefined(); expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); + expect(schema.properties.defaultThreadEnvMode?.description).toContain("new threads start"); const script = schema.properties.scripts?.items; expect(script?.required).toEqual(["name", "command"]); @@ -67,3 +77,16 @@ describe("T3ProjectFileFromJson", () => { expect(() => decodeJson("{ not json")).toThrow(); }); }); + +describe("parseT3ProjectFile", () => { + it("returns the decoded file for valid contents", () => { + expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "worktree" }')).toEqual({ + defaultThreadEnvMode: "worktree", + }); + }); + + it("returns null for malformed or invalid contents", () => { + expect(parseT3ProjectFile("{ not json")).toBeNull(); + expect(parseT3ProjectFile('{ "defaultThreadEnvMode": "spaceship" }')).toBeNull(); + }); +}); diff --git a/packages/shared/src/t3ProjectFile.ts b/packages/shared/src/t3ProjectFile.ts index 348c3fc4b99..b585c125ae7 100644 --- a/packages/shared/src/t3ProjectFile.ts +++ b/packages/shared/src/t3ProjectFile.ts @@ -1,3 +1,4 @@ +import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; import { T3ProjectFile, T3_PROJECT_FILE_SCHEMA_URL } from "@t3tools/contracts"; @@ -10,6 +11,18 @@ import { fromLenientJson } from "./schemaJson.ts"; */ export const T3ProjectFileFromJson = fromLenientJson(T3ProjectFile); +const decodeT3ProjectFile = Schema.decodeExit(T3ProjectFileFromJson); + +/** + * Decode raw `t3.json` contents, treating invalid or malformed files as + * absent. Clients use this to read optional defaults (scripts, thread env + * mode) without surfacing decode errors to the user. + */ +export function parseT3ProjectFile(contents: string): T3ProjectFile | null { + const decoded = decodeT3ProjectFile(contents); + return Exit.isSuccess(decoded) ? decoded.value : null; +} + /** * Build the publishable JSON Schema document for `t3.json` (draft 2020-12). * From 79ff9439ff52eed87dd3e7ecb7038e38ea9ee7d4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 18:20:54 -0700 Subject: [PATCH 02/10] fix: close review-bot races in default env mode resolution Web: re-check the logical-project draft after the async default resolve so concurrent invocations reuse instead of evicting. Mobile: hold the auto-branch effect until the t3.json default settles so the interim global value never freezes into the draft. Co-Authored-By: Claude Fable 5 --- .../threads/new-task-flow-provider.tsx | 24 +++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 16 +++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 21f3237de43..6e9c9185b90 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -371,6 +371,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { t3ProjectFileDefaultMode ?? selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; + // While the t3.json read is in flight the resolved default is provisional. + // Nothing may write it into the draft during that window (the auto-branch + // effect does), or the frozen interim value beats the file's default once + // it loads. Explicit draft picks and the project setting outrank the file, + // so those settle the mode immediately. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + selectedProject?.defaultThreadEnvMode != null || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; @@ -640,7 +649,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }, [refreshBranches, selectedProject]); useEffect(() => { - if (workspaceMode !== "worktree" || selectedBranchName !== null) { + if ( + !defaultWorkspaceModeSettled || + workspaceMode !== "worktree" || + selectedBranchName !== null + ) { return; } // The default may only exist as origin/ (isRemote), which @@ -652,7 +665,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (preferredBranch) { selectBranch(preferredBranch); } - }, [allBranchRefs, availableBranches, selectBranch, selectedBranchName, workspaceMode]); + }, [ + allBranchRefs, + availableBranches, + defaultWorkspaceModeSettled, + selectBranch, + selectedBranchName, + workspaceMode, + ]); const setRuntimeMode = useCallback( (value: RuntimeMode) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 748a140fe9c..7f4398c7a1e 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -264,6 +264,22 @@ export function useNewThreadHandler() { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); + // The await yields, so a concurrent invocation may have registered a + // draft for this logical project in the meantime. Registering ours + // too would evict that draft while its navigation is in flight — + // reuse the winner instead, like the synchronous path above does. + const racedDraft = getDraftSessionByLogicalProjectKey(logicalProjectKey); + if ( + racedDraft && + readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null + ) { + await router.navigate({ + to: "/draft/$draftId", + params: { draftId: racedDraft.draftId }, + replace: options?.replace ?? false, + }); + return; + } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, From fa4111793f66efd20adbf81e41ff611e44e4ff1d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 18:27:15 -0700 Subject: [PATCH 03/10] fix(web): remap raced draft to caller's project member Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useHandleNewThread.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 7f4398c7a1e..cec8ca3844a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -273,6 +273,20 @@ export function useNewThreadHandler() { racedDraft && readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { + // Same remap the reuse paths above perform: point the draft at the + // caller's project member and apply any explicit workspace options. + // The winner already applied carry state, and both invocations + // derive it from the same viewed thread. + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { + threadId: racedDraft.threadId, + createdAt: racedDraft.createdAt, + runtimeMode: racedDraft.runtimeMode, + interactionMode: racedDraft.interactionMode, + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + }); await router.navigate({ to: "/draft/$draftId", params: { draftId: racedDraft.draftId }, From 3b1a24895e851eafe82c039f70df028e5eb1f62a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 18:30:02 -0700 Subject: [PATCH 04/10] fix(web): raced draft remap uses caller's resolved env defaults Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useHandleNewThread.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cec8ca3844a..c85b12f2d61 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -275,8 +275,10 @@ export function useNewThreadHandler() { ) { // Same remap the reuse paths above perform: point the draft at the // caller's project member and apply any explicit workspace options. - // The winner already applied carry state, and both invocations - // derive it from the same viewed thread. + // Env mode and startFromOrigin come from this invocation's resolved + // values — the winner may target a different project member with + // different defaults. The winner already applied carry state, and + // both invocations derive it from the same viewed thread. setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, @@ -284,8 +286,13 @@ export function useNewThreadHandler() { interactionMode: racedDraft.interactionMode, ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + envMode: initialEnvMode, + startFromOrigin: + options?.startFromOrigin ?? + resolveNewDraftStartFromOrigin({ + envMode: initialEnvMode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), }); await router.navigate({ to: "/draft/$draftId", From c9089b22a40b643714372c7d57fc3572c8603865 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 18:35:32 -0700 Subject: [PATCH 05/10] fix(web): raced draft remap leaves winner context alone without explicit options Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useHandleNewThread.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c85b12f2d61..4558521caea 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -274,11 +274,13 @@ export function useNewThreadHandler() { readThreadShell(scopeThreadRef(racedDraft.environmentId, racedDraft.threadId)) === null ) { // Same remap the reuse paths above perform: point the draft at the - // caller's project member and apply any explicit workspace options. - // Env mode and startFromOrigin come from this invocation's resolved - // values — the winner may target a different project member with - // different defaults. The winner already applied carry state, and - // both invocations derive it from the same viewed thread. + // caller's project member and apply explicit workspace options if + // the caller passed any. Without explicit options the winner's + // context stands untouched — the winner's navigation is landing, + // which is the isDraftAlreadyOpen "leave it alone" case. Writing + // this invocation's defaults here instead would clobber the + // winner's explicit picks and could pair its worktreePath with a + // contradictory envMode. setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, @@ -286,13 +288,8 @@ export function useNewThreadHandler() { interactionMode: racedDraft.interactionMode, ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - envMode: initialEnvMode, - startFromOrigin: - options?.startFromOrigin ?? - resolveNewDraftStartFromOrigin({ - envMode: initialEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, - }), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); await router.navigate({ to: "/draft/$draftId", From 0a9911161216a9221c5b3cb3482cbc2233500a84 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:05:29 -0700 Subject: [PATCH 06/10] feat(web): per-project new-thread workspace in project settings Adds the New threads section to Settings -> Projects, and moves the env-mode priority chain into a shared resolver so web and mobile cannot disagree on the order. Co-Authored-By: Claude Fable 5 --- .../threads/new-task-flow-provider.tsx | 31 +++--- .../settings/ProjectSettingsPanel.tsx | 62 +++++++++++ .../settings/settingsSearch.test.ts | 3 +- .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/hooks/useHandleNewThread.ts | 100 +++++++++--------- packages/shared/package.json | 4 + packages/shared/src/threadEnvMode.test.ts | 65 ++++++++++++ packages/shared/src/threadEnvMode.ts | 36 +++++++ 8 files changed, 243 insertions(+), 63 deletions(-) create mode 100644 packages/shared/src/threadEnvMode.test.ts create mode 100644 packages/shared/src/threadEnvMode.ts diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6e9c9185b90..7d79e9ecead 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -18,6 +18,10 @@ import { ThreadId, } from "@t3tools/contracts"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { + isDefaultThreadEnvModeSettled, + resolveDefaultThreadEnvMode, +} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -366,20 +370,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); - const defaultWorkspaceMode: WorkspaceMode = - selectedProject?.defaultThreadEnvMode ?? - t3ProjectFileDefaultMode ?? - selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? - "local"; - // While the t3.json read is in flight the resolved default is provisional. - // Nothing may write it into the draft during that window (the auto-branch - // effect does), or the frozen interim value beats the file's default once - // it loads. Explicit draft picks and the project setting outrank the file, - // so those settle the mode immediately. - const defaultWorkspaceModeSettled = - selectedProjectDraft.workspaceSelection?.mode !== undefined || - selectedProject?.defaultThreadEnvMode != null || - !t3ProjectFileQuery.isPending; + const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFile: t3ProjectFileDefaultMode, + globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + }); + // While unsettled the resolved default is provisional. Nothing may write + // it into the draft during that window (the auto-branch effect does), or + // the frozen interim value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ + explicitMode: selectedProjectDraft.workspaceSelection?.mode, + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFilePending: t3ProjectFileQuery.isPending, + }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 48a99e421b0..2f8593587ca 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -17,7 +17,9 @@ import type { ProviderDriverKind, SidebarProjectGroupingMode, T3ProjectFileScript, + ThreadEnvMode, } from "@t3tools/contracts"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { useLocation, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; @@ -325,6 +327,7 @@ function ProjectDetail({ async ( input: Partial<{ defaultModelSelection: ModelSelection | null; + defaultThreadEnvMode: ThreadEnvMode | null; scripts: ReadonlyArray>; }>, failureTitle: string, @@ -377,6 +380,17 @@ function ProjectDetail({ [updateAllMembers], ); + // ----- new-thread workspace mode ----- + const storedEnvMode = representative.defaultThreadEnvMode ?? null; + const setDefaultThreadEnvMode = useCallback( + (mode: ThreadEnvMode | null) => + void updateAllMembers( + { defaultThreadEnvMode: mode }, + "Failed to update new-thread workspace", + ), + [updateAllMembers], + ); + // ----- scripts ----- const scripts = representative.scripts; const [editorRequest, setEditorRequest] = useState(null); @@ -385,6 +399,10 @@ function ProjectDetail({ const [isSavingScripts, setIsSavingScripts] = useState(false); const savingScriptsRef = useRef(false); const t3File = useT3ProjectFileState(representative.environmentId, representative.workspaceRoot); + // What the "Default" option resolves to while no override is set: the + // repo's t3.json value when present, otherwise the global setting. + const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; + const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => t3File.scripts.filter( @@ -761,6 +779,50 @@ function ProjectDetail({ /> + + setDefaultThreadEnvMode(null)} + /> + ) : null + } + control={ + + } + /> + + { it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); - expect(searchSettings("work")).toEqual([]); + expect(searchSettings("work").map((item) => item.id)).toEqual(["project-new-thread-workspace"]); + expect(searchSettings("xyzzy")).toEqual([]); }); it("keeps catalog order for multiple title matches", () => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index fb40d9e5641..f2cd5ec3419 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -186,6 +186,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Project default model", to: "/settings/projects", }, + { + id: "project-new-thread-workspace", + title: "Project new-thread workspace", + to: "/settings/projects", + }, { id: "project-scripts", title: "Project scripts", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4558521caea..657a3a50551 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -20,6 +20,7 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; +import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import { readThreadShell, useProjects, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; @@ -28,6 +29,25 @@ import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; +interface NewThreadWorkspaceOptions { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; +} + +// The workspace options the caller passed explicitly, shaped for the draft +// store: absent keys stay absent so they never overwrite existing draft +// state. Every reuse path applies exactly this set. +function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undefined) { + return { + ...(options?.branch !== undefined ? { branch: options.branch } : {}), + ...(options?.worktreePath !== undefined ? { worktreePath: options.worktreePath } : {}), + ...(options?.envMode !== undefined ? { envMode: options.envMode } : {}), + ...(options?.startFromOrigin !== undefined ? { startFromOrigin: options.startFromOrigin } : {}), + }; +} + export function useNewThreadHandler() { const projects = useProjects(); // New-thread defaults are a user preference, and the settings UI only ever @@ -106,19 +126,21 @@ export function useNewThreadHandler() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); - // Default env mode resolves per project: the project's own setting, - // then the repo's checked-in t3.json, then the global setting. The - // t3.json read is skipped entirely when the project setting decides, - // and its query atom caches per project after the first call. + // The shared resolver owns the priority order. The t3.json read is + // skipped entirely when a higher-priority source decides, and its + // query atom caches per project after the first call. const resolveDefaultEnvMode = async (): Promise => { - if (project?.defaultThreadEnvMode != null) return project.defaultThreadEnvMode; - const fromProjectFile = project - ? await readT3ProjectFileDefaultThreadEnvMode( - project.environmentId, - project.workspaceRoot, - ) - : null; - return fromProjectFile ?? primaryServerSettings.defaultThreadEnvMode; + const consultProjectFile = project !== undefined && project.defaultThreadEnvMode == null; + return resolveDefaultThreadEnvMode({ + projectSetting: project?.defaultThreadEnvMode, + projectFile: consultProjectFile + ? await readT3ProjectFileDefaultThreadEnvMode( + project.environmentId, + project.workspaceRoot, + ) + : null, + globalDefault: primaryServerSettings.defaultThreadEnvMode, + }); }; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) @@ -161,28 +183,21 @@ export function useNewThreadHandler() { // preserved. When the draft is already open and no options were // passed, leave it alone entirely — the user may have just picked a // branch in the composer. - const workspaceContext = hasExplicitWorkspaceOption - ? { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), - } - : isDraftAlreadyOpen - ? null - : await (async () => { - const defaultEnvMode = await resolveDefaultEnvMode(); - return { - branch: null, - worktreePath: null, - envMode: defaultEnvMode, - startFromOrigin: resolveNewDraftStartFromOrigin({ - envMode: defaultEnvMode, - newWorktreesStartFromOrigin: - primaryServerSettings.newWorktreesStartFromOrigin, - }), - }; - })(); + let workspaceContext: NewThreadWorkspaceOptions | null = null; + if (hasExplicitWorkspaceOption) { + workspaceContext = pickExplicitWorkspaceOptions(options); + } else if (!isDraftAlreadyOpen) { + const defaultEnvMode = await resolveDefaultEnvMode(); + workspaceContext = { + branch: null, + worktreePath: null, + envMode: defaultEnvMode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: defaultEnvMode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), + }; + } if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, { ...workspaceContext, @@ -239,22 +254,14 @@ export function useNewThreadHandler() { hasEnvModeOption || hasStartFromOriginOption ) { - setDraftThreadContext(currentRouteTarget.draftId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), - }); + setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options)); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { threadId: latestActiveDraftThread.threadId, createdAt: latestActiveDraftThread.createdAt, runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...pickExplicitWorkspaceOptions(options), }); return Promise.resolve(); } @@ -286,10 +293,7 @@ export function useNewThreadHandler() { createdAt: racedDraft.createdAt, runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), + ...pickExplicitWorkspaceOptions(options), }); await router.navigate({ to: "/draft/$draftId", diff --git a/packages/shared/package.json b/packages/shared/package.json index 8cdae3e5160..1cffab6c253 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -91,6 +91,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./threadEnvMode": { + "types": "./src/threadEnvMode.ts", + "import": "./src/threadEnvMode.ts" + }, "./t3ProjectFile": { "types": "./src/t3ProjectFile.ts", "import": "./src/t3ProjectFile.ts" diff --git a/packages/shared/src/threadEnvMode.test.ts b/packages/shared/src/threadEnvMode.test.ts new file mode 100644 index 00000000000..4cf22c24886 --- /dev/null +++ b/packages/shared/src/threadEnvMode.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode } from "./threadEnvMode.ts"; + +describe("resolveDefaultThreadEnvMode", () => { + it("prefers the project setting over t3.json over the global default", () => { + expect( + resolveDefaultThreadEnvMode({ + projectSetting: "local", + projectFile: "worktree", + globalDefault: "worktree", + }), + ).toBe("local"); + expect( + resolveDefaultThreadEnvMode({ + projectSetting: null, + projectFile: "local", + globalDefault: "worktree", + }), + ).toBe("local"); + expect( + resolveDefaultThreadEnvMode({ + projectSetting: undefined, + projectFile: null, + globalDefault: "worktree", + }), + ).toBe("worktree"); + }); +}); + +describe("isDefaultThreadEnvModeSettled", () => { + it("settles on an explicit pick or project setting even while the file loads", () => { + expect( + isDefaultThreadEnvModeSettled({ + explicitMode: "local", + projectSetting: null, + projectFilePending: true, + }), + ).toBe(true); + expect( + isDefaultThreadEnvModeSettled({ + explicitMode: undefined, + projectSetting: "worktree", + projectFilePending: true, + }), + ).toBe(true); + }); + + it("stays unsettled only while a consulted file read is pending", () => { + expect( + isDefaultThreadEnvModeSettled({ + explicitMode: undefined, + projectSetting: null, + projectFilePending: true, + }), + ).toBe(false); + expect( + isDefaultThreadEnvModeSettled({ + explicitMode: undefined, + projectSetting: null, + projectFilePending: false, + }), + ).toBe(true); + }); +}); diff --git a/packages/shared/src/threadEnvMode.ts b/packages/shared/src/threadEnvMode.ts new file mode 100644 index 00000000000..4c01c0f27b9 --- /dev/null +++ b/packages/shared/src/threadEnvMode.ts @@ -0,0 +1,36 @@ +import type { ThreadEnvMode } from "@t3tools/contracts"; + +/** + * Canonical priority order for a project's default thread env mode: + * per-project setting > checked-in t3.json > global server setting. + * + * An explicit composer pick outranks all of these; callers apply it before + * consulting the defaults. Web resolves the sources imperatively at draft + * creation, mobile reactively — both must route through this function so the + * platforms cannot disagree on the order. + */ +export function resolveDefaultThreadEnvMode(sources: { + readonly projectSetting: ThreadEnvMode | null | undefined; + readonly projectFile: ThreadEnvMode | null | undefined; + readonly globalDefault: ThreadEnvMode; +}): ThreadEnvMode { + return sources.projectSetting ?? sources.projectFile ?? sources.globalDefault; +} + +/** + * True once the resolved default can no longer change: an explicit pick or a + * source that outranks t3.json decided, or the file read settled. While + * false, nothing may persist the provisional default (for example into a + * draft's workspace selection) — it could differ from the final value. + */ +export function isDefaultThreadEnvModeSettled(sources: { + readonly explicitMode: ThreadEnvMode | undefined; + readonly projectSetting: ThreadEnvMode | null | undefined; + readonly projectFilePending: boolean; +}): boolean { + return ( + sources.explicitMode !== undefined || + sources.projectSetting != null || + !sources.projectFilePending + ); +} From c469957567ebded0e7d72369f2b1a4d3e776b9bd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:07:58 -0700 Subject: [PATCH 07/10] style: format useT3ProjectFileScripts Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useT3ProjectFileScripts.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useT3ProjectFileScripts.ts b/apps/web/src/hooks/useT3ProjectFileScripts.ts index ce2940153cd..c34f50ca86a 100644 --- a/apps/web/src/hooks/useT3ProjectFileScripts.ts +++ b/apps/web/src/hooks/useT3ProjectFileScripts.ts @@ -38,7 +38,11 @@ export function useT3ProjectFileState( const isPending = query.isPending; return useMemo(() => { if (contents === null) { - return { status: isPending ? "loading" : "missing", file: null, scripts: NO_SCRIPTS } as const; + return { + status: isPending ? "loading" : "missing", + file: null, + scripts: NO_SCRIPTS, + } as const; } const file = parseT3ProjectFile(contents); if (file === null) { From af9d77526a0390fbcb2f0ca9ee35e029e83b1707 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:10:16 -0700 Subject: [PATCH 08/10] fix(web): overlay optimistic t3.json writes in imperative env-mode read Co-Authored-By: Claude Fable 5 --- apps/web/src/lib/t3ProjectFileDefaults.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/t3ProjectFileDefaults.ts b/apps/web/src/lib/t3ProjectFileDefaults.ts index 189fba45455..b6a1b7484b9 100644 --- a/apps/web/src/lib/t3ProjectFileDefaults.ts +++ b/apps/web/src/lib/t3ProjectFileDefaults.ts @@ -2,7 +2,10 @@ import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; -import { getProjectFileQueryAtom } from "~/components/files/projectFilesQueryState"; +import { + getProjectFileQueryAtom, + resolveProjectFileQueryData, +} from "~/components/files/projectFilesQueryState"; import { appAtomRegistry } from "~/rpc/atomRegistry"; /** @@ -11,7 +14,9 @@ import { appAtomRegistry } from "~/rpc/atomRegistry"; * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread * path, which resolves defaults at call time rather than render time. The * file query atom caches per (environment, cwd), so repeat calls don't - * re-fetch. Missing, truncated, or invalid files resolve to null. + * re-fetch. Optimistic in-app writes overlay the query result, matching what + * `useProjectFileQuery` renders. Missing, truncated, or invalid files + * resolve to null. */ export async function readT3ProjectFileDefaultThreadEnvMode( environmentId: EnvironmentId, @@ -22,6 +27,12 @@ export async function readT3ProjectFileDefaultThreadEnvMode( getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME), { reportDefect: false, reportFailure: false }, ); - if (result._tag !== "Success" || result.value.truncated) return null; - return parseT3ProjectFile(result.value.contents)?.defaultThreadEnvMode ?? null; + const data = resolveProjectFileQueryData( + environmentId, + workspaceRoot, + T3_PROJECT_FILE_NAME, + result._tag === "Success" ? result.value : null, + ); + if (data === null || data.truncated) return null; + return parseT3ProjectFile(data.contents)?.defaultThreadEnvMode ?? null; } From 781eb042cf035ba4967ed9fa10aed2f7c3683422 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:18:45 -0700 Subject: [PATCH 09/10] fix(web): stored-draft reuse re-checks state after async default resolve Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useHandleNewThread.ts | 37 +++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 657a3a50551..4c869805e16 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -188,15 +188,28 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); - workspaceContext = { - branch: null, - worktreePath: null, - envMode: defaultEnvMode, - startFromOrigin: resolveNewDraftStartFromOrigin({ + // The await yields. If the draft was opened (a concurrent + // invocation's navigation landed) or promoted to a real thread + // in the meantime, resetting context now would wipe state + // written after the snapshot above — leave it alone, matching + // the isDraftAlreadyOpen case. + const routeTargetNow = getCurrentRouteTarget(); + const openedMeanwhile = + routeTargetNow?.kind === "draft" && + routeTargetNow.draftId === reusableStoredDraftThread.draftId; + const promotedMeanwhile = + storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null; + if (!openedMeanwhile && !promotedMeanwhile) { + workspaceContext = { + branch: null, + worktreePath: null, envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, - }), - }; + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: defaultEnvMode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), + }; + } } if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, { @@ -228,9 +241,13 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); + // Re-read the route: the snapshot from before the await is stale + // once a concurrent invocation's navigation lands, and navigating + // again would push a duplicate history entry. + const routeTargetAfterWrites = getCurrentRouteTarget(); if ( - currentRouteTarget?.kind === "draft" && - currentRouteTarget.draftId === reusableStoredDraftThread.draftId + routeTargetAfterWrites?.kind === "draft" && + routeTargetAfterWrites.draftId === reusableStoredDraftThread.draftId ) { return; } From f682eca77660d1aac0c63bc1cb78ce538eaa7fc1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 8 Aug 2026 19:22:15 -0700 Subject: [PATCH 10/10] fix(web): stale stored-draft loser bails out entirely Co-Authored-By: Claude Fable 5 --- apps/web/src/hooks/useHandleNewThread.ts | 29 +++++++++++++----------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4c869805e16..bd8af376d64 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -190,26 +190,29 @@ export function useNewThreadHandler() { const defaultEnvMode = await resolveDefaultEnvMode(); // The await yields. If the draft was opened (a concurrent // invocation's navigation landed) or promoted to a real thread - // in the meantime, resetting context now would wipe state - // written after the snapshot above — leave it alone, matching - // the isDraftAlreadyOpen case. + // in the meantime, this invocation is a stale loser: resetting + // context, remapping (which wipes branch/worktree on a member + // change), or navigating would all clobber state written after + // the snapshot above. Bail out entirely — the winner already + // did this work. const routeTargetNow = getCurrentRouteTarget(); const openedMeanwhile = routeTargetNow?.kind === "draft" && routeTargetNow.draftId === reusableStoredDraftThread.draftId; const promotedMeanwhile = storedDraftThreadRef !== null && readThreadShell(storedDraftThreadRef) !== null; - if (!openedMeanwhile && !promotedMeanwhile) { - workspaceContext = { - branch: null, - worktreePath: null, - envMode: defaultEnvMode, - startFromOrigin: resolveNewDraftStartFromOrigin({ - envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, - }), - }; + if (openedMeanwhile || promotedMeanwhile) { + return; } + workspaceContext = { + branch: null, + worktreePath: null, + envMode: defaultEnvMode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: defaultEnvMode, + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }), + }; } if (workspaceContext) { setDraftThreadContext(reusableStoredDraftThread.draftId, {