Skip to content

Commit 332973c

Browse files
jasonLasterclaude
andcommitted
fix(web): prevent new thread from being deselected after first message
clearDraftThread was called eagerly after thread.turn.start resolved, before the server snapshot had synced back. The route guard in _chat.$threadId saw neither a draft nor a server thread and redirected to /. Move draft cleanup into the EventRouter snapshot sync so the draft is only removed once the server thread exists in the store. Adds a browser test that creates a new thread, simulates a snapshot sync promoting the draft to a server thread, clears the draft, and asserts the route and composer remain intact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c44b38e commit 332973c

6 files changed

Lines changed: 224 additions & 5 deletions

File tree

apps/web/src/components/ChatView.browser.tsx

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { useStore } from "../store";
2525
import { estimateTimelineMessageHeight } from "./timelineHeight";
2626

2727
const THREAD_ID = "thread-browser-test" as ThreadId;
28+
const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
2829
const PROJECT_ID = "project-1" as ProjectId;
2930
const NOW_ISO = "2026-03-04T12:00:00.000Z";
3031
const BASE_TIME_MS = Date.parse(NOW_ISO);
@@ -85,6 +86,7 @@ interface MountedChatView {
8586
cleanup: () => Promise<void>;
8687
measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
8788
setViewport: (viewport: ViewportSpec) => Promise<void>;
89+
router: ReturnType<typeof getRouter>;
8890
}
8991

9092
function isoAt(offsetSeconds: number): string {
@@ -245,6 +247,46 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
245247
};
246248
}
247249

250+
function addThreadToSnapshot(
251+
snapshot: OrchestrationReadModel,
252+
threadId: ThreadId,
253+
): OrchestrationReadModel {
254+
return {
255+
...snapshot,
256+
snapshotSequence: snapshot.snapshotSequence + 1,
257+
threads: [
258+
...snapshot.threads,
259+
{
260+
id: threadId,
261+
projectId: PROJECT_ID,
262+
title: "New thread",
263+
model: "gpt-5",
264+
interactionMode: "default",
265+
runtimeMode: "full-access",
266+
branch: "main",
267+
worktreePath: null,
268+
latestTurn: null,
269+
createdAt: NOW_ISO,
270+
updatedAt: NOW_ISO,
271+
deletedAt: null,
272+
messages: [],
273+
activities: [],
274+
proposedPlans: [],
275+
checkpoints: [],
276+
session: {
277+
threadId,
278+
status: "ready",
279+
providerName: "codex",
280+
runtimeMode: "full-access",
281+
activeTurnId: null,
282+
lastError: null,
283+
updatedAt: NOW_ISO,
284+
},
285+
},
286+
],
287+
};
288+
}
289+
248290
function createDraftOnlySnapshot(): OrchestrationReadModel {
249291
const snapshot = createSnapshotForTargetUser({
250292
targetMessageId: "msg-user-draft-target" as MessageId,
@@ -256,6 +298,61 @@ function createDraftOnlySnapshot(): OrchestrationReadModel {
256298
};
257299
}
258300

301+
function createSnapshotWithLongProposedPlan(): OrchestrationReadModel {
302+
const snapshot = createSnapshotForTargetUser({
303+
targetMessageId: "msg-user-plan-target" as MessageId,
304+
targetText: "plan thread",
305+
});
306+
const planMarkdown = [
307+
"# Ship plan mode follow-up",
308+
"",
309+
"- Step 1: capture the thread-open trace",
310+
"- Step 2: identify the main-thread bottleneck",
311+
"- Step 3: keep collapsed cards cheap",
312+
"- Step 4: render the full markdown only on demand",
313+
"- Step 5: preserve export and save actions",
314+
"- Step 6: add regression coverage",
315+
"- Step 7: verify route transitions stay responsive",
316+
"- Step 8: confirm no server-side work changed",
317+
"- Step 9: confirm short plans still render normally",
318+
"- Step 10: confirm long plans stay collapsed by default",
319+
"- Step 11: confirm preview text is still useful",
320+
"- Step 12: confirm plan follow-up flow still works",
321+
"- Step 13: confirm timeline virtualization still behaves",
322+
"- Step 14: confirm theme styling still looks correct",
323+
"- Step 15: confirm save dialog behavior is unchanged",
324+
"- Step 16: confirm download behavior is unchanged",
325+
"- Step 17: confirm code fences do not parse until expand",
326+
"- Step 18: confirm preview truncation ends cleanly",
327+
"- Step 19: confirm markdown links still open in editor after expand",
328+
"- Step 20: confirm deep hidden detail only appears after expand",
329+
"",
330+
"```ts",
331+
"export const hiddenPlanImplementationDetail = 'deep hidden detail only after expand';",
332+
"```",
333+
].join("\n");
334+
335+
return {
336+
...snapshot,
337+
threads: snapshot.threads.map((thread) =>
338+
thread.id === THREAD_ID
339+
? Object.assign({}, thread, {
340+
proposedPlans: [
341+
{
342+
id: "plan-browser-test",
343+
turnId: null,
344+
planMarkdown,
345+
createdAt: isoAt(1_000),
346+
updatedAt: isoAt(1_001),
347+
},
348+
],
349+
updatedAt: isoAt(1_001),
350+
})
351+
: thread,
352+
),
353+
};
354+
}
355+
259356
function resolveWsRpc(tag: string): unknown {
260357
if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) {
261358
return fixture.snapshot;
@@ -393,6 +490,22 @@ async function waitForElement<T extends Element>(
393490
return element;
394491
}
395492

493+
async function waitForURL(
494+
router: ReturnType<typeof getRouter>,
495+
predicate: (pathname: string) => boolean,
496+
errorMessage: string,
497+
): Promise<string> {
498+
let pathname = "";
499+
await vi.waitFor(
500+
() => {
501+
pathname = router.state.location.pathname;
502+
expect(predicate(pathname), errorMessage).toBe(true);
503+
},
504+
{ timeout: 8_000, interval: 16 },
505+
);
506+
return pathname;
507+
}
508+
396509
async function waitForComposerEditor(): Promise<HTMLElement> {
397510
return waitForElement(
398511
() => document.querySelector<HTMLElement>('[contenteditable="true"]'),
@@ -536,6 +649,7 @@ async function mountChatView(options: {
536649
await setViewport(viewport);
537650
await waitForProductionStyles();
538651
},
652+
router,
539653
};
540654
}
541655

@@ -905,4 +1019,93 @@ describe("ChatView timeline estimator parity (full app)", () => {
9051019
await mounted.cleanup();
9061020
}
9071021
});
1022+
1023+
it("keeps the new thread selected after clicking the new-thread button", async () => {
1024+
const mounted = await mountChatView({
1025+
viewport: DEFAULT_VIEWPORT,
1026+
snapshot: createSnapshotForTargetUser({
1027+
targetMessageId: "msg-user-new-thread-test" as MessageId,
1028+
targetText: "new thread selection test",
1029+
}),
1030+
});
1031+
1032+
try {
1033+
// Wait for the sidebar to render with the project.
1034+
const newThreadButton = page.getByTestId("new-thread-button");
1035+
await expect.element(newThreadButton).toBeInTheDocument();
1036+
1037+
await newThreadButton.click();
1038+
1039+
// The route should change to a new draft thread ID.
1040+
const newThreadPath = await waitForURL(
1041+
mounted.router,
1042+
(path) => UUID_ROUTE_RE.test(path),
1043+
"Route should have changed to a new draft thread UUID.",
1044+
);
1045+
const newThreadId = newThreadPath.slice(1) as ThreadId;
1046+
1047+
// The composer editor should be present for the new draft thread.
1048+
await waitForComposerEditor();
1049+
1050+
// Simulate the snapshot sync arriving from the server after the draft
1051+
// thread has been promoted to a server thread (thread.create + turn.start
1052+
// succeeded). The snapshot now includes the new thread, and the sync
1053+
// should clear the draft without disrupting the route.
1054+
const { syncServerReadModel } = useStore.getState();
1055+
syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId));
1056+
1057+
// Clear the draft now that the server thread exists (mirrors EventRouter behavior).
1058+
useComposerDraftStore.getState().clearDraftThread(newThreadId);
1059+
1060+
// The route should still be on the new thread — not redirected away.
1061+
await waitForURL(
1062+
mounted.router,
1063+
(path) => path === newThreadPath,
1064+
"New thread should remain selected after snapshot sync clears the draft.",
1065+
);
1066+
1067+
// The empty thread view and composer should still be visible.
1068+
await expect.element(page.getByText("Send a message to start the conversation.")).toBeInTheDocument();
1069+
await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
1070+
} finally {
1071+
await mounted.cleanup();
1072+
}
1073+
});
1074+
1075+
it("keeps long proposed plans lightweight until the user expands them", async () => {
1076+
const mounted = await mountChatView({
1077+
viewport: DEFAULT_VIEWPORT,
1078+
snapshot: createSnapshotWithLongProposedPlan(),
1079+
});
1080+
1081+
try {
1082+
await waitForElement(
1083+
() =>
1084+
Array.from(document.querySelectorAll("button")).find(
1085+
(button) => button.textContent?.trim() === "Expand plan",
1086+
) as HTMLButtonElement | null,
1087+
"Unable to find Expand plan button.",
1088+
);
1089+
1090+
expect(document.body.textContent).not.toContain("deep hidden detail only after expand");
1091+
1092+
const expandButton = await waitForElement(
1093+
() =>
1094+
Array.from(document.querySelectorAll("button")).find(
1095+
(button) => button.textContent?.trim() === "Expand plan",
1096+
) as HTMLButtonElement | null,
1097+
"Unable to find Expand plan button.",
1098+
);
1099+
expandButton.click();
1100+
1101+
await vi.waitFor(
1102+
() => {
1103+
expect(document.body.textContent).toContain("deep hidden detail only after expand");
1104+
},
1105+
{ timeout: 8_000, interval: 16 },
1106+
);
1107+
} finally {
1108+
await mounted.cleanup();
1109+
}
1110+
});
9081111
});

apps/web/src/components/ChatView.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
607607
(store) => store.syncPersistedAttachments,
608608
);
609609
const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
610-
const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
611610
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
612611
const draftThread = useComposerDraftStore(
613612
(store) => store.draftThreadsByThreadId[threadId] ?? null,
@@ -2690,9 +2689,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
26902689
createdAt: messageCreatedAt,
26912690
});
26922691
turnStartSucceeded = true;
2693-
if (isFirstMessage) {
2694-
clearDraftThread(threadIdForSend);
2695-
}
26962692
})().catch(async (err: unknown) => {
26972693
if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
26982694
await api.orchestration

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,7 @@ function ComposerPromptEditorInner({
749749
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",
750750
className,
751751
)}
752+
data-testid="composer-editor"
752753
aria-placeholder={placeholder}
753754
placeholder={<span />}
754755
onPaste={onPaste}

apps/web/src/components/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,7 @@ export default function Sidebar() {
13201320
<button
13211321
type="button"
13221322
aria-label={`Create new thread in ${project.name}`}
1323+
data-testid="new-thread-button"
13231324
/>
13241325
}
13251326
showOnHover

apps/web/src/composerDraftStore.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,3 +1282,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12821282
export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {
12831283
return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
12841284
}
1285+
1286+
/**
1287+
* Clear draft threads that have been promoted to server threads.
1288+
*
1289+
* Call this after a snapshot sync so the route guard in `_chat.$threadId`
1290+
* sees the server thread before the draft is removed — avoids a redirect
1291+
* to `/` caused by a gap where neither draft nor server thread exists.
1292+
*/
1293+
export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet<ThreadId>): void {
1294+
const store = useComposerDraftStore.getState();
1295+
const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[];
1296+
for (const draftId of draftThreadIds) {
1297+
if (serverThreadIds.has(draftId)) {
1298+
store.clearDraftThread(draftId);
1299+
}
1300+
}
1301+
}

apps/web/src/routes/__root.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { Button } from "../components/ui/button";
1515
import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
1616
import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1717
import { readNativeApi } from "../nativeApi";
18-
import { useComposerDraftStore } from "../composerDraftStore";
18+
import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
1919
import { useStore } from "../store";
2020
import { useTerminalStateStore } from "../terminalStateStore";
2121
import { preferredTerminalEditor } from "../terminal-links";
@@ -158,6 +158,7 @@ function EventRouter() {
158158
if (disposed) return;
159159
latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
160160
syncServerReadModel(snapshot);
161+
clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id)));
161162
const draftThreadIds = Object.keys(
162163
useComposerDraftStore.getState().draftThreadsByThreadId,
163164
) as ThreadId[];

0 commit comments

Comments
 (0)