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;