From 4571dcf4ecdd42f6ed3ce4913c82580e581eee8b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 06:58:39 +0000 Subject: [PATCH 01/13] test(web): new threads inherit the target project's last-used composer settings Failing tests for #6508. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hooks/useHandleNewThread.test.ts | 106 +++++++++++++++++- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 5abfd07688496..84ac6ab14d6e7 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -11,6 +11,8 @@ const testState = vi.hoisted(() => { defaultModelSelection: null, defaultRuntimeMode: "full-access" as RuntimeMode, }; + let threadShells: ReadonlyArray> = []; + let routeTarget: unknown = null; let storedDraft: { readonly draftId: string; readonly environmentId: string; @@ -46,6 +48,16 @@ const testState = vi.hoisted(() => { get projectRemoved() { return projectRemoved; }, + get threadShells() { + return threadShells; + }, + get routeTarget() { + return routeTarget; + }, + viewing(nextRouteTarget: unknown, nextThreadShells: ReadonlyArray>) { + routeTarget = nextRouteTarget; + threadShells = nextThreadShells; + }, removeProject() { projectRemoved = true; }, @@ -61,6 +73,8 @@ const testState = vi.hoisted(() => { ) { storedDraft = nextStoredDraft; projectRemoved = false; + threadShells = []; + routeTarget = null; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, @@ -71,6 +85,7 @@ const testState = vi.hoisted(() => { router.navigate.mockClear(); draftStore.setDraftThreadContext.mockClear(); draftStore.setLogicalProjectDraftThreadId.mockClear(); + draftStore.setModelSelection.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -156,7 +171,6 @@ vi.mock("../composerDraftStore", () => { vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ ...(await importOriginal()), hasExplicitComposerModelSelection: () => false, - resolveNewThreadModelSelectionOverride: () => null, })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, @@ -181,8 +195,9 @@ vi.mock("../state/entities", () => { return { readProject: () => (testState.projectRemoved ? null : project), readProjects: () => (testState.projectRemoved ? [] : [project]), - readThreadShell: () => null, - readThreadShells: () => [], + readThreadShell: (ref: { readonly threadId: string }) => + testState.threadShells.find((thread) => thread.id === ref.threadId) ?? null, + readThreadShells: () => testState.threadShells, useProjects: () => [], useThread: () => null, }; @@ -191,7 +206,7 @@ vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", })); -vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => testState.routeTarget })); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", useUiStateStore: () => [], @@ -243,4 +258,87 @@ describe.each([ expect(testState.router.navigate).not.toHaveBeenCalled(); expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); + + it("seeds the draft with the target project's last-used settings, not its workspace", async () => { + testState.reset(draft); + const lastUsedSelection = { + instanceId: "codex", + model: "gpt-6.1-sol", + options: [{ id: "reasoningEffort", value: "high" }], + }; + testState.viewing(null, [ + { + id: "thread-last-used", + environmentId: "environment-ssh", + projectId: "project-remote", + modelSelection: lastUsedSelection, + runtimeMode: "auto", + interactionMode: "plan", + branch: "feature/last-used", + worktreePath: "/remote/project/.worktrees/last-used", + updatedAt: "2026-10-21T10:00:00.000Z", + }, + ]); + const pendingOpen = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenLastCalledWith( + "remote-project", + expect.anything(), + expect.any(String), + expect.objectContaining({ + runtimeMode: "auto", + interactionMode: "plan", + branch: null, + worktreePath: null, + envMode: "local", + }), + ); + expect(testState.draftStore.setModelSelection).toHaveBeenLastCalledWith( + expect.any(String), + lastUsedSelection, + expect.anything(), + ); + }); + + it("does not carry the viewed thread's settings into a different project", async () => { + testState.reset(draft); + testState.viewing( + { + kind: "server", + threadRef: { environmentId: "environment-primary", threadId: "thread-other-project" }, + }, + [ + { + id: "thread-other-project", + environmentId: "environment-primary", + projectId: "project-other", + modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5", options: [] }, + runtimeMode: "approval-required", + interactionMode: "plan", + branch: null, + worktreePath: null, + updatedAt: "2026-10-21T10:00:00.000Z", + }, + ], + ); + const pendingOpen = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setModelSelection).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ interactionMode: "plan" }), + ); + }); }); From 9602b0e180c5408e0f6c2c632905ba865327ab4a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:06:21 +0000 Subject: [PATCH 02/13] fix(web): new threads start from the target project's last-used settings A new thread took its model from the viewed thread even across projects, reset its runtime mode to the project default, and otherwise fell back to the global sticky model. New threads on web and mobile now resolve, in order: the draft's own picks, a configured default model or project runtime mode, the viewed thread when it belongs to the target project, the target project's most recently used thread, then global defaults. Workspace settings never carry. Co-Authored-By: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 28 ++++++- apps/mobile/src/lib/modelOptions.test.ts | 12 ++- apps/mobile/src/lib/modelOptions.ts | 2 + apps/web/src/hooks/useHandleNewThread.test.ts | 80 ++++++++++++++++++- apps/web/src/hooks/useHandleNewThread.ts | 46 +++++++++-- packages/client-runtime/package.json | 4 + .../src/state/projectLastUsedSettings.test.ts | 66 +++++++++++++++ .../src/state/projectLastUsedSettings.ts | 55 +++++++++++++ 8 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 packages/client-runtime/src/state/projectLastUsedSettings.test.ts create mode 100644 packages/client-runtime/src/state/projectLastUsedSettings.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 23618df9c8e60..00800a3a32652 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -102,6 +102,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; import { @@ -530,9 +531,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = isGitRepo && (draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin); + // A queued task keeps its own settings; a new one starts from the project's + // most recently used thread. + const lastUsed = + editingPendingTask || !selectedProject + ? null + : resolveProjectLastUsedSettings(threads, { + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }); const defaultRuntimeMode = editingPendingTask ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) - : projectSettings.settings.defaultRuntimeMode; + : ((projectSettings.sources.defaultRuntimeMode === "project" ? null : lastUsed?.runtimeMode) ?? + projectSettings.settings.defaultRuntimeMode); const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode; // Antigravity keeps unavailable selections so sign-out or a catalog change @@ -547,6 +558,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { projectSettings.settings.defaultModelSelection, selectedProject?.id ?? null, ); + const lastUsedModelSelection = resolveDefaultableModelSelection( + selectedEnvironmentServerConfig, + lastUsed?.modelSelection ?? null, + selectedProject?.id ?? null, + ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, @@ -555,7 +571,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const modelOptions = buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection, + draftModelSelection ?? + projectDefaultModelSelection ?? + lastUsedModelSelection ?? + stickyModelSelection, undefined, selectedProject?.id ?? null, ); @@ -563,6 +582,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, + lastUsedSelection: lastUsedModelSelection, stickySelection: stickyModelSelection, modelOptions, }); @@ -584,7 +604,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const planModeEnabled = legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false; const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE) + ? (selectedProjectDraft.interactionMode ?? + lastUsed?.interactionMode ?? + DEFAULT_PROVIDER_INTERACTION_MODE) : DEFAULT_PROVIDER_INTERACTION_MODE; const setSelectedModelKey = ( key: string | null, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 11b9b0603be84..dc79e84983ac8 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -538,6 +538,7 @@ describe("mobile model options", () => { it("resolves new tasks from draft, project, sticky, then provider defaults", () => { const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; + const lastUsed = { instanceId: ProviderInstanceId.make("codex"), model: "last-used" }; const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; const providerDefault = { selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, @@ -546,19 +547,22 @@ describe("mobile model options", () => { const resolve = ( draftSelection: ModelSelection | null, projectDefaultSelection: ModelSelection | null, + lastUsedSelection: ModelSelection | null, stickySelection: ModelSelection | null, ) => resolveNewTaskModelSelection({ draftSelection, projectDefaultSelection, + lastUsedSelection, stickySelection, modelOptions: [providerDefault], }); - expect(resolve(draft, project, sticky)).toBe(draft); - expect(resolve(null, project, sticky)).toBe(project); - expect(resolve(null, null, sticky)).toBe(sticky); - expect(resolve(null, null, null)).toBe(providerDefault.selection); + expect(resolve(draft, project, lastUsed, sticky)).toBe(draft); + expect(resolve(null, project, lastUsed, sticky)).toBe(project); + expect(resolve(null, null, lastUsed, sticky)).toBe(lastUsed); + expect(resolve(null, null, null, sticky)).toBe(sticky); + expect(resolve(null, null, null, null)).toBe(providerDefault.selection); const unavailable = { ...providerDefault, isUnavailable: true }; expect( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4f1b92befaa3f..a09784ba58906 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -224,12 +224,14 @@ export function resolveDefaultableModelSelection( export function resolveNewTaskModelSelection(input: { readonly draftSelection: ModelSelection | null; readonly projectDefaultSelection: ModelSelection | null; + readonly lastUsedSelection?: ModelSelection | null; readonly stickySelection: ModelSelection | null; readonly modelOptions: ReadonlyArray; }): ModelSelection | null { return ( input.draftSelection ?? input.projectDefaultSelection ?? + input.lastUsedSelection ?? input.stickySelection ?? input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ?? input.modelOptions.find((option) => !option.isUnavailable)?.selection ?? diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 84ac6ab14d6e7..1edd1f521108c 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -13,6 +13,7 @@ const testState = vi.hoisted(() => { }; let threadShells: ReadonlyArray> = []; let routeTarget: unknown = null; + let runtimeModeSource: "environment" | "project" = "environment"; let storedDraft: { readonly draftId: string; readonly environmentId: string; @@ -29,7 +30,7 @@ const testState = vi.hoisted(() => { }), }; const draftStore = { - getComposerDraft: vi.fn(() => ({})), + getComposerDraft: vi.fn((): Record => ({})), getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), getDraftSession: vi.fn(() => null), getDraftThread: vi.fn(() => null), @@ -37,6 +38,7 @@ const testState = vi.hoisted(() => { setDraftThreadContext: vi.fn(), setLogicalProjectDraftThreadId: vi.fn(), setModelSelection: vi.fn(), + setRuntimeMode: vi.fn(), }; return { @@ -54,6 +56,13 @@ const testState = vi.hoisted(() => { get routeTarget() { return routeTarget; }, + get runtimeModeSource() { + return runtimeModeSource; + }, + configureProjectRuntimeMode(mode: RuntimeMode) { + targetSettings = { ...targetSettings, defaultRuntimeMode: mode }; + runtimeModeSource = "project"; + }, viewing(nextRouteTarget: unknown, nextThreadShells: ReadonlyArray>) { routeTarget = nextRouteTarget; threadShells = nextThreadShells; @@ -75,6 +84,7 @@ const testState = vi.hoisted(() => { projectRemoved = false; threadShells = []; routeTarget = null; + runtimeModeSource = "environment"; targetSettings = { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, @@ -86,6 +96,9 @@ const testState = vi.hoisted(() => { draftStore.setDraftThreadContext.mockClear(); draftStore.setLogicalProjectDraftThreadId.mockClear(); draftStore.setModelSelection.mockClear(); + draftStore.setRuntimeMode.mockClear(); + draftStore.getComposerDraft.mockReset(); + draftStore.getComposerDraft.mockReturnValue({}); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -142,7 +155,11 @@ vi.mock("@t3tools/shared/projectSettings", () => ({ defaultThreadEnvMode: settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local", }, - sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, + sources: { + defaultModelSelection: "environment", + defaultThreadEnvMode: "environment", + defaultRuntimeMode: testState.runtimeModeSource, + }, overrides: {}, }), resolveNewThreadEnvMode: (input: { @@ -342,3 +359,62 @@ describe.each([ ); }); }); + +describe("useNewThreadHandler runtime mode precedence", () => { + const lastUsedThread = { + id: "thread-last-used", + environmentId: "environment-ssh", + projectId: "project-remote", + modelSelection: { instanceId: "codex", model: "gpt-6.1-sol", options: [] }, + runtimeMode: "auto", + interactionMode: "default", + branch: null, + worktreePath: null, + updatedAt: "2026-10-21T10:00:00.000Z", + }; + const openTarget = async () => { + const pendingOpen = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + testState.completeProjectFileRead(null); + await pendingOpen; + }; + + it("lets a project-configured runtime mode win over the last used one", async () => { + testState.reset(null); + testState.configureProjectRuntimeMode("approval-required"); + testState.viewing(null, [lastUsedThread]); + await openTarget(); + + expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenLastCalledWith( + "remote-project", + expect.anything(), + expect.any(String), + expect.objectContaining({ runtimeMode: "approval-required" }), + ); + }); + + it("keeps the runtime mode a reused draft already picked", async () => { + testState.reset({ + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }); + testState.draftStore.getComposerDraft.mockReturnValue({ runtimeMode: "approval-required" }); + testState.viewing(null, [lastUsedThread]); + await openTarget(); + + expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); + }); + + it("writes the last used runtime mode where the composer reads it", async () => { + testState.reset(null); + testState.viewing(null, [lastUsedThread]); + await openTarget(); + + expect(testState.draftStore.setRuntimeMode).toHaveBeenCalledWith("draft-delayed", "auto"); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cfb98fc83bd01..0d874a6c304c0 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -29,6 +29,7 @@ import { useProjects, useQuietThreadShell, } from "../state/entities"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, @@ -91,6 +92,7 @@ export function useNewThreadHandler() { setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, + setRuntimeMode, } = useComposerDraftStore.getState(); const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; @@ -110,11 +112,32 @@ export function useNewThreadHandler() { const composerModelSelection = composerActiveProvider ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) : null; - const carryModelSelection = composerModelSelection ?? carrySourceShell?.modelSelection ?? null; + const carrySourceProject = carrySourceShell ?? carrySourceDraft; + // The viewed thread only speaks for its own project; past it, the target + // project's most recently used thread does. + const carryFromRoute = + carrySourceProject?.environmentId === projectRef.environmentId && + carrySourceProject.projectId === projectRef.projectId; + const lastUsed = resolveProjectLastUsedSettings(readThreadShells(), projectRef); + const carryModelSelection = + (carryFromRoute ? (composerModelSelection ?? carrySourceShell?.modelSelection) : null) ?? + lastUsed?.modelSelection ?? + null; const carryInteractionMode = - carrySourceComposer?.interactionMode ?? - carrySourceShell?.interactionMode ?? - carrySourceDraft?.interactionMode ?? + (carryFromRoute + ? (carrySourceComposer?.interactionMode ?? + carrySourceShell?.interactionMode ?? + carrySourceDraft?.interactionMode) + : null) ?? + lastUsed?.interactionMode ?? + null; + // A draft session's runtime mode is not a choice: drafts are created with + // the built-in default and the composer shows the project default instead. + const carryRuntimeMode = + (carryFromRoute + ? (carrySourceComposer?.runtimeMode ?? carrySourceShell?.runtimeMode) + : null) ?? + lastUsed?.runtimeMode ?? null; const project = projects.find( (candidate) => @@ -129,7 +152,16 @@ export function useNewThreadHandler() { project, ); const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; - const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; + const inheritedRuntimeMode = + projectSettings.sources.defaultRuntimeMode === "project" ? null : carryRuntimeMode; + const defaultRuntimeMode = inheritedRuntimeMode ?? projectSettings.settings.defaultRuntimeMode; + // The composer reads a draft's runtime mode from its composer state, so + // an inherited mode is written there too, unless the draft already has a pick. + const applyInheritedRuntimeMode = (destinationDraftId: DraftId) => { + if (inheritedRuntimeMode && !getComposerDraft(destinationDraftId)?.runtimeMode) { + setRuntimeMode(destinationDraftId, inheritedRuntimeMode); + } + }; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ projectDefaultSelection: projectDefaultModelSelection ?? null, @@ -273,6 +305,9 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); + if (!isDraftAlreadyOpen) { + applyInheritedRuntimeMode(emptyStoredDraftThread.draftId); + } const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, @@ -368,6 +403,7 @@ export function useNewThreadHandler() { runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); + applyInheritedRuntimeMode(draftId); applyStickyState(draftId); const modelSelectionOverride = resolveModelSelectionOverride(draftId); if (modelSelectionOverride) { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index cb03114c8f403..486406dab2132 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -283,6 +283,10 @@ "types": "./src/state/subagentRuntime.ts", "default": "./src/state/subagentRuntime.ts" }, + "./state/project-last-used-settings": { + "types": "./src/state/projectLastUsedSettings.ts", + "default": "./src/state/projectLastUsedSettings.ts" + }, "./state/thread-sort": { "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.test.ts b/packages/client-runtime/src/state/projectLastUsedSettings.test.ts new file mode 100644 index 0000000000000..b1a1d3aa8120c --- /dev/null +++ b/packages/client-runtime/src/state/projectLastUsedSettings.test.ts @@ -0,0 +1,66 @@ +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveProjectLastUsedSettings, + type ProjectLastUsedSettingsThread, +} from "./projectLastUsedSettings.ts"; + +const environmentId = EnvironmentId.make("environment-a"); +const projectId = ProjectId.make("project-a"); + +function thread( + model: string, + overrides: Partial = {}, +): ProjectLastUsedSettingsThread { + return { + environmentId, + projectId, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model }, + runtimeMode: "full-access", + interactionMode: "default", + updatedAt: "2026-10-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("resolveProjectLastUsedSettings", () => { + it("uses the project thread the user last wrote in", () => { + const settings = resolveProjectLastUsedSettings( + [ + thread("renamed-recently", { + updatedAt: "2026-10-05T00:00:00.000Z", + latestUserAuthoredMessageAt: "2026-10-02T00:00:00.000Z", + }), + thread("written-recently", { + runtimeMode: "auto", + interactionMode: "plan", + latestUserAuthoredMessageAt: "2026-10-03T00:00:00.000Z", + }), + ], + { environmentId, projectId }, + ); + + expect(settings).toEqual({ + modelSelection: { instanceId: "codex", model: "written-recently" }, + runtimeMode: "auto", + interactionMode: "plan", + }); + }); + + it("ignores other projects, other environments, and subagent threads", () => { + const later = { updatedAt: "2026-10-09T00:00:00.000Z" }; + const settings = resolveProjectLastUsedSettings( + [ + thread("own"), + thread("other-project", { ...later, projectId: ProjectId.make("project-b") }), + thread("other-environment", { ...later, environmentId: EnvironmentId.make("env-b") }), + thread("subagent", { ...later, lineage: { relationshipToParent: "subagent" } }), + ], + { environmentId, projectId }, + ); + + expect(settings?.modelSelection.model).toBe("own"); + expect(resolveProjectLastUsedSettings([], { environmentId, projectId })).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.ts b/packages/client-runtime/src/state/projectLastUsedSettings.ts new file mode 100644 index 0000000000000..a0b339f88dc83 --- /dev/null +++ b/packages/client-runtime/src/state/projectLastUsedSettings.ts @@ -0,0 +1,55 @@ +import type { + EnvironmentId, + ModelSelection, + ProjectId, + ProviderInteractionMode, + RuntimeMode, +} from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "./models.ts"; + +export type ProjectLastUsedSettingsThread = Pick< + EnvironmentThreadShell, + "environmentId" | "projectId" | "modelSelection" | "runtimeMode" | "interactionMode" | "updatedAt" +> & { + readonly latestUserAuthoredMessageAt?: string | null; + readonly lineage?: Pick; +}; + +export interface ProjectLastUsedSettings { + readonly modelSelection: ModelSelection; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +/** + * The composer settings of the project's most recently used thread. Agents + * pick subagent settings, so those threads say nothing about the user's choice. + */ +export function resolveProjectLastUsedSettings( + threads: ReadonlyArray, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): ProjectLastUsedSettings | null { + let latest: ProjectLastUsedSettingsThread | null = null; + let latestAt = ""; + for (const thread of threads) { + if ( + thread.environmentId !== project.environmentId || + thread.projectId !== project.projectId || + thread.lineage?.relationshipToParent === "subagent" + ) { + continue; + } + const usedAt = thread.latestUserAuthoredMessageAt ?? thread.updatedAt; + if (latest === null || usedAt > latestAt) { + latest = thread; + latestAt = usedAt; + } + } + return latest === null + ? null + : { + modelSelection: latest.modelSelection, + runtimeMode: latest.runtimeMode, + interactionMode: latest.interactionMode, + }; +} From 552b04efdd2ba43e8877002577f854ef22adb94d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:13:44 +0000 Subject: [PATCH 03/13] fix(web,mobile): new threads inherit only the user's last-used settings The last-used lookup ranked threads only automation wrote in (null latestUserAuthoredMessageAt) by updatedAt, so a scheduled or MCP-launched thread could beat the user's own. Those are now skipped, and the updatedAt fallback only applies on servers that omit the field. Web still carried settings from the viewed thread when it was in the same project, which let a Full-access subagent or an older thread override the project's last-used settings. New drafts now take only the target project's last-used settings; picks already made in the destination draft still win. Mobile showed an inherited Plan mode but submitted Default for an untouched draft. Display and submission now resolve through the same function. Co-Authored-By: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 16 +++-- .../mobile/src/state/legacy-plan-mode.test.ts | 47 ++++++++++++++ apps/mobile/src/state/legacy-plan-mode.ts | 8 ++- apps/web/src/hooks/useHandleNewThread.test.ts | 63 +++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 47 ++------------ .../src/state/projectLastUsedSettings.test.ts | 34 ++++++++++ .../src/state/projectLastUsedSettings.ts | 29 ++++++--- 7 files changed, 188 insertions(+), 56 deletions(-) create mode 100644 apps/mobile/src/state/legacy-plan-mode.test.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 00800a3a32652..f1aa75efa8293 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -603,11 +603,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) ?? null; const planModeEnabled = legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false; - const interactionMode = planModeEnabled - ? (selectedProjectDraft.interactionMode ?? - lastUsed?.interactionMode ?? - DEFAULT_PROVIDER_INTERACTION_MODE) - : DEFAULT_PROVIDER_INTERACTION_MODE; + // Display and submission resolve through the same function so an untouched + // draft sends the mode it shows. + const interactionMode = resolvePendingTaskInteractionMode({ + preferenceLoaded: planModePreferenceLoaded, + planModeEnabled: legacyPlanModeEnabled, + draftInteractionMode: selectedProjectDraft.interactionMode, + inheritedInteractionMode: lastUsed?.interactionMode, + queuedInteractionMode: editingPendingTask?.interactionMode, + provider: selectedProviderStatus, + }); const setSelectedModelKey = ( key: string | null, options?: ReadonlyArray, @@ -1037,6 +1042,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { preferenceLoaded: planModePreferenceLoaded, planModeEnabled: legacyPlanModeEnabled, draftInteractionMode: draft.interactionMode, + inheritedInteractionMode: lastUsed?.interactionMode, queuedInteractionMode: editingPendingTask?.interactionMode, provider: selectedEnvironmentServerConfig?.providers.find( (candidate) => candidate.instanceId === draftModelSelection.instanceId, diff --git a/apps/mobile/src/state/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts new file mode 100644 index 0000000000000..b12197f3ed2ed --- /dev/null +++ b/apps/mobile/src/state/legacy-plan-mode.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; + +describe("resolvePendingTaskInteractionMode", () => { + const untouchedNewTask = { + preferenceLoaded: true, + planModeEnabled: true, + draftInteractionMode: undefined, + queuedInteractionMode: undefined, + inheritedInteractionMode: "plan", + } as const; + + it("sends an untouched new task in the mode it inherited", () => { + expect(resolvePendingTaskInteractionMode(untouchedNewTask)).toBe("plan"); + }); + + it("lets the draft's own pick win over the inherited mode", () => { + expect( + resolvePendingTaskInteractionMode({ ...untouchedNewTask, draftInteractionMode: "default" }), + ).toBe("default"); + }); + + it("drops an inherited plan mode the provider or preference cannot use", () => { + expect( + resolvePendingTaskInteractionMode({ + ...untouchedNewTask, + provider: { showInteractionModeToggle: false }, + }), + ).toBe("default"); + expect(resolvePendingTaskInteractionMode({ ...untouchedNewTask, planModeEnabled: false })).toBe( + "default", + ); + }); + + it("keeps a queued task's mode while the preference loads", () => { + expect( + resolvePendingTaskInteractionMode({ + ...untouchedNewTask, + preferenceLoaded: false, + planModeEnabled: false, + inheritedInteractionMode: null, + queuedInteractionMode: "plan", + }), + ).toBe("plan"); + }); +}); diff --git a/apps/mobile/src/state/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts index d768d4dac351c..1f8b2f0c66afe 100644 --- a/apps/mobile/src/state/legacy-plan-mode.ts +++ b/apps/mobile/src/state/legacy-plan-mode.ts @@ -26,6 +26,8 @@ export function resolvePendingTaskInteractionMode(input: { readonly preferenceLoaded: boolean; readonly planModeEnabled: boolean; readonly draftInteractionMode: ProviderInteractionMode | undefined; + /** The mode a new task starts in when its draft has no pick of its own. */ + readonly inheritedInteractionMode?: ProviderInteractionMode | null; readonly queuedInteractionMode: ProviderInteractionMode | undefined; readonly provider?: InteractionModeProvider | null; }): ProviderInteractionMode { @@ -33,7 +35,11 @@ export function resolvePendingTaskInteractionMode(input: { return DEFAULT_PROVIDER_INTERACTION_MODE; } if (input.planModeEnabled) { - return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; + return ( + input.draftInteractionMode ?? + input.inheritedInteractionMode ?? + DEFAULT_PROVIDER_INTERACTION_MODE + ); } if (!input.preferenceLoaded) { return input.queuedInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 1edd1f521108c..dd456ea3b4578 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -358,6 +358,69 @@ describe.each([ expect.objectContaining({ interactionMode: "plan" }), ); }); + it("prefers the project's last user-used thread over the viewed one", async () => { + testState.reset(draft); + const sameProjectThread = { + environmentId: "environment-ssh", + projectId: "project-remote", + interactionMode: "default", + branch: null, + worktreePath: null, + }; + const userSelection = { instanceId: "codex", model: "gpt-6.1-sol", options: [] }; + const viewed = (threadId: string) => ({ + kind: "server", + threadRef: { environmentId: "environment-ssh", threadId }, + }); + const threads = [ + { + ...sameProjectThread, + id: "thread-subagent", + modelSelection: { instanceId: "codex", model: "gpt-6.1-mini", options: [] }, + runtimeMode: "full-access", + lineage: { relationshipToParent: "subagent" }, + updatedAt: "2026-10-21T12:00:00.000Z", + latestUserAuthoredMessageAt: null, + }, + { + ...sameProjectThread, + id: "thread-older", + modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5", options: [] }, + runtimeMode: "approval-required", + updatedAt: "2026-10-20T10:00:00.000Z", + latestUserAuthoredMessageAt: "2026-10-20T10:00:00.000Z", + }, + { + ...sameProjectThread, + id: "thread-user", + modelSelection: userSelection, + runtimeMode: "auto", + updatedAt: "2026-10-21T10:00:00.000Z", + latestUserAuthoredMessageAt: "2026-10-21T10:00:00.000Z", + }, + ]; + + for (const threadId of ["thread-subagent", "thread-older"]) { + testState.reset(draft); + testState.viewing(viewed(threadId), threads); + const pendingOpen = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setRuntimeMode).toHaveBeenLastCalledWith( + expect.any(String), + "auto", + ); + expect(testState.draftStore.setModelSelection).toHaveBeenLastCalledWith( + expect.any(String), + userSelection, + expect.anything(), + ); + } + }); }); describe("useNewThreadHandler runtime mode precedence", () => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0d874a6c304c0..62e8b472890b7 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -97,48 +97,13 @@ export function useNewThreadHandler() { const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); - const carrySourceShell = - currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) : null; - const carrySourceDraft = - currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null; - const carrySourceComposer = currentRouteTarget - ? getComposerDraft( - currentRouteTarget.kind === "server" - ? currentRouteTarget.threadRef - : currentRouteTarget.draftId, - ) - : null; - const composerActiveProvider = carrySourceComposer?.activeProvider ?? null; - const composerModelSelection = composerActiveProvider - ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) - : null; - const carrySourceProject = carrySourceShell ?? carrySourceDraft; - // The viewed thread only speaks for its own project; past it, the target - // project's most recently used thread does. - const carryFromRoute = - carrySourceProject?.environmentId === projectRef.environmentId && - carrySourceProject.projectId === projectRef.projectId; + // A new thread starts from the target project's last user-used settings, + // not from whatever thread happens to be on screen; picks already made + // in the destination draft are kept below. const lastUsed = resolveProjectLastUsedSettings(readThreadShells(), projectRef); - const carryModelSelection = - (carryFromRoute ? (composerModelSelection ?? carrySourceShell?.modelSelection) : null) ?? - lastUsed?.modelSelection ?? - null; - const carryInteractionMode = - (carryFromRoute - ? (carrySourceComposer?.interactionMode ?? - carrySourceShell?.interactionMode ?? - carrySourceDraft?.interactionMode) - : null) ?? - lastUsed?.interactionMode ?? - null; - // A draft session's runtime mode is not a choice: drafts are created with - // the built-in default and the composer shows the project default instead. - const carryRuntimeMode = - (carryFromRoute - ? (carrySourceComposer?.runtimeMode ?? carrySourceShell?.runtimeMode) - : null) ?? - lastUsed?.runtimeMode ?? - null; + const carryModelSelection = lastUsed?.modelSelection ?? null; + const carryInteractionMode = lastUsed?.interactionMode ?? null; + const carryRuntimeMode = lastUsed?.runtimeMode ?? null; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.test.ts b/packages/client-runtime/src/state/projectLastUsedSettings.test.ts index b1a1d3aa8120c..eae52de947a46 100644 --- a/packages/client-runtime/src/state/projectLastUsedSettings.test.ts +++ b/packages/client-runtime/src/state/projectLastUsedSettings.test.ts @@ -48,6 +48,40 @@ describe("resolveProjectLastUsedSettings", () => { }); }); + it("skips threads the user never wrote in, however recent", () => { + const settings = resolveProjectLastUsedSettings( + [ + thread("user-written", { + runtimeMode: "auto", + latestUserAuthoredMessageAt: "2026-10-02T00:00:00.000Z", + }), + thread("scheduled-run", { + updatedAt: "2026-10-09T00:00:00.000Z", + latestUserAuthoredMessageAt: null, + }), + thread("legacy-recent", { updatedAt: "2026-10-08T00:00:00.000Z" }), + ], + { environmentId, projectId }, + ); + + expect(settings?.modelSelection.model).toBe("user-written"); + expect( + resolveProjectLastUsedSettings([thread("automated", { latestUserAuthoredMessageAt: null })], { + environmentId, + projectId, + }), + ).toBeNull(); + }); + + it("falls back to the latest update on servers without authored timestamps", () => { + const settings = resolveProjectLastUsedSettings( + [thread("older"), thread("newer", { updatedAt: "2026-10-03T00:00:00.000Z" })], + { environmentId, projectId }, + ); + + expect(settings?.modelSelection.model).toBe("newer"); + }); + it("ignores other projects, other environments, and subagent threads", () => { const later = { updatedAt: "2026-10-09T00:00:00.000Z" }; const settings = resolveProjectLastUsedSettings( diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.ts b/packages/client-runtime/src/state/projectLastUsedSettings.ts index a0b339f88dc83..8f353f172314a 100644 --- a/packages/client-runtime/src/state/projectLastUsedSettings.ts +++ b/packages/client-runtime/src/state/projectLastUsedSettings.ts @@ -22,29 +22,40 @@ export interface ProjectLastUsedSettings { } /** - * The composer settings of the project's most recently used thread. Agents - * pick subagent settings, so those threads say nothing about the user's choice. + * The composer settings of the project thread the user last wrote in. Agents + * pick subagent settings, and a null latestUserAuthoredMessageAt marks a + * thread only automation has written in, so neither says anything about the + * user's choice. Servers that predate the field omit it; their threads fall + * back to updatedAt and only count when no thread carries the field. */ export function resolveProjectLastUsedSettings( threads: ReadonlyArray, project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, ): ProjectLastUsedSettings | null { - let latest: ProjectLastUsedSettingsThread | null = null; - let latestAt = ""; + let authored: ProjectLastUsedSettingsThread | null = null; + let authoredAt = ""; + let legacy: ProjectLastUsedSettingsThread | null = null; + let legacyAt = ""; for (const thread of threads) { if ( thread.environmentId !== project.environmentId || thread.projectId !== project.projectId || - thread.lineage?.relationshipToParent === "subagent" + thread.lineage?.relationshipToParent === "subagent" || + thread.latestUserAuthoredMessageAt === null ) { continue; } - const usedAt = thread.latestUserAuthoredMessageAt ?? thread.updatedAt; - if (latest === null || usedAt > latestAt) { - latest = thread; - latestAt = usedAt; + if (thread.latestUserAuthoredMessageAt === undefined) { + if (legacy === null || thread.updatedAt > legacyAt) { + legacy = thread; + legacyAt = thread.updatedAt; + } + } else if (authored === null || thread.latestUserAuthoredMessageAt > authoredAt) { + authored = thread; + authoredAt = thread.latestUserAuthoredMessageAt; } } + const latest = authored ?? legacy; return latest === null ? null : { From 8b2a2b8ca3c1069b0c981e40688f3eda63effd2b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:16:10 +0000 Subject: [PATCH 04/13] docs: describe per-project new-thread preferences --- docs/user/composer.md | 7 ++++--- docs/user/thread-sidebar.md | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index bdb67cae4b31c..1f1c0ef30de71 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -107,9 +107,10 @@ instance: ## Model defaults -T3 Code remembers your provider, model, and model options for new threads. A -project's configured model takes precedence; resetting that project setting -returns to the remembered selection. +T3 Code remembers the provider, model, and model options you last used in each +project for new threads. A project's configured model takes precedence; resetting +that project setting returns to its remembered selection. A project with no +previously used thread falls back to your general defaults. Leaving reasoning level or service tier unset uses the provider's own configuration. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c64e95ec52931..065c55c6403ed 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -5,9 +5,9 @@ need a separate branch and working directory. ## Start a thread -On web and desktop, a new thread keeps the current project and carries your model -and mode selections, unless the destination project has its own model default. -Its branch and workspace mode come from your configured defaults. To continue in +New threads remember the target project's last-used model and modes. Choices you +already made in a draft and configured project defaults take precedence. +Branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. When you change a new thread's project, T3 Code stays in the current environment From 4837d5bc3d91b0daabc268643a8a751e4368cceb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:24:23 +0000 Subject: [PATCH 05/13] fix(web,mobile): reused drafts refresh inherited modes; environment defaults follow project history An inherited runtime mode was written into composer state, where it looked like an explicit pick and outranked every later default, so a reused empty draft kept its first inherited mode forever. Composer state now holds only the user's picks; the draft session carries the inherited mode, which ChatView reads for drafts as it already did for interaction mode, and reopening a draft refreshes it. An environment-wide default model was treated as a project default and beat the project's last-used model. Only settings configured on the project itself win now; the environment default applies after project history, on web and mobile. The draft project picker uses the same target-project defaults when it retargets a draft that keeps its content. Co-Authored-By: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 12 ++- apps/mobile/src/lib/modelOptions.test.ts | 7 +- apps/mobile/src/lib/modelOptions.ts | 2 + apps/web/src/components/ChatView.tsx | 7 +- .../src/components/chat/DraftHeroHeadline.tsx | 44 +++++---- apps/web/src/hooks/useHandleNewThread.test.ts | 72 ++++++++++----- apps/web/src/hooks/useHandleNewThread.ts | 60 ++++--------- apps/web/src/lib/chatThreadActions.test.ts | 90 +++++++++++++------ apps/web/src/lib/chatThreadActions.ts | 38 +++++--- 9 files changed, 210 insertions(+), 122 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 f1aa75efa8293..f0e5b58e87c2b 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -553,11 +553,19 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); - const projectDefaultModelSelection = resolveDefaultableModelSelection( + // A model configured on the project wins over its history; the + // environment's default only applies after the project's history. + const configuredDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, selectedProject?.id ?? null, ); + const projectDefaultModelSelection = + projectSettings.sources.defaultModelSelection === "environment" + ? null + : configuredDefaultModelSelection; + const environmentDefaultModelSelection = + projectDefaultModelSelection === null ? configuredDefaultModelSelection : null; const lastUsedModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, lastUsed?.modelSelection ?? null, @@ -574,6 +582,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { draftModelSelection ?? projectDefaultModelSelection ?? lastUsedModelSelection ?? + environmentDefaultModelSelection ?? stickyModelSelection, undefined, selectedProject?.id ?? null, @@ -583,6 +592,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, lastUsedSelection: lastUsedModelSelection, + environmentDefaultSelection: environmentDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, }); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index dc79e84983ac8..8cfecdeab860a 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -535,10 +535,11 @@ describe("mobile model options", () => { expect(resolveDefaultableModelSelection(null, legacy, null)).toBe(legacy); }); - it("resolves new tasks from draft, project, sticky, then provider defaults", () => { + it("resolves new tasks from draft, project, last-used, environment, sticky, then provider defaults", () => { const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" }; const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" }; const lastUsed = { instanceId: ProviderInstanceId.make("codex"), model: "last-used" }; + const environment = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "env" }; const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" }; const providerDefault = { selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, @@ -549,11 +550,13 @@ describe("mobile model options", () => { projectDefaultSelection: ModelSelection | null, lastUsedSelection: ModelSelection | null, stickySelection: ModelSelection | null, + environmentDefaultSelection: ModelSelection | null = null, ) => resolveNewTaskModelSelection({ draftSelection, projectDefaultSelection, lastUsedSelection, + environmentDefaultSelection, stickySelection, modelOptions: [providerDefault], }); @@ -561,6 +564,8 @@ describe("mobile model options", () => { expect(resolve(draft, project, lastUsed, sticky)).toBe(draft); expect(resolve(null, project, lastUsed, sticky)).toBe(project); expect(resolve(null, null, lastUsed, sticky)).toBe(lastUsed); + expect(resolve(null, null, lastUsed, sticky, environment)).toBe(lastUsed); + expect(resolve(null, null, null, sticky, environment)).toBe(environment); expect(resolve(null, null, null, sticky)).toBe(sticky); expect(resolve(null, null, null, null)).toBe(providerDefault.selection); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index a09784ba58906..2043e63c2d243 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -225,6 +225,7 @@ export function resolveNewTaskModelSelection(input: { readonly draftSelection: ModelSelection | null; readonly projectDefaultSelection: ModelSelection | null; readonly lastUsedSelection?: ModelSelection | null; + readonly environmentDefaultSelection?: ModelSelection | null; readonly stickySelection: ModelSelection | null; readonly modelOptions: ReadonlyArray; }): ModelSelection | null { @@ -232,6 +233,7 @@ export function resolveNewTaskModelSelection(input: { input.draftSelection ?? input.projectDefaultSelection ?? input.lastUsedSelection ?? + input.environmentDefaultSelection ?? input.stickySelection ?? input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ?? input.modelOptions.find((option) => !option.isUnavailable)?.selection ?? diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a87d1a64f7e5b..cdf80abccf456 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -820,10 +820,9 @@ export default function ChatView(props: ChatViewProps) { localServerError === null && visibleThreadError === serverRuntime?.lastError; const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) .settings.defaultRuntimeMode; - const runtimeMode = - composerRuntimeMode ?? - (isServerThread ? activeThread?.runtimeMode : undefined) ?? - defaultRuntimeMode; + // A draft's session holds the mode it was opened with; the composer holds + // only modes the user picked, so a reused draft picks up fresh defaults. + const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? defaultRuntimeMode; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index cf57c9e614d6f..f6a32f74cf080 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,6 +1,10 @@ import type { DraftId } from "~/composerDraftStore"; import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore"; -import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + resolveEnvironmentMachineKind, + type ScopedProjectRef, +} from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { FolderPlusIcon } from "lucide-react"; @@ -13,7 +17,10 @@ import { primaryServerKeybindingsAtom } from "~/state/server"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; -import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; +import { + hasExplicitComposerModelSelection, + resolveNewThreadDefaults, +} from "~/lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -43,6 +50,7 @@ import { InlineButton } from "../ui/button"; import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; const ADD_PROJECT_VALUE = "add-project"; @@ -182,22 +190,26 @@ export function DraftHeroHeadline({ void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); return; } - setLogicalProjectDraftThreadId( - logicalProjectKey, - scopeProjectRef(project.environmentId, project.id), - draftId, - ); + // A retargeted draft that keeps its content takes the new project's + // defaults for whatever the user has not picked in the composer. + const projectRef = scopeProjectRef(project.environmentId, project.id); + const defaults = resolveNewThreadDefaults({ + projectSettings: resolveProjectSettings( + environments.find((environment) => environment.environmentId === project.environmentId) + ?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + project.id, + project, + ), + lastUsed: resolveProjectLastUsedSettings(threads, projectRef), + }); + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { + runtimeMode: defaults.runtimeMode, + ...(defaults.interactionMode ? { interactionMode: defaults.interactionMode } : {}), + }); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings - ? resolveProjectSettings(environmentSettings, project.id, project).settings - .defaultModelSelection - : project.defaultModelSelection; - if (defaultModelSelection) { - setModelSelection(draftId, defaultModelSelection, { + if (defaults.modelSelection) { + setModelSelection(draftId, defaults.modelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index dd456ea3b4578..05baac532b989 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -410,9 +410,11 @@ describe.each([ testState.completeProjectFileRead(null); await pendingOpen; - expect(testState.draftStore.setRuntimeMode).toHaveBeenLastCalledWith( + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenLastCalledWith( + "remote-project", + expect.anything(), expect.any(String), - "auto", + expect.objectContaining({ runtimeMode: "auto" }), ); expect(testState.draftStore.setModelSelection).toHaveBeenLastCalledWith( expect.any(String), @@ -444,40 +446,66 @@ describe("useNewThreadHandler runtime mode precedence", () => { await pendingOpen; }; + const reusableDraft = { + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }; + // Composer state holds only modes the user picked; an inherited one written + // there would outrank every later default. + const lastSessionRuntimeMode = () => { + expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); + return testState.draftStore.setLogicalProjectDraftThreadId.mock.lastCall?.[3]?.runtimeMode; + }; + it("lets a project-configured runtime mode win over the last used one", async () => { testState.reset(null); testState.configureProjectRuntimeMode("approval-required"); testState.viewing(null, [lastUsedThread]); await openTarget(); - expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); - expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenLastCalledWith( - "remote-project", - expect.anything(), - expect.any(String), - expect.objectContaining({ runtimeMode: "approval-required" }), - ); + expect(lastSessionRuntimeMode()).toBe("approval-required"); }); - it("keeps the runtime mode a reused draft already picked", async () => { - testState.reset({ - draftId: "draft-existing", - environmentId: "environment-ssh", - promotedTo: null, - threadId: "thread-existing", - }); - testState.draftStore.getComposerDraft.mockReturnValue({ runtimeMode: "approval-required" }); - testState.viewing(null, [lastUsedThread]); + // The composer shows the draft session's mode until the user picks one, so + // reopening an untouched draft must refresh it rather than keep the mode it + // inherited the first time. + it.each([ + ["from elsewhere", null], + ["while it is open", { kind: "draft", draftId: "draft-existing" }], + ])("refreshes a reused draft's inherited runtime mode when opened %s", async (_, route) => { + testState.reset(reusableDraft); + testState.viewing(route, [lastUsedThread]); await openTarget(); + expect(lastSessionRuntimeMode()).toBe("auto"); - expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); + testState.reset(reusableDraft); + testState.viewing(route, [ + lastUsedThread, + { + ...lastUsedThread, + id: "thread-newer", + runtimeMode: "approval-required", + updatedAt: "2026-10-22T10:00:00.000Z", + }, + ]); + await openTarget(); + expect(lastSessionRuntimeMode()).toBe("approval-required"); + + testState.reset(reusableDraft); + testState.configureProjectRuntimeMode("full-access"); + testState.viewing(route, [lastUsedThread]); + await openTarget(); + expect(lastSessionRuntimeMode()).toBe("full-access"); }); - it("writes the last used runtime mode where the composer reads it", async () => { - testState.reset(null); + it("leaves a runtime mode the user picked in a reused draft's composer alone", async () => { + testState.reset(reusableDraft); + testState.draftStore.getComposerDraft.mockReturnValue({ runtimeMode: "approval-required" }); testState.viewing(null, [lastUsedThread]); await openTarget(); - expect(testState.draftStore.setRuntimeMode).toHaveBeenCalledWith("draft-delayed", "auto"); + expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 62e8b472890b7..217942c124c5e 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -33,7 +33,7 @@ import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/pr import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, - resolveNewThreadModelSelectionOverride, + resolveNewThreadDefaults, } from "../lib/chatThreadActions"; import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -92,18 +92,10 @@ export function useNewThreadHandler() { setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, - setRuntimeMode, } = useComposerDraftStore.getState(); const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); - // A new thread starts from the target project's last user-used settings, - // not from whatever thread happens to be on screen; picks already made - // in the destination draft are kept below. - const lastUsed = resolveProjectLastUsedSettings(readThreadShells(), projectRef); - const carryModelSelection = lastUsed?.modelSelection ?? null; - const carryInteractionMode = lastUsed?.interactionMode ?? null; - const carryRuntimeMode = lastUsed?.runtimeMode ?? null; const project = projects.find( (candidate) => candidate.id === projectRef.projectId && @@ -116,25 +108,17 @@ export function useNewThreadHandler() { project?.id ?? null, project, ); - const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; - const inheritedRuntimeMode = - projectSettings.sources.defaultRuntimeMode === "project" ? null : carryRuntimeMode; - const defaultRuntimeMode = inheritedRuntimeMode ?? projectSettings.settings.defaultRuntimeMode; - // The composer reads a draft's runtime mode from its composer state, so - // an inherited mode is written there too, unless the draft already has a pick. - const applyInheritedRuntimeMode = (destinationDraftId: DraftId) => { - if (inheritedRuntimeMode && !getComposerDraft(destinationDraftId)?.runtimeMode) { - setRuntimeMode(destinationDraftId, inheritedRuntimeMode); - } - }; - const resolveModelSelectionOverride = (destinationDraftId: DraftId) => - resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: projectDefaultModelSelection ?? null, - carrySelection: carryModelSelection, - carrySourceDraftId: - currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, - destinationDraftId, - }); + // A new thread starts from the target project, not from whatever thread + // happens to be on screen. These are written to the draft session, which + // the composer shows until the user picks something in the composer itself. + const { + modelSelection: defaultModelSelection, + runtimeMode: defaultRuntimeMode, + interactionMode: carryInteractionMode, + } = resolveNewThreadDefaults({ + projectSettings, + lastUsed: resolveProjectLastUsedSettings(readThreadShells(), projectRef), + }); const resolveDefaultEnvMode = async (): Promise => { const consultProjectFile = project !== undefined && projectSettings.settings.defaultThreadEnvMode === null; @@ -238,7 +222,7 @@ export function useNewThreadHandler() { if (workspaceContext) { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, - ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), + runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); } @@ -246,11 +230,8 @@ export function useNewThreadHandler() { const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); if (!storedDraftHasExplicitModelPick) { applyStickyState(emptyStoredDraftThread.draftId); - const modelSelectionOverride = resolveModelSelectionOverride( - emptyStoredDraftThread.draftId, - ); - if (modelSelectionOverride) { - setModelSelection(emptyStoredDraftThread.draftId, modelSelectionOverride, { + if (defaultModelSelection) { + setModelSelection(emptyStoredDraftThread.draftId, defaultModelSelection, { replaceOptions: true, }); } @@ -266,13 +247,10 @@ export function useNewThreadHandler() { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, - ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), + runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); - if (!isDraftAlreadyOpen) { - applyInheritedRuntimeMode(emptyStoredDraftThread.draftId); - } const opened = { draftId: emptyStoredDraftThread.draftId, threadId: emptyStoredDraftThread.threadId, @@ -368,11 +346,9 @@ export function useNewThreadHandler() { runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); - applyInheritedRuntimeMode(draftId); applyStickyState(draftId); - const modelSelectionOverride = resolveModelSelectionOverride(draftId); - if (modelSelectionOverride) { - setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true }); } await router.navigate({ to: "/draft/$draftId", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index ad465cd06b2bf..6530ba9080398 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,36 +1,74 @@ -import { ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + type ModelSelection, + type ServerSettings, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { describe, expect, it } from "vite-plus/test"; -import { resolveNewThreadModelSelectionOverride } from "./chatThreadActions"; +import { resolveNewThreadDefaults } from "./chatThreadActions"; -const PROJECT_DEFAULT_SELECTION: ModelSelection = { - instanceId: ProviderInstanceId.make("codex"), - model: "project-default", +const projectId = ProjectId.make("project-a"); +const ENVIRONMENT_DEFAULT: ModelSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", }; -const CARRIED_SELECTION: ModelSelection = { +const PROJECT_DEFAULT: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), - model: "carried-model", + model: "gpt-6.1-mini", + options: [{ id: "reasoningEffort", value: "low" }], }; +const lastUsed = { + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6.1-sol", + options: [{ id: "reasoningEffort", value: "high" }], + }, + runtimeMode: "auto", + interactionMode: "plan", +} as const; + +function settingsFor(overrides: Partial = {}) { + return resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: ENVIRONMENT_DEFAULT, + defaultRuntimeMode: "approval-required", + ...overrides, + }, + projectId, + ); +} -describe("chatThreadActions", () => { - it("does not carry a non-explicit model from the destination draft back into itself", () => { - expect( - resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: null, - carrySelection: CARRIED_SELECTION, - carrySourceDraftId: "draft-a", - destinationDraftId: "draft-a", - }), - ).toBeNull(); +describe("resolveNewThreadDefaults", () => { + it("prefers the project's last-used settings over environment defaults", () => { + expect(resolveNewThreadDefaults({ projectSettings: settingsFor(), lastUsed })).toEqual({ + modelSelection: lastUsed.modelSelection, + runtimeMode: "auto", + interactionMode: "plan", + }); }); - it("keeps the project default above any carried selection", () => { - expect( - resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: PROJECT_DEFAULT_SELECTION, - carrySelection: CARRIED_SELECTION, - carrySourceDraftId: "draft-a", - destinationDraftId: "draft-b", - }), - ).toEqual(PROJECT_DEFAULT_SELECTION); + it("falls back to environment defaults for a project nobody has used", () => { + expect(resolveNewThreadDefaults({ projectSettings: settingsFor(), lastUsed: null })).toEqual({ + modelSelection: ENVIRONMENT_DEFAULT, + runtimeMode: "approval-required", + interactionMode: null, + }); + }); + + it("lets settings configured on the project itself win over its last-used settings", () => { + const projectSettings = settingsFor({ + projectSettingsOverrides: { + [projectId]: { defaultModelSelection: PROJECT_DEFAULT, defaultRuntimeMode: "full-access" }, + }, + }); + + expect(resolveNewThreadDefaults({ projectSettings, lastUsed })).toEqual({ + modelSelection: PROJECT_DEFAULT, + runtimeMode: "full-access", + interactionMode: "plan", + }); }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index ac5412c239753..003047a9e06f7 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,10 +1,14 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { ProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import type { EnvironmentId, ModelSelection, ProjectId, + ProviderInteractionMode, + RuntimeMode, ScopedProjectRef, } from "@t3tools/contracts"; +import type { ResolvedProjectSettings } from "@t3tools/shared/projectSettings"; import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; type ComposerModelSelectionState = Pick< @@ -43,16 +47,30 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } -export function resolveNewThreadModelSelectionOverride(input: { - readonly projectDefaultSelection: ModelSelection | null; - readonly carrySelection: ModelSelection | null; - readonly carrySourceDraftId: string | null; - readonly destinationDraftId: string; -}): ModelSelection | null { - return ( - input.projectDefaultSelection ?? - (input.carrySourceDraftId === input.destinationDraftId ? null : input.carrySelection) - ); +/** + * What a new thread in a project starts with when the draft has no pick of its + * own: a setting configured on the project itself wins, then the project's + * last user-used settings, then environment defaults. + */ +export function resolveNewThreadDefaults(input: { + readonly projectSettings: ResolvedProjectSettings; + readonly lastUsed: ProjectLastUsedSettings | null; +}): { + readonly modelSelection: ModelSelection | null; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | null; +} { + const { settings, sources } = input.projectSettings; + return { + modelSelection: + (sources.defaultModelSelection === "environment" ? null : settings.defaultModelSelection) ?? + input.lastUsed?.modelSelection ?? + settings.defaultModelSelection, + runtimeMode: + (sources.defaultRuntimeMode === "environment" ? input.lastUsed?.runtimeMode : null) ?? + settings.defaultRuntimeMode, + interactionMode: input.lastUsed?.interactionMode ?? null, + }; } export function hasExplicitComposerModelSelection( From ae7039af269de0cf4f15b799e2a03b1f782aa6b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:35:36 +0000 Subject: [PATCH 06/13] fix(web): retargeted drafts take the destination's modes and model defaults Moving an unsent draft to another machine (manual or automatic routing) or another project kept the runtime and interaction modes its session inherited from the source, so A's full-access or Plan survived a move to B. Retargets now go through one helper that resolves the destination project's settings and last-used history and writes them to the draft session. Composer picks still outrank the session. Manual moves also refresh a model the user did not pick; automatic routing keeps it, since it chose the machine for it. With no history, the interaction default is now written instead of omitted. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/DraftHeroHeadline.tsx | 51 ++------ .../chat/useDraftEnvironmentRouting.ts | 51 ++++---- apps/web/src/hooks/useHandleNewThread.test.ts | 12 ++ apps/web/src/hooks/useHandleNewThread.ts | 8 +- apps/web/src/lib/chatThreadActions.test.ts | 119 +++++++++++++++++- apps/web/src/lib/chatThreadActions.ts | 96 ++++++++++++-- 6 files changed, 256 insertions(+), 81 deletions(-) diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index f6a32f74cf080..571f5adf1bd20 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,10 +1,6 @@ import type { DraftId } from "~/composerDraftStore"; import { composerDraftHasUserContent, useComposerDraftStore } from "~/composerDraftStore"; -import { - DEFAULT_SERVER_SETTINGS, - resolveEnvironmentMachineKind, - type ScopedProjectRef, -} from "@t3tools/contracts"; +import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { FolderPlusIcon } from "lucide-react"; @@ -17,10 +13,7 @@ import { primaryServerKeybindingsAtom } from "~/state/server"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useScratchProject } from "~/hooks/useScratchProject"; import { useClientSettings } from "~/hooks/useSettings"; -import { - hasExplicitComposerModelSelection, - resolveNewThreadDefaults, -} from "~/lib/chatThreadActions"; +import { retargetDraftToProject } from "~/lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -49,8 +42,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; const ADD_PROJECT_VALUE = "add-project"; @@ -76,12 +67,7 @@ export function DraftHeroHeadline({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); - const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); - const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = () => openCommandPalette({ open: "add-project" }); const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); const openProjectDraft = useNewThreadHandler(); @@ -190,30 +176,17 @@ export function DraftHeroHeadline({ void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); return; } - // A retargeted draft that keeps its content takes the new project's - // defaults for whatever the user has not picked in the composer. - const projectRef = scopeProjectRef(project.environmentId, project.id); - const defaults = resolveNewThreadDefaults({ - projectSettings: resolveProjectSettings( - environments.find((environment) => environment.environmentId === project.environmentId) - ?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, - project.id, - project, - ), - lastUsed: resolveProjectLastUsedSettings(threads, projectRef), - }); - setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { - runtimeMode: defaults.runtimeMode, - ...(defaults.interactionMode ? { interactionMode: defaults.interactionMode } : {}), + retargetDraftToProject({ + draftId, + projectRef: scopeProjectRef(project.environmentId, project.id), + project, + environmentSettings: environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings, + threads, + logicalProjectKey, + refreshModel: true, }); - if (!hasExplicitComposerModelSelection(currentDraft)) { - applyStickyState(draftId); - if (defaults.modelSelection) { - setModelSelection(draftId, defaults.modelSelection, { - replaceOptions: true, - }); - } - } }; const startScratch = async (): Promise => { if (scratchTargetEnvironmentId === null || isScratchDraft) { diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index 1ef648e48c90d..c353dcef75eba 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -18,7 +18,8 @@ import { } from "../../logicalProject"; import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { type EnvironmentPresentation, useEnvironmentProviders } from "../../state/environments"; -import { useProjects } from "../../state/entities"; +import { readThreadShells, useProjects } from "../../state/entities"; +import { retargetDraftToProject } from "../../lib/chatThreadActions"; import type { DraftSessionState } from "../../composerDraftStore"; interface DraftEnvironmentRoutingInput { @@ -68,9 +69,6 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) } = input; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); const allProjects = useProjects(); const { openScratchProject } = useScratchProject(); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; @@ -117,10 +115,19 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) (environment) => environment.environmentId === loadBalancing.environmentId, ); if (!target?.projectId) return; - setDraftThreadContext(draftId, { + retargetDraftToProject({ + draftId, projectRef: scopeProjectRef(target.environmentId, target.projectId), - environmentSelection: "auto", - loadBalancedEnvironmentId: target.environmentId, + project: + allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ) ?? null, + environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, + threads: readThreadShells(), + logicalProjectKey: null, + routing: { environmentSelection: "auto", loadBalancedEnvironmentId: target.environmentId }, + refreshModel: false, }); }, [ needsLoadBalancing, @@ -128,8 +135,9 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) loadBalancing.environmentId, draftId, logicalProjectEnvironments, + allProjects, + environmentById, sendInFlightRef, - setDraftThreadContext, ]); const onAutoEnvironment = () => { if (envLocked || !draftId) return; @@ -218,21 +226,18 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) currentDraft.projectId !== originalDraft.projectId ) return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } + retargetDraftToProject({ + draftId, + projectRef: scopeProjectRef(target.environmentId, project.id), + project, + environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, + threads: readThreadShells(), + logicalProjectKey: activeProjectIsScratch + ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) + : null, + routing: { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + refreshModel: true, + }); }; const finish = () => { if (environmentChangeRef.current === request) environmentChangeRef.current = null; diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 05baac532b989..6bf90b90fbd42 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -500,6 +500,18 @@ describe("useNewThreadHandler runtime mode precedence", () => { expect(lastSessionRuntimeMode()).toBe("full-access"); }); + // The store keeps a session's interaction mode when none is passed, so a + // reused draft must be told the default to drop a Plan it inherited. + it("resets a reused draft's inherited Plan when the project has no history", async () => { + testState.reset(reusableDraft); + testState.viewing(null, []); + await openTarget(); + + expect(testState.draftStore.setLogicalProjectDraftThreadId.mock.lastCall?.[3]).toMatchObject({ + interactionMode: "default", + }); + }); + it("leaves a runtime mode the user picked in a reused draft's composer alone", async () => { testState.reset(reusableDraft); testState.draftStore.getComposerDraft.mockReturnValue({ runtimeMode: "approval-required" }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 217942c124c5e..5870bb01a4181 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -114,7 +114,7 @@ export function useNewThreadHandler() { const { modelSelection: defaultModelSelection, runtimeMode: defaultRuntimeMode, - interactionMode: carryInteractionMode, + interactionMode: defaultInteractionMode, } = resolveNewThreadDefaults({ projectSettings, lastUsed: resolveProjectLastUsedSettings(readThreadShells(), projectRef), @@ -223,7 +223,7 @@ export function useNewThreadHandler() { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, runtimeMode: defaultRuntimeMode, - ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + interactionMode: defaultInteractionMode, }); } const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); @@ -248,7 +248,7 @@ export function useNewThreadHandler() { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, runtimeMode: defaultRuntimeMode, - ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + interactionMode: defaultInteractionMode, }, ); const opened = { @@ -344,7 +344,7 @@ export function useNewThreadHandler() { newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, }), runtimeMode: defaultRuntimeMode, - ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), + interactionMode: defaultInteractionMode, }); applyStickyState(draftId); if (defaultModelSelection) { diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 6530ba9080398..22cb7e6267261 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,13 +1,16 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProjectId, ProviderInstanceId, + ThreadId, type ModelSelection, type ServerSettings, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { describe, expect, it } from "vite-plus/test"; -import { resolveNewThreadDefaults } from "./chatThreadActions"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { resolveNewThreadDefaults, retargetDraftToProject } from "./chatThreadActions"; const projectId = ProjectId.make("project-a"); const ENVIRONMENT_DEFAULT: ModelSelection = { @@ -54,7 +57,7 @@ describe("resolveNewThreadDefaults", () => { expect(resolveNewThreadDefaults({ projectSettings: settingsFor(), lastUsed: null })).toEqual({ modelSelection: ENVIRONMENT_DEFAULT, runtimeMode: "approval-required", - interactionMode: null, + interactionMode: "default", }); }); @@ -72,3 +75,113 @@ describe("resolveNewThreadDefaults", () => { }); }); }); + +describe("retargetDraftToProject", () => { + const sourceRef = { environmentId: EnvironmentId.make("environment-a"), projectId }; + const destinationRef = { + environmentId: EnvironmentId.make("environment-b"), + projectId: ProjectId.make("project-b"), + }; + const draftId = DraftId.make("draft-retarget"); + const store = () => useComposerDraftStore.getState(); + const session = () => store().getDraftSession(draftId); + const composer = () => store().getComposerDraft(draftId); + const selection = (instance: string) => + composer()?.modelSelectionByProvider[ProviderInstanceId.make(instance)]; + const manualRouting = { + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + } as const; + const autoRouting = { + environmentSelection: "auto", + loadBalancedEnvironmentId: destinationRef.environmentId, + } as const; + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); + // The draft opened in A inherited A's last-used settings into its session. + store().setProjectDraftThreadId(sourceRef, draftId, { + threadId: ThreadId.make("thread-retarget"), + runtimeMode: "full-access", + interactionMode: "plan", + }); + store().setModelSelection(draftId, lastUsed.modelSelection, { replaceOptions: true }); + store().setPrompt(draftId, "keep this"); + }); + + const retarget = (overrides: Partial[0]> = {}) => + retargetDraftToProject({ + draftId, + projectRef: destinationRef, + project: null, + environmentSettings: { + ...DEFAULT_SERVER_SETTINGS, + defaultModelSelection: ENVIRONMENT_DEFAULT, + defaultRuntimeMode: "approval-required", + }, + threads: [], + logicalProjectKey: null, + refreshModel: true, + ...overrides, + }); + + it.each([ + ["a machine switch", { routing: manualRouting }], + ["automatic routing", { routing: autoRouting, refreshModel: false }], + ["a project pick", { logicalProjectKey: "project-b" }], + ])("drops the source's inherited modes on %s to an unused project", (_, overrides) => { + retarget(overrides); + + expect(session()).toMatchObject({ + environmentId: destinationRef.environmentId, + projectId: destinationRef.projectId, + runtimeMode: "approval-required", + interactionMode: "default", + }); + expect(composer()?.prompt).toBe("keep this"); + }); + + it("takes the destination project's own history over its environment default", () => { + retarget({ + environmentSettings: { ...DEFAULT_SERVER_SETTINGS, defaultRuntimeMode: "full-access" }, + threads: [ + { + ...destinationRef, + modelSelection: PROJECT_DEFAULT, + runtimeMode: "approval-required", + interactionMode: "default", + updatedAt: "2026-10-21T10:00:00.000Z", + latestUserAuthoredMessageAt: "2026-10-21T10:00:00.000Z", + }, + ], + }); + + expect(session()).toMatchObject({ runtimeMode: "approval-required" }); + expect(selection("codex")).toEqual(PROJECT_DEFAULT); + }); + + it("leaves modes and a model the user picked in the composer alone", () => { + store().setRuntimeMode(draftId, "full-access"); + store().setInteractionMode(draftId, "plan"); + store().setModelSelection(draftId, PROJECT_DEFAULT, { explicit: true }); + retarget(); + + expect(composer()).toMatchObject({ runtimeMode: "full-access", interactionMode: "plan" }); + expect(selection("codex")).toEqual(PROJECT_DEFAULT); + }); + + it("refreshes an inherited model on a manual switch but keeps it under automatic routing", () => { + retarget({ routing: autoRouting, refreshModel: false }); + expect(selection("codex")).toEqual(lastUsed.modelSelection); + + retarget({ routing: manualRouting }); + expect(composer()?.activeProvider).toBe("claudeAgent"); + expect(selection("claudeAgent")).toEqual(ENVIRONMENT_DEFAULT); + }); +}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 003047a9e06f7..c79cfb5cd4f9b 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,15 +1,31 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { ProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; -import type { - EnvironmentId, - ModelSelection, - ProjectId, - ProviderInteractionMode, - RuntimeMode, - ScopedProjectRef, +import { + resolveProjectLastUsedSettings, + type ProjectLastUsedSettings, + type ProjectLastUsedSettingsThread, +} from "@t3tools/client-runtime/state/project-last-used-settings"; +import { + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProjectId, + type ProviderInteractionMode, + type RuntimeMode, + type ScopedProjectRef, + type ServerSettings, } from "@t3tools/contracts"; -import type { ResolvedProjectSettings } from "@t3tools/shared/projectSettings"; -import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; +import { + resolveProjectSettings, + type LegacyProjectSettingsFields, + type ResolvedProjectSettings, +} from "@t3tools/shared/projectSettings"; +import { + useComposerDraftStore, + type ComposerThreadDraftState, + type DraftId, + type DraftThreadEnvMode, +} from "../composerDraftStore"; +import { DEFAULT_INTERACTION_MODE } from "../types"; type ComposerModelSelectionState = Pick< ComposerThreadDraftState, @@ -58,7 +74,7 @@ export function resolveNewThreadDefaults(input: { }): { readonly modelSelection: ModelSelection | null; readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode | null; + readonly interactionMode: ProviderInteractionMode; } { const { settings, sources } = input.projectSettings; return { @@ -69,10 +85,66 @@ export function resolveNewThreadDefaults(input: { runtimeMode: (sources.defaultRuntimeMode === "environment" ? input.lastUsed?.runtimeMode : null) ?? settings.defaultRuntimeMode, - interactionMode: input.lastUsed?.interactionMode ?? null, + interactionMode: input.lastUsed?.interactionMode ?? DEFAULT_INTERACTION_MODE, }; } +/** + * Moves a draft that keeps its content to another project, or to the same + * project on another machine. The draft session holds only defaults, since + * the composer holds what the user picked and outranks it, so the session + * takes the destination's defaults rather than keeping the source's. + * A logical project key also remaps that project's draft to this one. + */ +export function retargetDraftToProject(input: { + readonly draftId: DraftId; + readonly projectRef: ScopedProjectRef; + readonly project: LegacyProjectSettingsFields | null; + readonly environmentSettings: ServerSettings | null | undefined; + readonly threads: ReadonlyArray; + readonly logicalProjectKey: string | null; + readonly routing?: { + readonly environmentSelection: "auto" | "manual"; + readonly loadBalancedEnvironmentId: EnvironmentId | null; + }; + /** Automatic routing picked the machine for the current model, so it keeps that model. */ + readonly refreshModel: boolean; +}): void { + const store = useComposerDraftStore.getState(); + const defaults = resolveNewThreadDefaults({ + projectSettings: resolveProjectSettings( + input.environmentSettings ?? DEFAULT_SERVER_SETTINGS, + input.projectRef.projectId, + input.project, + ), + lastUsed: resolveProjectLastUsedSettings(input.threads, input.projectRef), + }); + const context = { + ...input.routing, + runtimeMode: defaults.runtimeMode, + interactionMode: defaults.interactionMode, + }; + if (input.logicalProjectKey === null) { + store.setDraftThreadContext(input.draftId, { projectRef: input.projectRef, ...context }); + } else { + store.setLogicalProjectDraftThreadId( + input.logicalProjectKey, + input.projectRef, + input.draftId, + context, + ); + } + if ( + input.refreshModel && + !hasExplicitComposerModelSelection(store.getComposerDraft(input.draftId)) + ) { + store.applyStickyState(input.draftId); + if (defaults.modelSelection) { + store.setModelSelection(input.draftId, defaults.modelSelection, { replaceOptions: true }); + } + } +} + export function hasExplicitComposerModelSelection( draft: ComposerModelSelectionState | null | undefined, ): boolean { From 7dca654eb2374707d238adda1b7b63e886a67a3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 08:05:11 +0000 Subject: [PATCH 07/13] test: add deterministic V2 fixtures for project defaults proof --- scripts/fixtures/issue-6508/browser.mjs | 131 +++++ scripts/fixtures/issue-6508/catalog.json | 601 +++++++++++++++++++++++ scripts/fixtures/issue-6508/clock.mjs | 11 + scripts/fixtures/issue-6508/provider.mjs | 132 +++++ scripts/fixtures/issue-6508/seed.mjs | 244 +++++++++ 5 files changed, 1119 insertions(+) create mode 100644 scripts/fixtures/issue-6508/browser.mjs create mode 100644 scripts/fixtures/issue-6508/catalog.json create mode 100644 scripts/fixtures/issue-6508/clock.mjs create mode 100644 scripts/fixtures/issue-6508/provider.mjs create mode 100644 scripts/fixtures/issue-6508/seed.mjs diff --git a/scripts/fixtures/issue-6508/browser.mjs b/scripts/fixtures/issue-6508/browser.mjs new file mode 100644 index 0000000000000..5b874b941f520 --- /dev/null +++ b/scripts/fixtures/issue-6508/browser.mjs @@ -0,0 +1,131 @@ +// Run through preview_evaluate on an owned, paired tab of the actual Vite app. +export async function reset({ + theme = "light", + project = "B", + nonempty = false, + explicit = false, +} = {}) { + const { useComposerDraftStore: s } = await import("/src/composerDraftStore.ts"); + s.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + backgroundSubmissionThreadKeys: {}, + stickyModelSelectionByProvider: {}, + stickyOptionsByModelByProvider: {}, + stickyActiveProvider: null, + }); + const { updateClientSettings } = await import("/src/hooks/useSettings.ts"); + await updateClientSettings({ + planModeEnabled: true, + onboardingCompletedAt: "2026-10-22T10:00:00.000Z", + legacySidebarEnabled: true, + sidebarProjectGroupingMode: "separate", + panelAnimationDurationMs: 0, + }); + localStorage.setItem("t3code:theme", theme); + if (nonempty) { + const id = "issue-6508-draft"; + const a = s.getState(); + a.setProjectDraftThreadId( + { environmentId: "issue-6508-E1", projectId: `proof-E1-${project}` }, + id, + { + threadId: "issue-6508-draft-thread", + createdAt: "2026-10-22T10:00:00.000Z", + runtimeMode: "approval-required", + interactionMode: "default", + envMode: "local", + branch: null, + worktreePath: null, + startFromOrigin: false, + }, + ); + a.setModelSelection( + id, + { + instanceId: "codex", + model: "gpt-6-luna", + options: [{ id: "reasoningEffort", value: "low" }], + }, + { explicit: false, replaceOptions: true }, + ); + a.setPrompt(id, "Issue 6508 deterministic submission."); + if (explicit) { + a.setModelSelection( + id, + { + instanceId: "codex", + model: "gpt-6-astra", + options: [{ id: "reasoningEffort", value: "medium" }], + }, + { explicit: true, replaceOptions: true }, + ); + a.setRuntimeMode(id, "full-access"); + a.setInteractionMode(id, "default"); + } + } + return localStorage.getItem("t3code:composer-drafts:v1"); +} +export function freezeClock() { + const RealDate = globalThis.Date; + const ms = RealDate.parse("2026-10-22T10:00:00.000Z"); + globalThis.Date = class extends RealDate { + constructor(...a) { + super(...(a.length ? a : [ms])); + } + static now() { + return ms; + } + }; + return { + clock: new Date().toISOString(), + viewport: [innerWidth, innerHeight], + dpr: devicePixelRatio, + zoom: visualViewport.scale, + }; +} +export async function patchProject(overrides) { + const { appAtomRegistry: r } = await import("/src/rpc/atomRegistry.ts"); + const { serverEnvironment: e } = await import("/src/state/server.ts"); + const result = await e.updateSettings.run(r, { + environmentId: "issue-6508-E1", + input: { patch: { projectSettingsOverrides: overrides, projectSettingsFolded: false } }, + }); + return result; +} +export async function oracle() { + const { useComposerDraftStore: s } = await import("/src/composerDraftStore.ts"); + const { appAtomRegistry: r } = await import("/src/rpc/atomRegistry.ts"); + const { environmentShell: e } = await import("/src/state/shell.ts"); + return { + url: location.href, + clock: new Date().toISOString(), + dpr: devicePixelRatio, + viewport: [innerWidth, innerHeight], + theme: { + id: document.documentElement.dataset.themeId, + appearance: document.documentElement.dataset.themeAppearance, + }, + local: localStorage.getItem("t3code:composer-drafts:v1"), + sessions: s.getState().draftThreadsByThreadKey, + composers: s.getState().draftsByThreadKey, + shell: r.get(e.stateValueAtom("issue-6508-E1")), + }; +} +export function captureCommands() { + if (globalThis.__issue6508Commands) return; + globalThis.__issue6508Commands = []; + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function (data) { + if ( + typeof data === "string" && + /orchestration\.launchThread|thread\.create|turn\.start/.test(data) + ) { + try { + globalThis.__issue6508Commands.push(JSON.parse(data)); + } catch {} + } + return send.call(this, data); + }; +} diff --git a/scripts/fixtures/issue-6508/catalog.json b/scripts/fixtures/issue-6508/catalog.json new file mode 100644 index 0000000000000..ee6ad001104d9 --- /dev/null +++ b/scripts/fixtures/issue-6508/catalog.json @@ -0,0 +1,601 @@ +{ + "fetchedAtMs": 1792654681378, + "manifest": { + "version": 1, + "updatedAt": "2026-10-21T06:20:00Z", + "compatibility": [ + { + "driver": "codex", + "t3CodeRange": ">=0.0.44", + "recommendedRange": ">=0.159.0", + "ranges": [ + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } + ] + }, + { + "driver": "codex", + "t3CodeRange": ">=0.0.42 <0.0.44", + "recommendedRange": ">=0.156.0", + "ranges": [ + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } + ] + }, + { + "driver": "claudeAgent", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2.1.280", + "ranges": [ + { "range": ">=2.1.280", "status": "supported" }, + { "range": ">=2.1.111 <2.1.280", "status": "graceful" }, + { "range": "<2.1.111", "status": "unsupported" } + ] + }, + { + "driver": "cursor", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2026.05.09", + "ranges": [ + { "range": ">=2026.05.09", "status": "supported" }, + { "range": "<2026.05.09", "status": "unknown" } + ] + }, + { + "driver": "grok", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.0.13", + "ranges": [ + { "range": ">=1.0.13", "status": "supported" }, + { "range": "<1.0.13", "status": "broken" } + ] + }, + { + "driver": "opencode", + "t3CodeRange": ">=0.0.42 <0.0.46", + "recommendedRange": ">=1.14.19 <2.0.0", + "recommendedVersion": "1.14.19", + "ranges": [ + { "range": ">=2.0.0", "status": "broken" }, + { "range": ">=1.14.19 <2.0.0", "status": "supported" }, + { "range": "<1.14.19", "status": "broken" } + ] + }, + { + "driver": "opencode", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=2.0.18", + "ranges": [ + { "range": ">=2.0.18", "status": "supported" }, + { "range": ">=2.0.0 <2.0.18", "status": "unsupported" }, + { "range": ">=1.14.19 <2.0.0", "status": "graceful" }, + { "range": "<1.14.19", "status": "broken" } + ] + }, + { + "driver": "antigravity", + "t3CodeRange": ">=0.0.42", + "recommendedRange": "=1.1.1", + "ranges": [{ "range": "=1.1.1", "status": "supported" }] + }, + { + "driver": "pi", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=1.0.0", + "ranges": [ + { "range": ">=0.80.5", "status": "supported" }, + { "range": "<0.80.5", "status": "unsupported" } + ] + }, + { + "driver": "muse", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=1.4.2", + "ranges": [ + { "range": ">=1.4.2", "status": "supported" }, + { "range": "<1.4.2", "status": "unknown" } + ] + } + ], + "currentModels": { + "codex": [ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-luna", + "gpt-daybreak-blue-latest", + "gpt-daybreak-red-latest" + ], + "claudeAgent": [ + "claude-fable-5-1", + "claude-opus-5-5", + "claude-sonnet-5-5", + "claude-haiku-5-5" + ], + "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] + }, + "providers": { + "codex": { + "profiles": {}, + "models": [ + { + "slug": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "status": "current", + "adapter": { "codex": { "minVersion": "0.153.0" } } + }, + { + "slug": "gpt-6-astra", + "name": "GPT-6 Astra", + "status": "current", + "adapter": { "codex": { "minVersion": "0.153.0" } } + }, + { + "slug": "gpt-6-luna", + "name": "GPT-6 Luna", + "status": "current", + "adapter": { "codex": { "minVersion": "0.155.0" } } + }, + { "slug": "gpt-6-sol", "name": "GPT-6 Sol", "status": "legacy" }, + { "slug": "gpt-5.6-sol", "name": "GPT-5.6 Sol", "status": "legacy" }, + { "slug": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "status": "legacy" }, + { "slug": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "status": "legacy" }, + { "slug": "gpt-5.5", "name": "GPT-5.5", "status": "legacy" }, + { "slug": "gpt-5.4-mini", "name": "GPT-5.4 Mini", "status": "legacy" }, + { "slug": "gpt-5.3-codex-spark", "name": "GPT-5.3 Codex Spark", "status": "legacy" } + ] + }, + "claudeAgent": { + "defaults": { "chat": "claude-fable-5-1" }, + "profiles": { + "fable-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-5-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-8": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultracode": "xhigh", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-7": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "xhigh": "max", "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "opus-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" } + ] + }, + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } + ] + }, + "adapter": { "claudeCode": { "fixedContextWindowTokens": 200000 } } + }, + "sonnet-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "sonnet-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium" }, + { "id": "high", "label": "High", "isDefault": true }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k", "isDefault": true }, + { "id": "1m", "label": "1M" } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "max": "high", "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, + "haiku-5-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "haiku-4-5": { + "capabilities": { + "optionDescriptors": [ + { "id": "thinking", "label": "Thinking", "type": "boolean", "currentValue": true } + ] + }, + "adapter": { "claudeCode": { "fixedContextWindowTokens": 200000 } } + } + }, + "models": [ + { + "slug": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "aliases": ["opus", "opus-5.5", "claude-opus-5.5"], + "status": "current", + "profile": "opus-5-5", + "adapter": { "claudeCode": { "minVersion": "2.1.280" } } + }, + { + "slug": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "aliases": ["sonnet", "sonnet-5.5", "claude-sonnet-5.5"], + "status": "current", + "profile": "sonnet-5", + "adapter": { "claudeCode": { "minVersion": "2.1.284" } } + }, + { + "slug": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "aliases": ["fable", "fable-5.1", "claude-fable-5.1"], + "status": "current", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.257" } } + }, + { + "slug": "claude-fable-5", + "name": "Claude Fable 5", + "status": "legacy", + "profile": "fable-5", + "adapter": { "claudeCode": { "minVersion": "2.1.169" } } + }, + { + "slug": "claude-opus-5", + "name": "Claude Opus 5", + "aliases": ["opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "legacy", + "profile": "opus-5", + "adapter": { "claudeCode": { "minVersion": "2.1.219" } } + }, + { + "slug": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "aliases": ["opus-4.8", "claude-opus-4.8"], + "status": "legacy", + "profile": "opus-4-8", + "adapter": { "claudeCode": { "minVersion": "2.1.154" } } + }, + { + "slug": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "aliases": ["opus-4.7", "claude-opus-4.7"], + "status": "legacy", + "profile": "opus-4-7", + "adapter": { "claudeCode": { "minVersion": "2.1.111" } } + }, + { + "slug": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"], + "status": "legacy", + "profile": "opus-4-6" + }, + { + "slug": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "status": "legacy", + "profile": "opus-4-5" + }, + { + "slug": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "aliases": ["sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "legacy", + "profile": "sonnet-5" + }, + { + "slug": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"], + "status": "legacy", + "profile": "sonnet-4-6" + }, + { + "slug": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "aliases": ["haiku", "haiku-5.5", "claude-haiku-5.5"], + "status": "current", + "badge": "new", + "profile": "haiku-5-5", + "adapter": { "claudeCode": { "minVersion": "2.1.293" } } + }, + { + "slug": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "aliases": ["haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "status": "legacy", + "profile": "haiku-4-5" + } + ] + }, + "antigravity": { + "defaults": { "chat": "gemini-3.8-flash-high" }, + "profiles": {}, + "models": [ + { + "slug": "gemini-3.8-flash-high", + "name": "Gemini 3.8 Flash (High)", + "status": "current" + }, + { + "slug": "gemini-3.8-flash-medium", + "name": "Gemini 3.8 Flash (Medium)", + "status": "current" + }, + { "slug": "gemini-3.8-flash-low", "name": "Gemini 3.8 Flash (Low)", "status": "current" }, + { + "slug": "gemini-3.7-flash-high", + "name": "Gemini 3.7 Flash (High)", + "status": "legacy" + }, + { + "slug": "gemini-3.7-flash-medium", + "name": "Gemini 3.7 Flash (Medium)", + "status": "legacy" + }, + { "slug": "gemini-3.7-flash-low", "name": "Gemini 3.7 Flash (Low)", "status": "legacy" }, + { + "slug": "gemini-3.6-flash-high", + "name": "Gemini 3.6 Flash (High)", + "status": "legacy" + }, + { + "slug": "gemini-3.6-flash-medium", + "name": "Gemini 3.6 Flash (Medium)", + "status": "legacy" + }, + { "slug": "gemini-3.6-flash-low", "name": "Gemini 3.6 Flash (Low)", "status": "legacy" }, + { "slug": "gemini-pro-agent", "name": "Gemini 3.1 Pro (High)", "status": "legacy" }, + { "slug": "gemini-3.1-pro-low", "name": "Gemini 3.1 Pro (Low)", "status": "legacy" } + ] + } + } + } +} diff --git a/scripts/fixtures/issue-6508/clock.mjs b/scripts/fixtures/issue-6508/clock.mjs new file mode 100644 index 0000000000000..24b1406bcbd4a --- /dev/null +++ b/scripts/fixtures/issue-6508/clock.mjs @@ -0,0 +1,11 @@ +// Fixture preload; wall clock is fixed while Node timers and performance.now stay real. +const RealDate = globalThis.Date; +const ms = RealDate.parse("2026-10-22T10:00:00.000Z"); +globalThis.Date = class extends RealDate { + constructor(...args) { + super(...(args.length ? args : [ms])); + } + static now() { + return ms; + } +}; diff --git a/scripts/fixtures/issue-6508/provider.mjs b/scripts/fixtures/issue-6508/provider.mjs new file mode 100644 index 0000000000000..123f4eea4eea8 --- /dev/null +++ b/scripts/fixtures/issue-6508/provider.mjs @@ -0,0 +1,132 @@ +// Deterministic Codex app-server peer: no tools, model calls, or filesystem actions. +import { createInterface } from "node:readline"; +import { appendFileSync, readFileSync } from "node:fs"; +const args = process.argv.slice(2); +if (args.includes("--version")) { + console.log("codex-cli 0.159.0"); + process.exit(0); +} +if (args.includes("mcp")) { + console.log("[]"); + process.exit(0); +} +if (args.includes("exec")) { + console.log('{"title":"Fixture submission"}'); + process.exit(0); +} +const catalog = JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url))); +const models = catalog.manifest.currentModels.codex.map((model, i) => ({ + id: model, + model, + displayName: model, + description: "Frozen real catalog; deterministic provider fixture", + hidden: false, + isDefault: i === 0, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"].map((reasoningEffort) => ({ + reasoningEffort, + description: reasoningEffort, + })), +})); +const emit = (m) => console.log(JSON.stringify(m)); +let thread; +for await (const line of createInterface({ input: process.stdin })) { + let m; + try { + m = JSON.parse(line); + } catch { + continue; + } + if (m.id === undefined) continue; + const p = m.params ?? {}; + let result = {}; + switch (m.method) { + case "initialize": + result = { + userAgent: "issue-6508-fixture", + codexHome: process.cwd(), + platformFamily: "unix", + platformOs: "linux", + }; + break; + case "account/read": + result = { + account: { type: "chatgpt", email: "fixture@example.invalid", planType: "plus" }, + requiresOpenaiAuth: false, + }; + break; + case "account/rateLimits/read": + result = { rateLimits: { primary: null, secondary: null, credits: null, planType: "plus" } }; + break; + case "model/list": + result = { data: models, nextCursor: null }; + break; + case "skills/list": + result = { data: [{ cwd: process.cwd(), skills: [], errors: [] }] }; + break; + case "mcpServerStatus/list": + result = { data: [], nextCursor: null }; + break; + case "config/read": + result = { config: {}, origins: {}, layers: [] }; + break; + case "thread/start": + case "thread/resume": { + thread = { + id: p.threadId ?? "fixture-native-thread", + cliVersion: "0.159.0", + createdAt: 1792663200, + updatedAt: 1792663200, + cwd: p.cwd ?? process.cwd(), + ephemeral: true, + modelProvider: "openai", + preview: "", + projectId: null, + sessionId: "fixture-session", + source: "appServer", + status: { type: "idle" }, + turns: [], + }; + result = { + thread, + model: p.model ?? models[0].model, + modelProvider: "openai", + cwd: thread.cwd, + approvalPolicy: p.approvalPolicy ?? "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + reasoningEffort: "high", + }; + break; + } + case "thread/read": + result = { thread }; + break; + case "turn/start": { + const turn = { id: "fixture-native-turn", items: [], status: "inProgress" }; + result = { turn }; + emit({ id: m.id, result }); + emit({ method: "turn/started", params: { threadId: p.threadId, turn } }); + const item = { + id: "fixture-answer", + type: "agentMessage", + text: "Deterministic fixture response. No tools or real provider were run.", + }; + emit({ + method: "item/started", + params: { threadId: p.threadId, turnId: turn.id, item: { ...item, text: "" } }, + }); + emit({ + method: "item/agentMessage/delta", + params: { threadId: p.threadId, turnId: turn.id, itemId: item.id, delta: item.text }, + }); + emit({ method: "item/completed", params: { threadId: p.threadId, turnId: turn.id, item } }); + emit({ + method: "turn/completed", + params: { threadId: p.threadId, turn: { ...turn, status: "completed", items: [item] } }, + }); + continue; + } + } + emit({ id: m.id, result }); +} diff --git a/scripts/fixtures/issue-6508/seed.mjs b/scripts/fixtures/issue-6508/seed.mjs new file mode 100644 index 0000000000000..7f1da9946719a --- /dev/null +++ b/scripts/fixtures/issue-6508/seed.mjs @@ -0,0 +1,244 @@ +// Historical projections are visual fixtures; only real app commands prove submission. +import { + OrchestrationV2AppThreadJson, + OrchestrationV2ConversationMessageJson, +} from "../../../packages/contracts/src/orchestrationV2.ts"; +import { ServerSettings } from "../../../packages/contracts/src/settings.ts"; +import { createRequire } from "node:module"; +const require = createRequire(new URL("../../../packages/contracts/package.json", import.meta.url)); +const Schema = await import(require.resolve("effect/Schema")); +const validate = (schema, value) => + Schema.decodeUnknownSync(Schema.fromJsonString(schema))(JSON.stringify(value)); +import { DatabaseSync } from "node:sqlite"; +import { mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +const [homeArg, preset = "H", environment = "E1"] = process.argv.slice(2); +if (!homeArg || !["H", "N", "P"].includes(preset) || !["E1", "E2"].includes(environment)) + throw Error( + "Usage: node scripts/fixtures/issue-6508/seed.mjs /tmp/issue-6508-evidence/ H|N|P E1|E2 (server stopped, migrations complete)", + ); +const home = resolve(homeArg); +if (!home.startsWith("/tmp/issue-6508-evidence/")) throw Error("Disposable proof homes only"); +const state = join(home, "userdata"); +const catalog = JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url))); +const selection = (model, effort) => ({ + instanceId: "codex", + model, + options: [{ id: "reasoningEffort", value: effort }], +}); +const H = selection("gpt-6.1-sol", "high"), + J = selection("gpt-6-luna", "low"), + F = selection("gpt-6-astra", "medium"); +for (const s of [H, J, F]) + if (!catalog.manifest.currentModels.codex.includes(s.model)) + throw Error("Model absent in frozen real manifest"); +const now = "2026-10-22T10:00:00.000Z"; +const projects = ["A", "B", "Unused"].map((n) => ({ + id: `proof-${environment}-${n}`, + title: `Project ${n}`, + root: `/tmp/issue-6508-evidence/workspaces/${environment}/${n}`, +})); +for (const p of projects) { + mkdirSync(p.root, { recursive: true }); + try { + execFileSync("git", ["-C", p.root, "rev-parse", "--git-dir"], { stdio: "ignore" }); + } catch { + execFileSync("git", ["init", "-b", "main", p.root], { stdio: "ignore" }); + writeFileSync(join(p.root, "README.md"), `# ${p.title}\nDeterministic issue 6508 fixture.\n`); + execFileSync("git", ["-C", p.root, "add", "README.md"]); + execFileSync( + "git", + [ + "-C", + p.root, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "Fixture workspace", + ], + { stdio: "ignore", env: { ...process.env, GIT_AUTHOR_DATE: now, GIT_COMMITTER_DATE: now } }, + ); + } +} +const d = new DatabaseSync(join(state, "statev2.sqlite")); +const tables = d + .prepare("select name from sqlite_schema where type='table' and name like 'orchestration_%'") + .all(); +d.exec("BEGIN IMMEDIATE"); +for (const { name } of tables) d.exec(`DELETE FROM "${name}"`); +d.exec("DELETE FROM projection_projects"); +for (const p of projects) + d.prepare( + "INSERT INTO projection_projects(project_id,title,workspace_root,scripts_json,created_at,updated_at) VALUES(?,?,?,?,?,?)", + ).run(p.id, p.title, p.root, "[]", now, now); +const threads = []; +function add(label, project, tuple, runtime, interaction, authored, updated, kind = "user") { + const id = `proof-${environment}-${label}`; + const t = { + createdBy: kind === "automation" ? "system" : "user", + creationSource: "web", + id, + projectId: projects[project].id, + title: label, + providerInstanceId: "codex", + modelSelection: tuple, + runtimeMode: runtime, + interactionMode: interaction, + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: kind === "subagent" ? `proof-${environment}-A history` : null, + relationshipToParent: kind === "subagent" ? "subagent" : null, + rootThreadId: kind === "subagent" ? `proof-${environment}-A history` : id, + }, + forkedFrom: null, + createdAt: "2026-10-20T10:00:00.000Z", + updatedAt: updated, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + validate(OrchestrationV2AppThreadJson, t); + d.prepare( + "INSERT INTO orchestration_v2_projection_threads(thread_id,project_id,title,default_provider,provider_instance_id,runtime_mode,interaction_mode,created_at,updated_at,payload_json) VALUES(?,?,?,?,?,?,?,?,?,?)", + ).run( + id, + t.projectId, + label, + "codex", + "codex", + runtime, + interaction, + t.createdAt, + updated, + JSON.stringify(t), + ); + if (authored) { + const m = { + createdBy: kind === "automation" ? "system" : "user", + creationSource: "web", + id: `${id}-message`, + threadId: id, + runId: null, + nodeId: null, + role: "user", + text: `${label}: deterministic history fixture, no provider is running.`, + attachments: [], + streaming: false, + createdAt: authored, + updatedAt: authored, + }; + validate(OrchestrationV2ConversationMessageJson, m); + d.prepare( + "INSERT INTO orchestration_v2_projection_messages(message_id,thread_id,role,streaming,created_at,updated_at,payload_json) VALUES(?,?,?,?,?,?,?)", + ).run(m.id, id, "user", 0, authored, authored, JSON.stringify(m)); + } + threads.push(t); +} +if (preset !== "N") { + add("A history", 0, H, "auto", "plan", "2026-10-22T09:00:00.000Z", "2026-10-22T09:00:00.000Z"); + add( + "A renamed older", + 0, + J, + "approval-required", + "default", + "2026-10-21T09:00:00.000Z", + "2026-10-22T09:50:00.000Z", + ); + add( + "A automated decoy", + 0, + F, + "full-access", + "default", + "2026-10-22T09:55:00.000Z", + "2026-10-22T09:55:00.000Z", + "automation", + ); + add( + "A subagent decoy", + 0, + F, + "full-access", + "default", + "2026-10-22T09:56:00.000Z", + "2026-10-22T09:56:00.000Z", + "subagent", + ); +} +add( + "B viewed history", + 1, + J, + "approval-required", + "default", + "2026-10-22T08:00:00.000Z", + "2026-10-22T08:00:00.000Z", +); +d.exec("COMMIT"); +d.close(); +const settings = { + defaultModelSelection: F, + defaultRuntimeMode: "full-access", + projectSettingsFolded: false, + projectSettingsOverrides: + preset === "P" + ? { [projects[0].id]: { defaultModelSelection: J, defaultRuntimeMode: "approval-required" } } + : {}, + providerInstances: { + codex: { + driver: "codex", + enabled: true, + config: { binaryPath: join(home, "codex-fixture"), homePath: join(home, "codex-home") }, + }, + claudeAgent: { driver: "claudeAgent", enabled: false }, + }, +}; +validate(ServerSettings, settings); +mkdirSync(join(home, "codex-home"), { recursive: true }); +writeFileSync(join(state, "settings.json"), JSON.stringify(settings, null, 2) + "\n"); +writeFileSync(join(state, "environment-id"), `issue-6508-${environment}\n`); +copyFileSync(new URL("./catalog.json", import.meta.url), join(state, "model-manifest.json")); +writeFileSync( + join(home, "codex-fixture"), + `#!/bin/sh\nexec node '${resolve(new URL("./provider.mjs", import.meta.url).pathname)}' "$@"\n`, + { mode: 0o755 }, +); +const manifest = { + version: 1, + preset, + environmentId: `issue-6508-${environment}`, + clock: now, + clockMechanism: + "Date constructor and Date.now in owned web page; timers remain real (native not frozen)", + projects, + threads, + H, + J, + F, + settings, + catalogHash: createHash("sha256").update(JSON.stringify(catalog)).digest("hex"), + seedHash: createHash("sha256") + .update(readFileSync(new URL("./seed.mjs", import.meta.url))) + .digest("hex"), +}; +writeFileSync(join(home, "seed-manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); +console.log( + JSON.stringify({ + home, + preset, + environmentId: manifest.environmentId, + projects: projects.map((p) => p.id), + seedHash: manifest.seedHash, + catalogHash: manifest.catalogHash, + }), +); From c99d121607ca204ca66c0054375d1b156f0876b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 08:31:42 +0000 Subject: [PATCH 08/13] test: align project defaults fixtures with tooling conventions --- knip.jsonc | 2 + scripts/fixtures/issue-6508/provider.mjs | 16 ++--- scripts/fixtures/issue-6508/seed.mjs | 77 +++++++++++++++--------- 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index 7fbfe38259951..74cf164fd43b6 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -23,6 +23,8 @@ // electron-builder loads the macOS signer by path. "entry": [ "knip-schemas.ts", + // Loaded directly by the evidence runner, provider subprocess and browser. + "fixtures/issue-6508/*.mjs", "mobile-native-client.ts", "setup-worktree.ts", "sign-macos.ts", diff --git a/scripts/fixtures/issue-6508/provider.mjs b/scripts/fixtures/issue-6508/provider.mjs index 123f4eea4eea8..14077bfbbf8f8 100644 --- a/scripts/fixtures/issue-6508/provider.mjs +++ b/scripts/fixtures/issue-6508/provider.mjs @@ -1,20 +1,20 @@ // Deterministic Codex app-server peer: no tools, model calls, or filesystem actions. -import { createInterface } from "node:readline"; -import { appendFileSync, readFileSync } from "node:fs"; -const args = process.argv.slice(2); -if (args.includes("--version")) { +import * as NodeReadline from "node:readline"; +import * as NodeFS from "node:fs"; +const args = new Set(process.argv.slice(2)); +if (args.has("--version")) { console.log("codex-cli 0.159.0"); process.exit(0); } -if (args.includes("mcp")) { +if (args.has("mcp")) { console.log("[]"); process.exit(0); } -if (args.includes("exec")) { +if (args.has("exec")) { console.log('{"title":"Fixture submission"}'); process.exit(0); } -const catalog = JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url))); +const catalog = JSON.parse(NodeFS.readFileSync(new URL("./catalog.json", import.meta.url))); const models = catalog.manifest.currentModels.codex.map((model, i) => ({ id: model, model, @@ -30,7 +30,7 @@ const models = catalog.manifest.currentModels.codex.map((model, i) => ({ })); const emit = (m) => console.log(JSON.stringify(m)); let thread; -for await (const line of createInterface({ input: process.stdin })) { +for await (const line of NodeReadline.createInterface({ input: process.stdin })) { let m; try { m = JSON.parse(line); diff --git a/scripts/fixtures/issue-6508/seed.mjs b/scripts/fixtures/issue-6508/seed.mjs index 7f1da9946719a..06e2d0105dac4 100644 --- a/scripts/fixtures/issue-6508/seed.mjs +++ b/scripts/fixtures/issue-6508/seed.mjs @@ -4,25 +4,27 @@ import { OrchestrationV2ConversationMessageJson, } from "../../../packages/contracts/src/orchestrationV2.ts"; import { ServerSettings } from "../../../packages/contracts/src/settings.ts"; -import { createRequire } from "node:module"; -const require = createRequire(new URL("../../../packages/contracts/package.json", import.meta.url)); +import * as NodeModule from "node:module"; +const require = NodeModule.createRequire( + new URL("../../../packages/contracts/package.json", import.meta.url), +); const Schema = await import(require.resolve("effect/Schema")); const validate = (schema, value) => Schema.decodeUnknownSync(Schema.fromJsonString(schema))(JSON.stringify(value)); -import { DatabaseSync } from "node:sqlite"; -import { mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; -import { resolve, join } from "node:path"; -import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; const [homeArg, preset = "H", environment = "E1"] = process.argv.slice(2); if (!homeArg || !["H", "N", "P"].includes(preset) || !["E1", "E2"].includes(environment)) throw Error( "Usage: node scripts/fixtures/issue-6508/seed.mjs /tmp/issue-6508-evidence/ H|N|P E1|E2 (server stopped, migrations complete)", ); -const home = resolve(homeArg); +const home = NodePath.resolve(homeArg); if (!home.startsWith("/tmp/issue-6508-evidence/")) throw Error("Disposable proof homes only"); -const state = join(home, "userdata"); -const catalog = JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url))); +const state = NodePath.join(home, "userdata"); +const catalog = JSON.parse(NodeFS.readFileSync(new URL("./catalog.json", import.meta.url))); const selection = (model, effort) => ({ instanceId: "codex", model, @@ -41,14 +43,19 @@ const projects = ["A", "B", "Unused"].map((n) => ({ root: `/tmp/issue-6508-evidence/workspaces/${environment}/${n}`, })); for (const p of projects) { - mkdirSync(p.root, { recursive: true }); + NodeFS.mkdirSync(p.root, { recursive: true }); try { - execFileSync("git", ["-C", p.root, "rev-parse", "--git-dir"], { stdio: "ignore" }); + NodeChildProcess.execFileSync("git", ["-C", p.root, "rev-parse", "--git-dir"], { + stdio: "ignore", + }); } catch { - execFileSync("git", ["init", "-b", "main", p.root], { stdio: "ignore" }); - writeFileSync(join(p.root, "README.md"), `# ${p.title}\nDeterministic issue 6508 fixture.\n`); - execFileSync("git", ["-C", p.root, "add", "README.md"]); - execFileSync( + NodeChildProcess.execFileSync("git", ["init", "-b", "main", p.root], { stdio: "ignore" }); + NodeFS.writeFileSync( + NodePath.join(p.root, "README.md"), + `# ${p.title}\nDeterministic issue 6508 fixture.\n`, + ); + NodeChildProcess.execFileSync("git", ["-C", p.root, "add", "README.md"]); + NodeChildProcess.execFileSync( "git", [ "-C", @@ -65,7 +72,7 @@ for (const p of projects) { ); } } -const d = new DatabaseSync(join(state, "statev2.sqlite")); +const d = new NodeSqlite.DatabaseSync(NodePath.join(state, "statev2.sqlite")); const tables = d .prepare("select name from sqlite_schema where type='table' and name like 'orchestration_%'") .all(); @@ -198,19 +205,28 @@ const settings = { codex: { driver: "codex", enabled: true, - config: { binaryPath: join(home, "codex-fixture"), homePath: join(home, "codex-home") }, + config: { + binaryPath: NodePath.join(home, "codex-fixture"), + homePath: NodePath.join(home, "codex-home"), + }, }, claudeAgent: { driver: "claudeAgent", enabled: false }, }, }; validate(ServerSettings, settings); -mkdirSync(join(home, "codex-home"), { recursive: true }); -writeFileSync(join(state, "settings.json"), JSON.stringify(settings, null, 2) + "\n"); -writeFileSync(join(state, "environment-id"), `issue-6508-${environment}\n`); -copyFileSync(new URL("./catalog.json", import.meta.url), join(state, "model-manifest.json")); -writeFileSync( - join(home, "codex-fixture"), - `#!/bin/sh\nexec node '${resolve(new URL("./provider.mjs", import.meta.url).pathname)}' "$@"\n`, +NodeFS.mkdirSync(NodePath.join(home, "codex-home"), { recursive: true }); +NodeFS.writeFileSync( + NodePath.join(state, "settings.json"), + JSON.stringify(settings, null, 2) + "\n", +); +NodeFS.writeFileSync(NodePath.join(state, "environment-id"), `issue-6508-${environment}\n`); +NodeFS.copyFileSync( + new URL("./catalog.json", import.meta.url), + NodePath.join(state, "model-manifest.json"), +); +NodeFS.writeFileSync( + NodePath.join(home, "codex-fixture"), + `#!/bin/sh\nexec node '${NodePath.resolve(new URL("./provider.mjs", import.meta.url).pathname)}' "$@"\n`, { mode: 0o755 }, ); const manifest = { @@ -226,12 +242,15 @@ const manifest = { J, F, settings, - catalogHash: createHash("sha256").update(JSON.stringify(catalog)).digest("hex"), - seedHash: createHash("sha256") - .update(readFileSync(new URL("./seed.mjs", import.meta.url))) + catalogHash: NodeCrypto.createHash("sha256").update(JSON.stringify(catalog)).digest("hex"), + seedHash: NodeCrypto.createHash("sha256") + .update(NodeFS.readFileSync(new URL("./seed.mjs", import.meta.url))) .digest("hex"), }; -writeFileSync(join(home, "seed-manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); +NodeFS.writeFileSync( + NodePath.join(home, "seed-manifest.json"), + JSON.stringify(manifest, null, 2) + "\n", +); console.log( JSON.stringify({ home, From 5ed60b5849bccb6e1fbfb03f3523ae74b0478c34 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:56:19 +0000 Subject: [PATCH 09/13] fix(fixtures): issue-6508 provider wrapper survives paths with spaces Co-Authored-By: Claude Opus 5.5 --- scripts/fixtures/issue-6508/seed.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/fixtures/issue-6508/seed.mjs b/scripts/fixtures/issue-6508/seed.mjs index 06e2d0105dac4..86c7cedff6a90 100644 --- a/scripts/fixtures/issue-6508/seed.mjs +++ b/scripts/fixtures/issue-6508/seed.mjs @@ -16,6 +16,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeCrypto from "node:crypto"; import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; const [homeArg, preset = "H", environment = "E1"] = process.argv.slice(2); if (!homeArg || !["H", "N", "P"].includes(preset) || !["E1", "E2"].includes(environment)) throw Error( @@ -226,7 +227,7 @@ NodeFS.copyFileSync( ); NodeFS.writeFileSync( NodePath.join(home, "codex-fixture"), - `#!/bin/sh\nexec node '${NodePath.resolve(new URL("./provider.mjs", import.meta.url).pathname)}' "$@"\n`, + `#!/bin/sh\nexec node '${NodeURL.fileURLToPath(new URL("./provider.mjs", import.meta.url))}' "$@"\n`, { mode: 0o755 }, ); const manifest = { From f2ff60b7b459e140b7e6fc540167911858741e62 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:08:27 +0000 Subject: [PATCH 10/13] fix(fixtures): issue-6508 provider wrapper quotes apostrophes in checkout path Co-Authored-By: Claude Opus 5.5 --- scripts/fixtures/issue-6508/seed.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/fixtures/issue-6508/seed.mjs b/scripts/fixtures/issue-6508/seed.mjs index 86c7cedff6a90..f476ab263c116 100644 --- a/scripts/fixtures/issue-6508/seed.mjs +++ b/scripts/fixtures/issue-6508/seed.mjs @@ -227,7 +227,7 @@ NodeFS.copyFileSync( ); NodeFS.writeFileSync( NodePath.join(home, "codex-fixture"), - `#!/bin/sh\nexec node '${NodeURL.fileURLToPath(new URL("./provider.mjs", import.meta.url))}' "$@"\n`, + `#!/bin/sh\nexec node '${NodeURL.fileURLToPath(new URL("./provider.mjs", import.meta.url)).replaceAll("'", `'"'"'`)}' "$@"\n`, { mode: 0o755 }, ); const manifest = { From 616dbced99bd8de3b66f117fbb6c9729e5c456c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:40:58 +0000 Subject: [PATCH 11/13] fix(web): retargeted drafts route provider usage fallback through the shared defaults Fix a duplicated providers key from the merge and trim comments to the lint cap. AI-assisted; verified with the tests and tools listed above. --- .../components/chat/useDraftEnvironmentRouting.ts | 1 - apps/web/src/lib/chatThreadActions.ts | 8 +++----- .../src/state/projectLastUsedSettings.ts | 15 ++++----------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index 4c6fb89aec7e9..9fd8e98c7f43a 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -237,7 +237,6 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) project, environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, providers: providersByEnvironment.get(target.environmentId) ?? [], - providers: providersByEnvironment.get(target.environmentId) ?? [], threads: readThreadShells(), logicalProjectKey: activeProjectIsScratch ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 125a27750a9dc..c7cc6f90af38d 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -115,11 +115,9 @@ export function resolveNewThreadDefaults(input: { } /** - * Moves a draft that keeps its content to another project, or to the same - * project on another machine. The draft session holds only defaults, since - * the composer holds what the user picked and outranks it, so the session - * takes the destination's defaults rather than keeping the source's. - * A logical project key also remaps that project's draft to this one. + * Moves a draft that keeps its content to another project or machine. The + * session holds only defaults (the composer holds the user's picks and outranks + * it), so it takes the destination's defaults instead of keeping the source's. */ export function retargetDraftToProject(input: { readonly draftId: DraftId; diff --git a/packages/client-runtime/src/state/projectLastUsedSettings.ts b/packages/client-runtime/src/state/projectLastUsedSettings.ts index f95c972b144c2..b60b7b1bdd117 100644 --- a/packages/client-runtime/src/state/projectLastUsedSettings.ts +++ b/packages/client-runtime/src/state/projectLastUsedSettings.ts @@ -1,9 +1,4 @@ -import type { - EnvironmentId, - ModelSelection, - ProjectId, - RuntimeMode, -} from "@t3tools/contracts"; +import type { EnvironmentId, ModelSelection, ProjectId, RuntimeMode } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "./models.ts"; export type ProjectLastUsedSettingsThread = Pick< @@ -20,11 +15,9 @@ export interface ProjectLastUsedSettings { } /** - * The composer settings of the project thread the user last wrote in. Agents - * pick subagent settings, and a null latestUserAuthoredMessageAt marks a - * thread only automation has written in, so neither says anything about the - * user's choice. Servers that predate the field omit it; their threads fall - * back to updatedAt and only count when no thread carries the field. + * Settings of the project thread the user last wrote in. Subagent threads and a null + * latestUserAuthoredMessageAt (automation only) say nothing about the user's choice. + * Older servers omit the field; those threads rank by updatedAt, below any that carry it. */ export function resolveProjectLastUsedSettings( threads: ReadonlyArray, From a913b30ca01f4f61d98d04e5746a7dec6b0440bd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:05:56 +0000 Subject: [PATCH 12/13] fix: inherited runtime modes respect the policy ceiling and drafts track live defaults Mobile runs the project's last-used runtime mode through the managed-policy check, so a mode above the administrator's ceiling falls back to the clamped server default. Web drafts again ignore the runtime mode frozen in the draft session and resolve their default at render time, from project settings and the project's last-used mode, so a settings change applies to an open draft. AI-assisted; verified with the tests and tools listed above. --- .../threads/new-task-flow-provider.tsx | 10 ++++---- apps/web/src/components/ChatView.tsx | 20 ++++++++++++---- apps/web/src/lib/chatThreadActions.ts | 18 +++++++++++--- apps/web/src/state/entities.ts | 24 ++++++++++++++++++- 4 files changed, 59 insertions(+), 13 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 78c0f97e6b316..48440dd21b5db 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -414,11 +414,13 @@ export function NewTaskFlowProvider( projectId: selectedProject.id, }) : null; - // A runtime mode configured on the project wins over its history. - const defaultRuntimeMode = - (projectSettings.sources.defaultRuntimeMode === "project" ? null : lastUsed?.runtimeMode) ?? - projectSettings.settings.defaultRuntimeMode; const managedPolicy = selectedEnvironmentServerConfig?.managedPolicy; + // A mode configured on the project wins over its history, which yields to the policy ceiling. + const defaultRuntimeMode = resolveNewThreadRuntimeMode( + projectSettings.sources.defaultRuntimeMode === "project" ? undefined : lastUsed?.runtimeMode, + projectSettings.settings.defaultRuntimeMode, + managedPolicy, + ); const runtimeMode = resolveNewThreadRuntimeMode( selectedProjectDraft.runtimeMode, defaultRuntimeMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d8f0eca5f655..975cbfdaf7566 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -251,6 +251,7 @@ import { resolveThreadDetailRef, useEnvironmentSupportsServerBrowser, useProject, + useProjectLastUsedRuntimeMode, useQuietThreadProjection, useThreadUpdatedAt, useThreadLastVisitedAt, @@ -260,6 +261,7 @@ import { useQuietThreadShell, useThreadCheckpointSummaries, } from "../state/entities"; +import { resolveDefaultRuntimeMode } from "../lib/chatThreadActions"; import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; @@ -936,11 +938,19 @@ export default function ChatView(props: ChatViewProps) { const [, setThreadErrorBannerDismissTick] = useState(0); const threadErrorIsServerRuntimeError = localServerError === null && visibleThreadError === serverRuntime?.lastError; - const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) - .settings.defaultRuntimeMode; - // A draft's session holds the mode it was opened with; the composer holds - // only modes the user picked, so a reused draft picks up fresh defaults. - const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? defaultRuntimeMode; + const draftLastUsedRuntimeMode = useProjectLastUsedRuntimeMode( + !isServerThread && activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null, + ); + const defaultRuntimeMode = resolveDefaultRuntimeMode( + resolveProjectSettings(settings, activeThread?.projectId ?? null), + draftLastUsedRuntimeMode, + ); + const runtimeMode = + composerRuntimeMode ?? + (isServerThread ? activeThread?.runtimeMode : undefined) ?? + defaultRuntimeMode; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c7cc6f90af38d..765495d3b3174 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -64,6 +64,17 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +/** A runtime mode configured on the project wins over the project's last-used one. */ +export function resolveDefaultRuntimeMode( + projectSettings: ResolvedProjectSettings, + lastUsedRuntimeMode: RuntimeMode | null, +): RuntimeMode { + return ( + (projectSettings.sources.defaultRuntimeMode === "environment" ? lastUsedRuntimeMode : null) ?? + projectSettings.settings.defaultRuntimeMode + ); +} + /** * What a new thread in a project starts with when the draft has no pick of its * own: a setting configured on the project itself wins, then the project's @@ -83,9 +94,10 @@ export function resolveNewThreadDefaults(input: { readonly runtimeMode: RuntimeMode; } { const { settings, sources } = input.projectSettings; - const runtimeMode = - (sources.defaultRuntimeMode === "environment" ? input.lastUsed?.runtimeMode : null) ?? - settings.defaultRuntimeMode; + const runtimeMode = resolveDefaultRuntimeMode( + input.projectSettings, + input.lastUsed?.runtimeMode ?? null, + ); const projectSelection = sources.defaultModelSelection === "environment" ? null : settings.defaultModelSelection; if (!projectSelection && input.lastUsed) { diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 19c0736779897..31efb29c2b577 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -17,7 +17,9 @@ import { parseThreadKey, threadKey, } from "@t3tools/client-runtime/state/entities"; -import type { ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import type { RuntimeMode, ScopedProjectRef, ScopedThreadRef } from "@t3tools/contracts"; +import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; +import { resolveProjectLastUsedSettings } from "@t3tools/client-runtime/state/project-last-used-settings"; import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, @@ -143,6 +145,26 @@ export function useThreadShellsForProjectRefs( return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); } +const EMPTY_RUNTIME_MODE_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-project-last-used-runtime-mode:empty"), +); +// Quiet shells and a primitive result keep streaming ticks from re-rendering readers. +const projectLastUsedRuntimeModeAtom = Atom.family((key: string) => { + const ref = parseScopedProjectKey(key); + return Atom.make((get): RuntimeMode | null => + ref === null + ? null + : (resolveProjectLastUsedSettings(get(environmentThreadShells.quietThreadShellsAtom), ref) + ?.runtimeMode ?? null), + ).pipe(Atom.withLabel(`web-project-last-used-runtime-mode:${key}`)); +}); + +export function useProjectLastUsedRuntimeMode(ref: ScopedProjectRef | null): RuntimeMode | null { + return useAtomValue( + ref === null ? EMPTY_RUNTIME_MODE_ATOM : projectLastUsedRuntimeModeAtom(scopedProjectKey(ref)), + ); +} + export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); } From fc32707aa4d15b368c1191020eab036b74331804 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:22:41 +0000 Subject: [PATCH 13/13] fix(web): inherited runtime modes yield to the managed policy ceiling A project's last-used runtime mode above the administrator's ceiling no longer becomes a new draft's default; the server's policy-clamped default applies instead, matching mobile. AI-assisted; verified with the tests and tools listed above. --- apps/web/src/components/ChatView.tsx | 3 +++ .../src/components/chat/DraftHeroHeadline.tsx | 8 ++++--- .../chat/useDraftEnvironmentRouting.ts | 2 ++ apps/web/src/hooks/useHandleNewThread.ts | 1 + apps/web/src/lib/chatThreadActions.test.ts | 13 ++++++++++++ apps/web/src/lib/chatThreadActions.ts | 21 ++++++++++++++----- 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 975cbfdaf7566..fb9689d1c66b4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -262,6 +262,7 @@ import { useThreadCheckpointSummaries, } from "../state/entities"; import { resolveDefaultRuntimeMode } from "../lib/chatThreadActions"; +import { useManagedPolicy } from "../state/managedPolicy"; import { useEnvironmentShellHasSnapshot } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; @@ -938,6 +939,7 @@ export default function ChatView(props: ChatViewProps) { const [, setThreadErrorBannerDismissTick] = useState(0); const threadErrorIsServerRuntimeError = localServerError === null && visibleThreadError === serverRuntime?.lastError; + const managedPolicy = useManagedPolicy(environmentId); const draftLastUsedRuntimeMode = useProjectLastUsedRuntimeMode( !isServerThread && activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -946,6 +948,7 @@ export default function ChatView(props: ChatViewProps) { const defaultRuntimeMode = resolveDefaultRuntimeMode( resolveProjectSettings(settings, activeThread?.projectId ?? null), draftLastUsedRuntimeMode, + managedPolicy, ); const runtimeMode = composerRuntimeMode ?? diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index c3c180958a43c..d3448014c5d64 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -195,13 +195,15 @@ export function DraftHeroHeadline({ void openProjectDraft(scopeProjectRef(project.environmentId, project.id)); return; } + const targetServerConfig = environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig; retargetDraftToProject({ draftId, projectRef: scopeProjectRef(project.environmentId, project.id), project, - environmentSettings: environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings, + environmentSettings: targetServerConfig?.settings, + managedPolicy: targetServerConfig?.managedPolicy, providers: providersByEnvironment.get(project.environmentId) ?? [], threads, logicalProjectKey, diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index 9fd8e98c7f43a..f26627863aeab 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -125,6 +125,7 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) project.environmentId === target.environmentId && project.id === target.projectId, ) ?? null, environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, + managedPolicy: environmentById.get(target.environmentId)?.serverConfig?.managedPolicy, providers: providersByEnvironment.get(target.environmentId) ?? [], threads: readThreadShells(), logicalProjectKey: null, @@ -236,6 +237,7 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) projectRef: scopeProjectRef(target.environmentId, project.id), project, environmentSettings: environmentById.get(target.environmentId)?.serverConfig?.settings, + managedPolicy: environmentById.get(target.environmentId)?.serverConfig?.managedPolicy, providers: providersByEnvironment.get(target.environmentId) ?? [], threads: readThreadShells(), logicalProjectKey: activeProjectIsScratch diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 1a5c95150a601..5dc71b9a5ad93 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -147,6 +147,7 @@ export function createNewThreadHandler(router: AnyRouter) { projectSettings, lastUsed: resolveProjectLastUsedSettings(readThreadShells(), projectRef), serverSettings: targetServerSettings, + managedPolicy: environmentServerConfigs.get(projectRef.environmentId)?.managedPolicy, providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], hasStickyProvider: useComposerDraftStore.getState().stickyActiveProvider !== null, now: Date.now(), diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 9594262f4f73c..fb2694eac9db6 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -4,6 +4,7 @@ import { ProjectId, ProviderInstanceId, ThreadId, + type ManagedPolicySummary, type ModelSelection, type ServerSettings, } from "@t3tools/contracts"; @@ -46,12 +47,14 @@ function settingsFor(overrides: Partial = {}) { const resolveDefaults = ( projectSettings: ReturnType, last: typeof lastUsed | null, + managedPolicy?: ManagedPolicySummary, ) => resolveNewThreadDefaults({ projectId, projectSettings, lastUsed: last, serverSettings: undefined, + managedPolicy, providers: [], hasStickyProvider: false, now: 0, @@ -65,6 +68,15 @@ describe("resolveNewThreadDefaults", () => { }); }); + it("drops a last-used runtime mode above the policy ceiling for the environment default", () => { + const policy = { + organization: "Example", + lockedSettings: [], + limits: { maxRuntimeMode: "auto-accept-edits" }, + } as const; + expect(resolveDefaults(settingsFor(), lastUsed, policy).runtimeMode).toBe("approval-required"); + }); + it("falls back to environment defaults for a project nobody has used", () => { expect(resolveDefaults(settingsFor(), null)).toEqual({ modelSelection: ENVIRONMENT_DEFAULT, @@ -134,6 +146,7 @@ describe("retargetDraftToProject", () => { defaultModelSelection: ENVIRONMENT_DEFAULT, defaultRuntimeMode: "approval-required", }, + managedPolicy: undefined, providers: [], threads: [], logicalProjectKey: null, diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 765495d3b3174..78dc25e1d6b5f 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -6,6 +6,8 @@ import { } from "@t3tools/client-runtime/state/project-last-used-settings"; import { DEFAULT_SERVER_SETTINGS, + managedRuntimeModeDenial, + type ManagedPolicySummary, type EnvironmentId, type ModelSelection, type ProjectId, @@ -64,15 +66,20 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } -/** A runtime mode configured on the project wins over the project's last-used one. */ +/** + * A runtime mode configured on the project wins over the project's last-used one, + * which yields to the server's policy-clamped default when the policy denies it. + */ export function resolveDefaultRuntimeMode( projectSettings: ResolvedProjectSettings, lastUsedRuntimeMode: RuntimeMode | null, + managedPolicy: ManagedPolicySummary | undefined, ): RuntimeMode { - return ( - (projectSettings.sources.defaultRuntimeMode === "environment" ? lastUsedRuntimeMode : null) ?? - projectSettings.settings.defaultRuntimeMode - ); + const inherited = + projectSettings.sources.defaultRuntimeMode === "environment" ? lastUsedRuntimeMode : null; + return inherited !== null && managedRuntimeModeDenial(managedPolicy, inherited) === undefined + ? inherited + : projectSettings.settings.defaultRuntimeMode; } /** @@ -86,6 +93,7 @@ export function resolveNewThreadDefaults(input: { readonly projectSettings: ResolvedProjectSettings; readonly lastUsed: ProjectLastUsedSettings | null; readonly serverSettings: ServerSettings | undefined; + readonly managedPolicy: ManagedPolicySummary | undefined; readonly providers: ReadonlyArray; readonly hasStickyProvider: boolean; readonly now: number; @@ -97,6 +105,7 @@ export function resolveNewThreadDefaults(input: { const runtimeMode = resolveDefaultRuntimeMode( input.projectSettings, input.lastUsed?.runtimeMode ?? null, + input.managedPolicy, ); const projectSelection = sources.defaultModelSelection === "environment" ? null : settings.defaultModelSelection; @@ -136,6 +145,7 @@ export function retargetDraftToProject(input: { readonly projectRef: ScopedProjectRef; readonly project: LegacyProjectSettingsFields | null; readonly environmentSettings: ServerSettings | undefined; + readonly managedPolicy: ManagedPolicySummary | undefined; readonly providers: ReadonlyArray; readonly threads: ReadonlyArray; readonly logicalProjectKey: string | null; @@ -156,6 +166,7 @@ export function retargetDraftToProject(input: { ), lastUsed: resolveProjectLastUsedSettings(input.threads, input.projectRef), serverSettings: input.environmentSettings, + managedPolicy: input.managedPolicy, providers: input.providers, hasStickyProvider: store.stickyActiveProvider !== null, now: Date.now(),