From 7135dcc158eefeb62ab2300bb5bde8cafaf11999 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 16:04:39 +0000 Subject: [PATCH 1/5] fix(web): a stashed prompt keeps its machine mentions Stashing saved thread, terminal, review and preview records but not the draft's environment (machine) records, so a stash restored in another thread or after a reload showed the machine chip as unavailable, and the stashed thread kept an empty draft. Save the draft's environment contexts with the other records and clear them from the stashed draft. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 53 +++++++++++++++++-- .../src/components/chat/useComposerStash.ts | 5 ++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 8fe98b78da113..111ec575c49d5 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,19 +1,22 @@ import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { environmentContextRecord } from "~/lib/composerContextRecords"; +import { usePromptStashStore } from "~/promptStashStore"; import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, installFakeEnvironment, + threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; import { render } from "../../test/render"; @@ -33,6 +36,50 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ trackRpcRequestSent: () => {}, })); +it("a machine chip stashed in one thread restores with its machine in another", async () => { + const other = threadWithHistory("thread-stash-restore", 1); + using _environment = installFakeEnvironment([other]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const draftId = DraftId.make("draft-stash-machine"); + const machine = environmentContextRecord(EnvironmentId.make("environment-build"), "Build box"); + const drafts = useComposerDraftStore.getState(); + drafts.setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { + threadId: ThreadId.make("thread-stash-machine"), + }); + drafts.setPrompt(draftId, "Deploy on "); + drafts.addEnvironmentContexts(draftId, [machine]); + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + const editor = page.getByTestId("composer-editor"); + const pressStashShortcut = async () => { + await userEvent.click(editor); + await userEvent.keyboard("{ControlOrMeta>}s{/ControlOrMeta}"); + }; + await vi.waitFor(() => expect(editor.element().textContent).toContain("Deploy on")); + await pressStashShortcut(); + await vi.waitFor(() => expect(usePromptStashStore.getState().entries).toHaveLength(1)); + + await router.navigate({ to: `/${ENVIRONMENT_ID}/${other.id}` }); + await expect.element(page.getByText("Answer 0 for thread-stash-restore.")).toBeVisible(); + await pressStashShortcut(); + + await vi.waitFor(() => expect(editor.element().textContent).toContain("Deploy on")); + expect( + useComposerDraftStore.getState().getComposerDraft(scopeThreadRef(ENVIRONMENT_ID, other.id)) + ?.environmentContexts, + ).toEqual([machine]); + expect( + useComposerDraftStore.getState().getComposerDraft(draftId)?.environmentContexts ?? [], + ).toEqual([]); +}); + it("a new draft does not load its reserved thread before the server lists it", async () => { using _environment = installFakeEnvironment([]); using fetchSpy = vi.spyOn(globalThis, "fetch"); diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 1d480dcf150e1..ddf1d8e7c2f42 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -141,6 +141,9 @@ export function useComposerStash(input: { (store) => store.clearComposerPromptAndImages, ); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); + const setComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.setEnvironmentContexts, + ); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); // Each stash bumps the key; the badge replays its one-shot pulse animation per key. const [stashPulseKey, setStashPulseKey] = useState(0); @@ -461,6 +464,7 @@ export function useComposerStash(input: { const files = [...attachmentDraft.files]; const stashedRecords: ComposerContextRecord[] = [ ...composerThreadContexts, + ...(getComposerDraft(composerDraftTarget)?.environmentContexts ?? []), ...readTerminalContexts().map(terminalContextRecord), ...composerReviewComments.map(reviewCommentContextRecord), ...composerPreviewAnnotations.map((annotation) => @@ -564,6 +568,7 @@ export function useComposerStash(input: { clearComposerDraftPromptAndImages(stashTarget); clearComposerDraftTerminalContexts(stashTarget); setComposerDraftThreadContexts(stashTarget, []); + setComposerDraftEnvironmentContexts(stashTarget, []); for (const comment of composerReviewComments) { removeComposerDraftReviewComment(stashTarget, comment.id); } From 7580e883e7df5c5bb7bd7c0d85e014e23737b076 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 09:49:38 +0000 Subject: [PATCH 2/5] test(web): wait for the draft's project before stashing in the machine chip test ChatView now loads eagerly in this file, so the composer renders before the shell snapshot lists the draft's project, and Mod+S does nothing until it does. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.browser.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 20e7560fdf1e4..4f17551aa777a 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -68,6 +68,8 @@ it("a machine chip stashed in one thread restores with its machine in another", await userEvent.keyboard("{ControlOrMeta>}s{/ControlOrMeta}"); }; await vi.waitFor(() => expect(editor.element().textContent).toContain("Deploy on")); + // A draft can't stash until the shell snapshot lists its project. + await expect.element(page.getByText("render", { exact: true }).first()).toBeVisible(); await pressStashShortcut(); await vi.waitFor(() => expect(usePromptStashStore.getState().entries).toHaveLength(1)); From 6abff53bfaa7b3743e12268197b60b853d6e8fda Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 11:25:24 +0000 Subject: [PATCH 3/5] fix(web): keep a machine stash when a question is open --- apps/web/src/components/chat/useComposerStash.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index ddf1d8e7c2f42..08bf880a60d1f 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -168,6 +168,18 @@ export function useComposerStash(input: { }); return; } + // An answer to an agent's question carries no machines, so restoring here would drop them. + if ( + pendingUserInputs.length > 0 && + menuEntry.records?.some((record) => record.kind === "environment") + ) { + toastManager.add({ + type: "error", + title: "Stashed machines can't go in an answer", + description: "Restore this prompt after the agent's question is answered.", + }); + return; + } setIsStashMenuOpen(false); // An expired upload restores as a needs-reattach row instead of a reference From c6bcd2e515f5fb7285815a58ba61e59fc3f5f211 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 10:45:42 +0000 Subject: [PATCH 4/5] test(web): an open question keeps a stashed machine prompt Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 14 +++++- apps/web/test/fakeEnvironment.ts | 46 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 70420c63a8d96..ac172e662d48b 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -24,6 +24,7 @@ import { installFakeEnvironment, rejectPendingCommand, threadWithHistory, + threadWithQuestion, type FakeDriver, } from "../../test/fakeEnvironment"; import { act, render } from "../../test/render"; @@ -45,7 +46,8 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ it("a machine chip stashed in one thread restores with its machine in another", async () => { const other = threadWithHistory("thread-stash-restore", 1); - using _environment = installFakeEnvironment([other]); + const asking = threadWithQuestion("thread-stash-question", "Which file should change?"); + using _environment = installFakeEnvironment([other, asking]); __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00.000Z", @@ -75,6 +77,16 @@ it("a machine chip stashed in one thread restores with its machine in another", await pressStashShortcut(); await vi.waitFor(() => expect(usePromptStashStore.getState().entries).toHaveLength(1)); + // An answer carries no machines, so an open question refuses the restore and keeps the entry. + await router.navigate({ to: `/${ENVIRONMENT_ID}/${asking.id}` }); + await expect.element(page.getByText("Which file should change?")).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: /^Stashed prompts/ })); + await userEvent.click(page.getByRole("button", { name: /^Restore stashed prompt/ })); + await vi.waitFor(() => + expect(document.body.textContent).toContain("Stashed machines can't go in an answer"), + ); + expect(usePromptStashStore.getState().entries).toHaveLength(1); + await router.navigate({ to: `/${ENVIRONMENT_ID}/${other.id}` }); await expect.element(page.getByText("Answer 0 for thread-stash-restore.")).toBeVisible(); await pressStashShortcut(); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 24aba2f822ea1..1d4ed3627216b 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -7,12 +7,14 @@ import { EventId, ExecutionEnvironmentDescriptor, MessageId, + NodeId, OrchestrationV2DispatchCommandError, ORCHESTRATION_PROTOCOL_VERSION, ORCHESTRATION_V2_WS_METHODS, ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ServerConfig, ThreadId, TurnItemId, @@ -227,6 +229,7 @@ export interface FakeThread { readonly providerThreads?: ReadonlyArray; readonly runs: ReadonlyArray; readonly items: ReadonlyArray; + readonly runtimeRequests?: OrchestrationV2ThreadProjection["runtimeRequests"]; } /** A thread of `turns` user prompts, each answered in markdown with a list and inline code. */ @@ -256,6 +259,47 @@ export function threadWithHistory( return { id, title: `Thread ${name}`, modelSelection, runs, items }; } +/** A one-turn thread whose agent waits on an answer to one question. */ +export function threadWithQuestion(name: string, question: string): FakeThread { + const thread = threadWithHistory(name, 1); + const requestId = RuntimeRequestId.make(`${name}-request`); + return { + ...thread, + items: [ + ...thread.items, + { + ...base(thread.id, RunId.make(`${name}-run-0`), `${name}-question`, 2), + type: "user_input_request", + requestId, + responseMode: "message", + questions: [ + { + id: "choice", + header: "Choice", + question, + required: true, + allowCustomAnswer: false, + options: [{ label: "Continue", value: "continue", description: "Resume work" }], + }, + ], + }, + ], + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make(`${name}-node`), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: AT, + resolvedAt: null, + }, + ], + }; +} + function shellOf(thread: FakeThread): OrchestrationV2ThreadShell { const latest = thread.runs.at(-1); const modelSelection = thread.modelSelection ?? MODEL; @@ -341,7 +385,7 @@ function projectionOf(thread: FakeThread): OrchestrationV2ThreadProjection { providerSessions: [], providerThreads: [...(thread.providerThreads ?? [])], providerTurns: [], - runtimeRequests: [], + runtimeRequests: [...(thread.runtimeRequests ?? [])], messages: [], plans: [], turnItems: [...thread.items], From 6655361f5b2339d73839c3d7121b981afc8014c9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 12:04:30 +0000 Subject: [PATCH 5/5] fix(web): a question that opens during a stash restore keeps the machines stashed Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 58 +++++++++++++++++++ .../src/components/chat/useComposerStash.ts | 29 ++++++---- apps/web/test/fakeEnvironment.ts | 25 ++++++++ 3 files changed, 101 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index ac172e662d48b..0b79e60f867db 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -20,7 +20,10 @@ import { getRouter } from "~/router"; import { ENVIRONMENT_ID, PROJECT_ID, + answerAssetUrls, dispatchedCommands, + emitThreadEvent, + hasPendingAssetUrl, installFakeEnvironment, rejectPendingCommand, threadWithHistory, @@ -101,6 +104,61 @@ it("a machine chip stashed in one thread restores with its machine in another", ).toEqual([]); }); +it("a question that opens while a stashed file is checked keeps a machine stash", async () => { + const thread = threadWithHistory("thread-stash-late-question", 1); + const asking = threadWithQuestion("thread-stash-late-question", "Which file should change?"); + const [request] = asking.runtimeRequests ?? []; + const question = asking.items.at(-1); + if (!request || !question) throw new Error("threadWithQuestion has no question"); + using _environment = installFakeEnvironment([thread]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + usePromptStashStore.getState().stashEntry({ + id: "stash-late-question", + createdAt: "2026-01-01T00:00:00.000Z", + prompt: "Deploy on ", + attachments: [], + files: [ + { + id: "file-notes", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 5, + attachmentId: "attachment-notes", + environmentId: ENVIRONMENT_ID, + }, + ], + droppedImageNames: [], + records: [environmentContextRecord(EnvironmentId.make("environment-build"), "Build box")], + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText("Answer 0 for thread-stash-late-question.")).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: /^Stashed prompts/ })); + await userEvent.click(page.getByRole("button", { name: /^Restore stashed prompt/ })); + + // The question opens while the restore still waits on the server's file check. + await vi.waitFor(() => expect(hasPendingAssetUrl()).toBe(true)); + await emitThreadEvent(thread.id, { type: "turn-item.updated", payload: question }); + await emitThreadEvent(thread.id, { type: "runtime-request.updated", payload: request }); + await expect.element(page.getByText("Which file should change?")).toBeVisible(); + await answerAssetUrls(); + + await vi.waitFor(() => + expect(document.body.textContent).toContain("Stashed machines can't go in an answer"), + ); + expect(usePromptStashStore.getState().entries).toHaveLength(1); + usePromptStashStore.getState().takeEntry("stash-late-question"); +}); + it("a new draft does not load its reserved thread before the server lists it", async () => { using _environment = installFakeEnvironment([]); using fetchSpy = vi.spyOn(globalThis, "fetch"); diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 1aeeeb8f97ba9..b1f762f8f0fa7 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -161,16 +161,8 @@ export function useComposerStash(input: { setStashPulseKey((key) => key + 1); }; - const restoreStashEntry = async (menuEntry: PromptStashEntry) => { - const filesToVerify = menuEntry.files ?? []; - if (filesToVerify.some((file) => file.environmentId !== environmentId)) { - toastManager.add({ - type: "error", - title: "Stashed files belong to another environment", - description: "Restore this prompt in the environment that received its files.", - }); - return; - } + // Stable, so the check after the verify await sees a question that opened during it. + const refuseMachinesInAnswer = useStableCallback((menuEntry: PromptStashEntry) => { // An answer to an agent's question carries no machines, so restoring here would drop them. if ( pendingUserInputs.length > 0 && @@ -181,8 +173,22 @@ export function useComposerStash(input: { title: "Stashed machines can't go in an answer", description: "Restore this prompt after the agent's question is answered.", }); + return true; + } + return false; + }); + + const restoreStashEntry = async (menuEntry: PromptStashEntry) => { + const filesToVerify = menuEntry.files ?? []; + if (filesToVerify.some((file) => file.environmentId !== environmentId)) { + toastManager.add({ + type: "error", + title: "Stashed files belong to another environment", + description: "Restore this prompt in the environment that received its files.", + }); return; } + if (refuseMachinesInAnswer(menuEntry)) return; setIsStashMenuOpen(false); // An expired upload restores as a needs-reattach row instead of a reference @@ -205,7 +211,8 @@ export function useComposerStash(input: { // so abort and leave the entry restorable where the user now is. if ( isRevertingCheckpoint() || - !isComposerDraftTargetOpen(composerTargetKey(composerDraftTarget)) + !isComposerDraftTargetOpen(composerTargetKey(composerDraftTarget)) || + refuseMachinesInAnswer(menuEntry) ) { return; } diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 1d4ed3627216b..6fc18e58dea8a 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -2,6 +2,7 @@ // session that answers the shell, thread and config subscriptions from fixtures. Other RPCs // stay pending so nothing retries on timers. Everything above the wire is the app's own. import { + type AssetCreateUrlResult, AuthStandardClientScopes, EnvironmentId, EventId, @@ -412,6 +413,7 @@ let pendingDispatches: Array<{ readonly command: OrchestrationV2Command; readonly reply: Deferred.Deferred; }> = []; +let pendingAssetUrls: Array> = []; let sequence = 1; let vcsStatusSubscriber: Queue.Queue | null = null; @@ -440,6 +442,13 @@ const handlers: Record unknown> = { pendingDispatches.push({ command, reply }); return Deferred.await(reply); }), + // Held like any other unanswered call, until `answerAssetUrls`. + [WS_METHODS.assetsCreateUrl]: () => + Effect.suspend(() => { + const reply = Deferred.makeUnsafe(); + pendingAssetUrls.push(reply); + return Deferred.await(reply); + }), [WS_METHODS.subscribeTerminalMetadata]: () => subscribe([{ type: "snapshot", terminals: [] }], (queue) => { terminalMetadataSubscriber = queue; @@ -588,6 +597,7 @@ export function installFakeEnvironment( acceptCommands = false; holdCommand = () => false; pendingDispatches = []; + pendingAssetUrls = []; }, }; } @@ -608,6 +618,21 @@ export async function rejectPendingCommand(message: string): Promise { ); } +export function hasPendingAssetUrl(): boolean { + return pendingAssetUrls.length > 0; +} + +/** Answers every held asset URL request, as a server that still has the file. */ +export async function answerAssetUrls(): Promise { + const replies = pendingAssetUrls; + pendingAssetUrls = []; + for (const reply of replies) { + await Effect.runPromise( + Deferred.succeed(reply, { relativeUrl: "/api/assets/fake", expiresAt: 4_102_444_800_000 }), + ); + } +} + /** Commands the app dispatched to the environment, oldest first. */ export function dispatchedCommands(): ReadonlyArray { return dispatched;