@@ -25,6 +25,7 @@ import { useStore } from "../store";
2525import { estimateTimelineMessageHeight } from "./timelineHeight" ;
2626
2727const THREAD_ID = "thread-browser-test" as ThreadId ;
28+ const UUID_ROUTE_RE = / ^ \/ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / ;
2829const PROJECT_ID = "project-1" as ProjectId ;
2930const NOW_ISO = "2026-03-04T12:00:00.000Z" ;
3031const 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
9092function 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+
248290function 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+
259356function 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+
396509async 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} ) ;
0 commit comments