diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 7b164e8115420..fe1a6b6c1bf19 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,6 +1,6 @@ import { createMemoryHistory } from "@tanstack/react-router"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { RunId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, RunId, 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"; @@ -13,6 +13,8 @@ import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { environmentContextRecord } from "~/lib/composerContextRecords"; +import { usePromptStashStore } from "~/promptStashStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -20,12 +22,15 @@ import { environmentThreadDetails } from "~/state/threads"; import { ENVIRONMENT_ID, PROJECT_ID, + answerAssetUrls, assistantItem, dispatchedCommands, emitThreadEvent, + hasPendingAssetUrl, installFakeEnvironment, rejectPendingCommand, threadWithHistory, + threadWithQuestion, type FakeDriver, } from "../../test/fakeEnvironment"; import { act, fakeTimers, render } from "../../test/render"; @@ -45,6 +50,118 @@ 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); + 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", + }); + 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")); + // 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)); + + // 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(); + + 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 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 7d389d0ad9e64..b1f762f8f0fa7 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -144,6 +144,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); @@ -158,6 +161,23 @@ export function useComposerStash(input: { setStashPulseKey((key) => key + 1); }; + // 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 && + 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 true; + } + return false; + }); + const restoreStashEntry = async (menuEntry: PromptStashEntry) => { const filesToVerify = menuEntry.files ?? []; if (filesToVerify.some((file) => file.environmentId !== environmentId)) { @@ -168,6 +188,7 @@ export function useComposerStash(input: { }); return; } + if (refuseMachinesInAnswer(menuEntry)) return; setIsStashMenuOpen(false); // An expired upload restores as a needs-reattach row instead of a reference @@ -190,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; } @@ -482,6 +504,7 @@ export function useComposerStash(input: { const stashedRecords: ComposerContextRecord[] = [ ...(replyTo ? [replyTo] : []), ...composerThreadContexts, + ...(getComposerDraft(composerDraftTarget)?.environmentContexts ?? []), ...readTerminalContexts().map(terminalContextRecord), ...composerReviewComments.map(reviewCommentContextRecord), ...composerPreviewAnnotations.map((annotation) => @@ -585,6 +608,7 @@ export function useComposerStash(input: { clearComposerDraftPromptAndImages(stashTarget); clearComposerDraftTerminalContexts(stashTarget); setComposerDraftThreadContexts(stashTarget, []); + setComposerDraftEnvironmentContexts(stashTarget, []); setComposerDraftReplyTo(stashTarget, null); for (const comment of composerReviewComments) { removeComposerDraftReviewComment(stashTarget, comment.id); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 24aba2f822ea1..6fc18e58dea8a 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -2,17 +2,20 @@ // 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, ExecutionEnvironmentDescriptor, MessageId, + NodeId, OrchestrationV2DispatchCommandError, ORCHESTRATION_PROTOCOL_VERSION, ORCHESTRATION_V2_WS_METHODS, ProjectId, ProviderInstanceId, RunId, + RuntimeRequestId, ServerConfig, ThreadId, TurnItemId, @@ -227,6 +230,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 +260,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 +386,7 @@ function projectionOf(thread: FakeThread): OrchestrationV2ThreadProjection { providerSessions: [], providerThreads: [...(thread.providerThreads ?? [])], providerTurns: [], - runtimeRequests: [], + runtimeRequests: [...(thread.runtimeRequests ?? [])], messages: [], plans: [], turnItems: [...thread.items], @@ -368,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; @@ -396,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; @@ -544,6 +597,7 @@ export function installFakeEnvironment( acceptCommands = false; holdCommand = () => false; pendingDispatches = []; + pendingAssetUrls = []; }, }; } @@ -564,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;