From d8ebbd63b9fea1394f2a13d251b8cbcdfbe3a16c Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 5 Jun 2026 15:21:16 -0500 Subject: [PATCH 1/2] fix: persist create-workspace runtime selection per draft Add a draft-scoped selectedRuntime key so the create page restores the chosen runtime per draft, and make new draft state get fork/delete/ emptiness support by construction via a DRAFT_SCOPED_FIELDS registry. Derive ParsedRuntime from a schema for normalized reads. --- src/browser/contexts/WorkspaceContext.tsx | 26 ++---- .../ChatInput/useCreationWorkspace.ts | 16 ++-- .../hooks/useDraftWorkspaceSettings.test.tsx | 83 +++++++++++++++++++ .../hooks/useDraftWorkspaceSettings.ts | 44 ++++++++-- src/common/constants/storage.test.ts | 32 +++++++ src/common/constants/storage.ts | 76 ++++++++++++++++- src/common/types/runtime.test.ts | 47 ++++++++++- src/common/types/runtime.ts | 36 ++++++-- 8 files changed, 318 insertions(+), 42 deletions(-) diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 509868576c..020448bdcb 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -19,6 +19,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import type { MuxDeepLinkPayload } from "@/common/types/deepLink"; import { deleteWorkspaceStorage, + DRAFT_SCOPED_FIELDS, getAgentIdKey, getDraftScopeId, getInputAttachmentsKey, @@ -29,7 +30,6 @@ import { getTerminalTitlesKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, - getWorkspaceNameStateKey, migrateWorkspaceStorage, AGENT_AI_DEFAULTS_KEY, DEFAULT_MODEL_KEY, @@ -353,22 +353,14 @@ function createWorkspaceDraftId(): string { function isDraftEmpty(projectPath: string, draftId: string): boolean { const scopeId = getDraftScopeId(projectPath, draftId); - // Check for input text - const inputText = readPersistedState(getInputKey(scopeId), ""); - if (inputText.trim().length > 0) { - return false; - } - - // Check for attachments - const attachments = readPersistedState(getInputAttachmentsKey(scopeId), []); - if (Array.isArray(attachments) && attachments.length > 0) { - return false; - } - - // Check for workspace name state (auto-generated or manual) - const nameState = readPersistedState(getWorkspaceNameStateKey(scopeId), null); - if (nameState !== null) { - return false; + // Derive emptiness from the shared draft-field registry so new draft-scoped fields + // automatically participate in reuse detection without editing this function. + for (const field of DRAFT_SCOPED_FIELDS) { + if (!field.keepsDraftAlive) continue; + const value = readPersistedState(field.key(scopeId), null); + if (field.keepsDraftAlive(value)) { + return false; + } } return true; diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 3c0c5e44ca..6fac760245 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -257,6 +257,13 @@ export function useCreationWorkspace({ const [runtimeAvailabilityState, setRuntimeAvailabilityState] = useState({ status: "loading" }); + const creationDraftScopeId = + projectPath.trim().length > 0 + ? typeof draftId === "string" && draftId.trim().length > 0 + ? getDraftScopeId(projectPath, draftId) + : getPendingScopeId(projectPath) + : null; + // Centralized draft workspace settings with automatic persistence const { settings, @@ -265,16 +272,11 @@ export function useCreationWorkspace({ setSelectedRuntime, setDefaultRuntimeChoice, setTrunkBranch, - } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk); + } = useDraftWorkspaceSettings(projectPath, branches, recommendedTrunk, creationDraftScopeId); // Persist draft workspace name generation state per draft (so multiple drafts don't share a // single auto-naming/manual-name state). - const workspaceNameScopeId = - projectPath.trim().length > 0 - ? typeof draftId === "string" && draftId.trim().length > 0 - ? getDraftScopeId(projectPath, draftId) - : getPendingScopeId(projectPath) - : null; + const workspaceNameScopeId = creationDraftScopeId; // Project scope ID for reading send options at send time const projectScopeId = getProjectScopeId(projectPath); diff --git a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx index 3ce07796ee..eee0a22b58 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.test.tsx +++ b/src/browser/hooks/useDraftWorkspaceSettings.test.tsx @@ -21,6 +21,7 @@ import { getModelKey, getProjectScopeId, getRuntimeKey, + getSelectedRuntimeKey, } from "@/common/constants/storage"; import { CODER_RUNTIME_PLACEHOLDER } from "@/common/types/runtime"; import type * as DraftWorkspaceSettingsModule from "./useDraftWorkspaceSettings"; @@ -269,6 +270,88 @@ describe("useDraftWorkspaceSettings", () => { }); }); + test("restores selected runtime from draft state", async () => { + const projectPath = "/tmp/project"; + const draftScopeId = "__draft__/tmp/project/draft-a"; + + const wrapper = createWrapper(projectPath); + + const first = renderHook( + () => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeId), + { wrapper } + ); + + act(() => { + first.result.current.setSelectedRuntime({ + mode: "docker", + image: "node:20", + shareCredentials: true, + }); + }); + + await waitFor(() => { + expect(first.result.current.settings.selectedRuntime).toEqual({ + mode: "docker", + image: "node:20", + shareCredentials: true, + }); + }); + + first.unmount(); + + const second = renderHook( + () => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeId), + { wrapper } + ); + + await waitFor(() => { + expect(second.result.current.settings.selectedRuntime).toEqual({ + mode: "docker", + image: "node:20", + shareCredentials: true, + }); + }); + + expect(readPersistedState(getSelectedRuntimeKey(draftScopeId), null)).toEqual({ + mode: "docker", + image: "node:20", + shareCredentials: true, + }); + }); + + test("keeps selected runtime scoped to each draft", async () => { + const projectPath = "/tmp/project"; + const draftScopeA = "__draft__/tmp/project/draft-a"; + const draftScopeB = "__draft__/tmp/project/draft-b"; + + updatePersistedState(getSelectedRuntimeKey(draftScopeA), { + mode: "ssh", + host: "dev@host", + }); + updatePersistedState(getSelectedRuntimeKey(draftScopeB), { + mode: "local", + }); + + const wrapper = createWrapper(projectPath); + + const first = renderHook( + () => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeA), + { wrapper } + ); + const second = renderHook( + () => useDraftWorkspaceSettings(projectPath, ["main"], "main", draftScopeB), + { wrapper } + ); + + await waitFor(() => { + expect(first.result.current.settings.selectedRuntime).toEqual({ + mode: "ssh", + host: "dev@host", + }); + expect(second.result.current.settings.selectedRuntime).toEqual({ mode: "local" }); + }); + }); + test("seeds SSH host from the remembered value when switching modes", async () => { const projectPath = "/tmp/project"; diff --git a/src/browser/hooks/useDraftWorkspaceSettings.ts b/src/browser/hooks/useDraftWorkspaceSettings.ts index 7e8c8b7afa..41a20c4276 100644 --- a/src/browser/hooks/useDraftWorkspaceSettings.ts +++ b/src/browser/hooks/useDraftWorkspaceSettings.ts @@ -8,6 +8,7 @@ import { type ParsedRuntime, type CoderWorkspaceConfig, buildRuntimeString, + ParsedRuntimeSchema, RUNTIME_MODE, CODER_RUNTIME_PLACEHOLDER, } from "@/common/types/runtime"; @@ -24,6 +25,7 @@ import { getAgentIdKey, getModelKey, getRuntimeKey, + getSelectedRuntimeKey, getTrunkBranchKey, getLastRuntimeConfigKey, getProjectScopeId, @@ -206,12 +208,14 @@ const buildRuntimeFromChoice = (choice: RuntimeChoice): ParsedRuntime => { * @param projectPath - Path to the project (used as key prefix for localStorage) * @param branches - Available branches (used to set default trunk branch) * @param recommendedTrunk - Backend-recommended trunk branch + * @param selectedRuntimeScopeId - Optional draft/pending scope that owns the creation runtime selection * @returns Settings object and setters */ export function useDraftWorkspaceSettings( projectPath: string, branches: string[], - recommendedTrunk: string | null + recommendedTrunk: string | null, + selectedRuntimeScopeId?: string | null ): { settings: DraftWorkspaceSettings; /** Restores prior Coder selections when re-entering Coder mode. */ @@ -474,15 +478,36 @@ export function useDraftWorkspaceSettings( lastDevcontainerShareCredentials ); - // Currently selected runtime for this session (initialized from default) + const selectedRuntimeStorageKey = selectedRuntimeScopeId + ? getSelectedRuntimeKey(selectedRuntimeScopeId) + : null; + const [persistedSelectedRuntime, setPersistedSelectedRuntime] = usePersistedState( + selectedRuntimeStorageKey ?? "__unused_selected_runtime__", + null, + { listener: true } + ); + // Validate/normalize the persisted draft runtime via the schema (single source of truth), + // discarding corrupt values so a bad localStorage entry self-heals to the default. + const parsedPersistedRuntime = ParsedRuntimeSchema.safeParse(persistedSelectedRuntime); + const persistedRuntimeSelection = parsedPersistedRuntime.success + ? parsedPersistedRuntime.data + : null; + const hasPersistedRuntimeSelectionRef = useRef(persistedRuntimeSelection !== null); + + // Currently selected runtime for this session (initialized from draft state, then default) // Uses discriminated union: SSH has host, Docker has image - const [selectedRuntime, setSelectedRuntimeState] = useState(() => defaultRuntime); + const [selectedRuntime, setSelectedRuntimeState] = useState( + () => persistedRuntimeSelection ?? defaultRuntime + ); // Project changes remount ChatInput (key includes projectPath), so this effect only handles // live Settings updates to the default runtime while staying on the same project. const appliedDefaultRuntimeChoiceRef = useRef(settingsDefaultRuntime); useEffect(() => { - if (appliedDefaultRuntimeChoiceRef.current === settingsDefaultRuntime) { + if ( + appliedDefaultRuntimeChoiceRef.current === settingsDefaultRuntime || + hasPersistedRuntimeSelectionRef.current + ) { return; } @@ -524,6 +549,13 @@ export function useDraftWorkspaceSettings( } }, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]); + const persistRuntimeSelection = (runtime: ParsedRuntime) => { + hasPersistedRuntimeSelectionRef.current = true; + if (selectedRuntimeStorageKey) { + setPersistedSelectedRuntime(runtime); + } + }; + // Setter for selected runtime (also persists host/image/coder for future mode switches) const setSelectedRuntime = (runtime: ParsedRuntime) => { const mergedRuntime = mergeRememberedRuntimeConfig( @@ -533,6 +565,7 @@ export function useDraftWorkspaceSettings( ); setSelectedRuntimeState(mergedRuntime); + persistRuntimeSelection(mergedRuntime); // Persist host/image/coder so they're remembered when switching modes. // Avoid wiping the remembered value when the UI switches modes with an empty field. @@ -595,8 +628,9 @@ export function useDraftWorkspaceSettings( ); const newRuntimeString = buildRuntimeString(newRuntime); setDefaultRuntimeString(newRuntimeString); - // Also update selection to match new default + // Also update this draft's selection so navigating away and back keeps the user's choice. setSelectedRuntimeState(newRuntime); + persistRuntimeSelection(newRuntime); }; // Helper to get runtime string for IPC calls diff --git a/src/common/constants/storage.test.ts b/src/common/constants/storage.test.ts index 482057fb2f..f322ebe107 100644 --- a/src/common/constants/storage.test.ts +++ b/src/common/constants/storage.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { copyWorkspaceStorage, deleteWorkspaceStorage, + DRAFT_SCOPED_FIELDS, getDraftScopeId, getInputAttachmentsKey, + getSelectedRuntimeKey, normalizeTranscriptDensity, } from "@/common/constants/storage"; @@ -96,4 +98,34 @@ describe("storage workspace-scoped keys", () => { expect(localStorage.getItem(key)).toBeNull(); }); + + // Guards that draft-scoped registry fields automatically participate in fork-copy and + // delete-cleanup. If a new draft field is added without persistence wiring, or the runtime + // selection regresses to project/global scope, these expectations break. + test("copyWorkspaceStorage copies the draft-scoped runtime selection", () => { + const source = "ws-source"; + const dest = "ws-dest"; + const value = JSON.stringify({ mode: "docker", image: "node:20" }); + + localStorage.setItem(getSelectedRuntimeKey(source), value); + copyWorkspaceStorage(source, dest); + + expect(localStorage.getItem(getSelectedRuntimeKey(dest))).toBe(value); + }); + + test("deleteWorkspaceStorage removes every persistent draft-scoped field", () => { + const workspaceId = "ws-draft-delete"; + + for (const field of DRAFT_SCOPED_FIELDS) { + if (field.persistence !== "persistent") continue; + localStorage.setItem(field.key(workspaceId), "value"); + } + + deleteWorkspaceStorage(workspaceId); + + for (const field of DRAFT_SCOPED_FIELDS) { + if (field.persistence !== "persistent") continue; + expect(localStorage.getItem(field.key(workspaceId))).toBeNull(); + } + }); }); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 793180b9a5..62ed0b4521 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -297,6 +297,15 @@ export function getPinnedAgentIdKey(scopeId: string): string { export function getDisableWorkspaceAgentsKey(scopeId: string): string { return `disableWorkspaceAgents:${scopeId}`; } +/** + * Get the localStorage key for the currently selected creation runtime for a scope. + * Drafts use this to keep runtime selection with the rest of their chat draft state. + * Format: "selectedRuntime:{scopeId}" + */ +export function getSelectedRuntimeKey(scopeId: string): string { + return `selectedRuntime:${scopeId}`; +} + /** * Get the localStorage key for the default runtime for a project * Defaults to worktree if not set; can only be changed via the "Default for project" checkbox. @@ -734,15 +743,74 @@ export function getAutoCompactionThresholdKey(model: string): string { } /** - * List of workspace-scoped key functions that should be copied on fork and deleted on removal + * Single source of truth for chat-draft state stored at a scope (a draft scope, + * pending scope, or workspace id share the same scope-keyed helpers). + * + * Add new create-workspace draft state here ONCE and it automatically gets: + * - copy-on-fork / delete-on-removal (via PERSISTENT_WORKSPACE_KEY_FUNCTIONS below) + * - draft emptiness/reuse detection (via `keepsDraftAlive`, consumed by isDraftEmpty) + * + * This prevents the recurring class of bug where a new field is persisted but + * forgotten in one of these cross-cutting behaviors. + */ +export interface DraftScopedFieldSpec { + /** Stable identifier for debugging/telemetry. */ + readonly id: string; + /** Builds the localStorage key for a given scope id. */ + readonly key: (scopeId: string) => string; + /** + * "persistent" fields are copied when forking a workspace and removed on delete; + * "ephemeral" fields are only removed on delete (never copied). + */ + readonly persistence: "persistent" | "ephemeral"; + /** + * When present, a draft is treated as non-empty (and therefore not reusable as a + * blank "New Workspace" slot) if the stored value satisfies this predicate. + * Fields without a predicate never keep a draft alive on their own (e.g. a runtime + * selection is a cheap default that shouldn't block draft reuse). + */ + readonly keepsDraftAlive?: (value: unknown) => boolean; +} + +export const DRAFT_SCOPED_FIELDS: readonly DraftScopedFieldSpec[] = [ + { + id: "input", + key: getInputKey, + persistence: "persistent", + keepsDraftAlive: (value: unknown) => typeof value === "string" && value.trim().length > 0, + }, + { + id: "inputAttachments", + key: getInputAttachmentsKey, + persistence: "persistent", + keepsDraftAlive: (value: unknown) => Array.isArray(value) && value.length > 0, + }, + { + id: "workspaceNameState", + key: getWorkspaceNameStateKey, + persistence: "persistent", + keepsDraftAlive: (value: unknown) => value != null, + }, + { + id: "selectedRuntime", + key: getSelectedRuntimeKey, + persistence: "persistent", + }, +]; + +/** + * List of workspace-scoped key functions that should be copied on fork and deleted on removal. + * + * Draft-scoped persistent fields are sourced from DRAFT_SCOPED_FIELDS so they can't drift + * out of sync; the remaining entries are workspace-only state (review/model/agent caches, etc). */ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ + ...DRAFT_SCOPED_FIELDS.filter((field) => field.persistence === "persistent").map( + (field) => field.key + ), getWorkspaceAISettingsByAgentKey, getModelKey, - getInputKey, getAutoExpandPrefsKey, - getWorkspaceNameStateKey, - getInputAttachmentsKey, getAgentIdKey, getPinnedAgentIdKey, getThinkingLevelKey, diff --git a/src/common/types/runtime.test.ts b/src/common/types/runtime.test.ts index b82a93206e..876ed9871b 100644 --- a/src/common/types/runtime.test.ts +++ b/src/common/types/runtime.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "@jest/globals"; -import { parseRuntimeModeAndHost, buildRuntimeString, CODER_RUNTIME_PLACEHOLDER } from "./runtime"; +import { + parseRuntimeModeAndHost, + buildRuntimeString, + CODER_RUNTIME_PLACEHOLDER, + ParsedRuntimeSchema, +} from "./runtime"; describe("parseRuntimeModeAndHost", () => { it("parses SSH mode with host", () => { @@ -109,3 +114,43 @@ describe("round-trip parsing and building", () => { expect(parsed).toEqual({ mode: "ssh", host: CODER_RUNTIME_PLACEHOLDER }); }); }); + +describe("ParsedRuntimeSchema", () => { + it("rejects unknown modes and missing discriminant fields", () => { + expect(ParsedRuntimeSchema.safeParse({ mode: "nope" }).success).toBe(false); + // SSH without host is invalid. + expect(ParsedRuntimeSchema.safeParse({ mode: "ssh" }).success).toBe(false); + // Docker without image is invalid. + expect(ParsedRuntimeSchema.safeParse({ mode: "docker" }).success).toBe(false); + expect(ParsedRuntimeSchema.safeParse(null).success).toBe(false); + }); + + it("strips unknown keys so corrupt persisted values normalize cleanly", () => { + const parsed = ParsedRuntimeSchema.safeParse({ + mode: "docker", + image: "node:20", + shareCredentials: true, + bogus: "drop-me", + }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data).toEqual({ + mode: "docker", + image: "node:20", + shareCredentials: true, + }); + }); + + it("keeps a valid Coder SSH selection while stripping extra coder fields", () => { + const parsed = ParsedRuntimeSchema.safeParse({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: { existingWorkspace: true, junk: 1 }, + }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data).toEqual({ + mode: "ssh", + host: CODER_RUNTIME_PLACEHOLDER, + coder: { existingWorkspace: true }, + }); + }); +}); diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 21f05598d7..f2fcde6604 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -2,10 +2,10 @@ * Runtime configuration types for workspace execution environments */ -import type { z } from "zod"; +import { z } from "zod"; import type { RuntimeConfigSchema } from "../orpc/schemas"; import { RuntimeEnablementIdSchema, RuntimeModeSchema } from "../orpc/schemas"; -import type { CoderWorkspaceConfig } from "../orpc/schemas/coder"; +import { CoderWorkspaceConfigSchema, type CoderWorkspaceConfig } from "../orpc/schemas/coder"; // Re-export CoderWorkspaceConfig type from schema (single source of truth) export type { CoderWorkspaceConfig }; @@ -79,13 +79,33 @@ export type RuntimeConfig = z.infer; /** * Parsed runtime result - discriminated union based on mode. * SSH requires host, Docker requires image, others have no extra args. + * + * Schema-backed so persisted/UI values can be validated and normalized by + * construction (e.g. draft state read from localStorage) instead of hand-rolled + * per-field guards. `ParsedRuntime` is derived from this schema as the single + * source of truth for the shape. */ -export type ParsedRuntime = - | { mode: "local" } - | { mode: "worktree" } - | { mode: "ssh"; host: string; coder?: CoderWorkspaceConfig } - | { mode: "docker"; image: string; shareCredentials?: boolean } - | { mode: "devcontainer"; configPath: string; shareCredentials?: boolean }; +export const ParsedRuntimeSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal(RUNTIME_MODE.LOCAL) }), + z.object({ mode: z.literal(RUNTIME_MODE.WORKTREE) }), + z.object({ + mode: z.literal(RUNTIME_MODE.SSH), + host: z.string(), + coder: CoderWorkspaceConfigSchema.optional(), + }), + z.object({ + mode: z.literal(RUNTIME_MODE.DOCKER), + image: z.string(), + shareCredentials: z.boolean().optional(), + }), + z.object({ + mode: z.literal(RUNTIME_MODE.DEVCONTAINER), + configPath: z.string(), + shareCredentials: z.boolean().optional(), + }), +]); + +export type ParsedRuntime = z.infer; /** * Parse runtime string from localStorage or UI input into structured result. From 1d27a04b5754e173c2a8b2e3be5fa99278664f4d Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 5 Jun 2026 15:28:36 -0500 Subject: [PATCH 2/2] fix: clear all draft-scoped fields when clearing pending scope Codex: the non-draft pending composer persisted selectedRuntime but the post-creation cleanup only cleared input/attachments, restoring a stale runtime on the next blank composer. Clear every DRAFT_SCOPED_FIELDS entry. --- .../features/ChatInput/useCreationWorkspace.test.tsx | 9 +++++++-- .../features/ChatInput/useCreationWorkspace.ts | 11 +++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx index a9c414eb6a..6c6ae0aadc 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/features/ChatInput/useCreationWorkspace.test.tsx @@ -15,6 +15,7 @@ import { getPendingScopeId, getPendingWorkspaceSendErrorKey, getProjectScopeId, + getSelectedRuntimeKey, getThinkingLevelKey, } from "@/common/constants/storage"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; @@ -767,7 +768,7 @@ describe("useCreationWorkspace", () => { const pendingInputKey = getInputKey(pendingScopeId); const pendingImagesKey = getInputAttachmentsKey(pendingScopeId); // Thinking is workspace-scoped, but this test doesn't set a project-scoped thinking preference. - expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]); + expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, undefined]); expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]); }); @@ -1128,8 +1129,12 @@ describe("useCreationWorkspace", () => { const pendingInputKey = getInputKey(pendingScopeId); const pendingImagesKey = getInputAttachmentsKey(pendingScopeId); const pendingErrorKey = getPendingWorkspaceSendErrorKey(TEST_WORKSPACE_ID); - expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, ""]); + const pendingRuntimeKey = getSelectedRuntimeKey(pendingScopeId); + expect(updatePersistedStateCalls).toContainEqual([pendingInputKey, undefined]); expect(updatePersistedStateCalls).toContainEqual([pendingImagesKey, undefined]); + // Regression guard: the pending runtime selection must also be cleared after creation so a + // returning blank composer doesn't restore a stale one-off runtime choice. + expect(updatePersistedStateCalls).toContainEqual([pendingRuntimeKey, undefined]); expect(updatePersistedStateCalls).toContainEqual([pendingErrorKey, sendError]); }); test("onWorkspaceCreated is called before sendMessage resolves (no blocking)", async () => { diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 6fac760245..546b47d7c5 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -15,9 +15,8 @@ import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; import { + DRAFT_SCOPED_FIELDS, getAgentIdKey, - getInputKey, - getInputAttachmentsKey, getModelKey, getNotifyOnResponseAutoEnableKey, getNotifyOnResponseKey, @@ -569,8 +568,12 @@ export function useCreationWorkspace({ return; } - updatePersistedState(getInputKey(pendingScopeId), ""); - updatePersistedState(getInputAttachmentsKey(pendingScopeId), undefined); + // Clear every draft-scoped field (input, attachments, name, runtime, ...) so a + // returning blank composer for this pending scope starts from defaults rather than + // restoring stale draft state. Registry-driven so new draft fields clear automatically. + for (const field of DRAFT_SCOPED_FIELDS) { + updatePersistedState(field.key(pendingScopeId), undefined); + } }; // Sync preferences before switching (keeps workspace settings consistent).