Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 118 additions & 1 deletion apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,19 +13,24 @@ 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";
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";
Expand All @@ -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 router={router} />, {
// 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 router={router} />, {
// 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");
Expand Down
26 changes: 25 additions & 1 deletion apps/web/src/components/chat/useComposerStash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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")
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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)) {
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 70 additions & 1 deletion apps/web/test/fakeEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -227,6 +230,7 @@ export interface FakeThread {
readonly providerThreads?: ReadonlyArray<OrchestrationV2ProviderThread>;
readonly runs: ReadonlyArray<OrchestrationV2Run>;
readonly items: ReadonlyArray<OrchestrationV2TurnItem>;
readonly runtimeRequests?: OrchestrationV2ThreadProjection["runtimeRequests"];
}

/** A thread of `turns` user prompts, each answered in markdown with a list and inline code. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -341,7 +386,7 @@ function projectionOf(thread: FakeThread): OrchestrationV2ThreadProjection {
providerSessions: [],
providerThreads: [...(thread.providerThreads ?? [])],
providerTurns: [],
runtimeRequests: [],
runtimeRequests: [...(thread.runtimeRequests ?? [])],
messages: [],
plans: [],
turnItems: [...thread.items],
Expand All @@ -368,6 +413,7 @@ let pendingDispatches: Array<{
readonly command: OrchestrationV2Command;
readonly reply: Deferred.Deferred<never, OrchestrationV2DispatchCommandError>;
}> = [];
let pendingAssetUrls: Array<Deferred.Deferred<AssetCreateUrlResult>> = [];
let sequence = 1;
let vcsStatusSubscriber: Queue.Queue<VcsStatusStreamEvent> | null = null;

Expand Down Expand Up @@ -396,6 +442,13 @@ const handlers: Record<string, (input: never) => 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<AssetCreateUrlResult>();
pendingAssetUrls.push(reply);
return Deferred.await(reply);
}),
[WS_METHODS.subscribeTerminalMetadata]: () =>
subscribe<TerminalMetadataStreamEvent>([{ type: "snapshot", terminals: [] }], (queue) => {
terminalMetadataSubscriber = queue;
Expand Down Expand Up @@ -544,6 +597,7 @@ export function installFakeEnvironment(
acceptCommands = false;
holdCommand = () => false;
pendingDispatches = [];
pendingAssetUrls = [];
},
};
}
Expand All @@ -564,6 +618,21 @@ export async function rejectPendingCommand(message: string): Promise<void> {
);
}

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<void> {
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<OrchestrationV2Command> {
return dispatched;
Expand Down
Loading