@@ -351,13 +351,16 @@ const worker = setupWorker(
351351) ;
352352
353353async function mountApp ( options ?: {
354+ initialEntry ?: string ;
354355 routeThreadId ?: ThreadId ;
355356 waitForThreadId ?: ThreadId | null ;
356- } ) : Promise < { cleanup : ( ) => Promise < void > } > {
357+ } ) : Promise < { cleanup : ( ) => Promise < void > ; router : ReturnType < typeof getRouter > } > {
357358 const host = createFullscreenTestHost ( ) ;
358359
359360 const routeThreadId = options ?. routeThreadId ?? THREAD_ID ;
360- const router = getRouter ( createMemoryHistory ( { initialEntries : [ `/${ routeThreadId } ` ] } ) ) ;
361+ const router = getRouter (
362+ createMemoryHistory ( { initialEntries : [ options ?. initialEntry ?? `/${ routeThreadId } ` ] } ) ,
363+ ) ;
361364 const screen = await render ( < RouterProvider router = { router } /> , { container : host } ) ;
362365
363366 try {
@@ -391,6 +394,7 @@ async function mountApp(options?: {
391394 let cleanedUp = false ;
392395
393396 return {
397+ router,
394398 cleanup : async ( ) => {
395399 if ( cleanedUp ) return ;
396400 cleanedUp = true ;
@@ -444,7 +448,10 @@ function sendShellEventPush(event: OrchestrationShellStreamItem) {
444448 sendEffectRpcChunk ( shellStreamClient , shellStreamRequestId , event ) ;
445449}
446450
447- describe ( "EventRouter scoped orchestration sync" , ( ) => {
451+ // This file drives one browser app, WebSocket mock, and projection fixture. Keep
452+ // the cases serialized: several intentionally advance the fixture while their
453+ // route/stream assertions are pending, which is not safe to overlap.
454+ describe . sequential ( "EventRouter scoped orchestration sync" , ( ) => {
448455 beforeAll ( async ( ) => {
449456 fixture = buildFixture ( ) ;
450457 await worker . start ( {
@@ -522,6 +529,133 @@ describe("EventRouter scoped orchestration sync", () => {
522529 }
523530 } ) ;
524531
532+ it ( "keeps a live assistant intro when a lagging thread snapshot arrives right after it" , async ( ) => {
533+ await assertLiveAssistantIntroSurvivesLaggingSnapshot ( ) ;
534+ } ) ;
535+
536+ it ( "recovers a stale remembered route without opening a missing detail subscription" , async ( ) => {
537+ const staleThreadId = ThreadId . makeUnsafe ( "thread-stale-route" ) ;
538+ localStorage . setItem (
539+ "synara:sidebar-ui:v1" ,
540+ JSON . stringify ( {
541+ chatSectionExpanded : true ,
542+ chatThreadListExtraPages : 2 ,
543+ projectThreadListExtraPagesByCwd : { "/repo/project" : 1 } ,
544+ dismissedThreadStatusKeyByThreadId : { } ,
545+ lastThreadRoute : { threadId : staleThreadId } ,
546+ activityViewEnabled : false ,
547+ } ) ,
548+ ) ;
549+ const consoleError = vi . spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
550+ const mounted = await mountApp ( { routeThreadId : staleThreadId , waitForThreadId : null } ) ;
551+
552+ try {
553+ await vi . waitFor (
554+ ( ) => {
555+ expect ( mounted . router . state . location . pathname ) . not . toBe ( `/${ staleThreadId } ` ) ;
556+ expect (
557+ JSON . parse ( localStorage . getItem ( "synara:sidebar-ui:v1" ) ?? "null" ) ?. lastThreadRoute ,
558+ ) . toBeNull ( ) ;
559+ } ,
560+ { timeout : 4_000 , interval : 16 } ,
561+ ) ;
562+ await new Promise ( ( resolve ) => window . setTimeout ( resolve , 120 ) ) ;
563+ expect ( subscribeThreadRequestCountById . get ( staleThreadId ) ) . toBeUndefined ( ) ;
564+ expect ( consoleError ) . not . toHaveBeenCalled ( ) ;
565+ } finally {
566+ consoleError . mockRestore ( ) ;
567+ await mounted . cleanup ( ) ;
568+ }
569+ } ) ;
570+
571+ it ( "restores a valid remembered thread from the root route" , async ( ) => {
572+ localStorage . setItem (
573+ "synara:sidebar-ui:v1" ,
574+ JSON . stringify ( {
575+ lastThreadRoute : { threadId : THREAD_ID } ,
576+ } ) ,
577+ ) ;
578+ const mounted = await mountApp ( { initialEntry : "/" } ) ;
579+
580+ try {
581+ await vi . waitFor (
582+ ( ) => {
583+ expect ( mounted . router . state . location . pathname ) . toBe ( `/${ THREAD_ID } ` ) ;
584+ expect ( subscribeThreadRequestCountById . get ( THREAD_ID ) ) . toBeGreaterThanOrEqual ( 1 ) ;
585+ } ,
586+ { timeout : 4_000 , interval : 16 } ,
587+ ) ;
588+ } finally {
589+ await mounted . cleanup ( ) ;
590+ }
591+ } ) ;
592+
593+ it ( "clears a missing remembered thread while resolving the root route" , async ( ) => {
594+ const staleThreadId = ThreadId . makeUnsafe ( "thread-stale-root-restore" ) ;
595+ localStorage . setItem (
596+ "synara:sidebar-ui:v1" ,
597+ JSON . stringify ( {
598+ chatSectionExpanded : true ,
599+ lastThreadRoute : { threadId : staleThreadId } ,
600+ } ) ,
601+ ) ;
602+ const mounted = await mountApp ( { initialEntry : "/" , waitForThreadId : null } ) ;
603+
604+ try {
605+ await vi . waitFor (
606+ ( ) => {
607+ expect (
608+ JSON . parse ( localStorage . getItem ( "synara:sidebar-ui:v1" ) ?? "null" ) ?. lastThreadRoute ,
609+ ) . toBeNull ( ) ;
610+ expect ( mounted . router . state . location . pathname ) . not . toBe ( `/${ staleThreadId } ` ) ;
611+ } ,
612+ { timeout : 4_000 , interval : 16 } ,
613+ ) ;
614+ expect ( subscribeThreadRequestCountById . get ( staleThreadId ) ) . toBeUndefined ( ) ;
615+ } finally {
616+ await mounted . cleanup ( ) ;
617+ }
618+ } ) ;
619+
620+ it ( "stops leasing a deleted route and clears its remembered location after recovery" , async ( ) => {
621+ localStorage . setItem (
622+ "synara:sidebar-ui:v1" ,
623+ JSON . stringify ( {
624+ lastThreadRoute : { threadId : THREAD_ID } ,
625+ } ) ,
626+ ) ;
627+ const mounted = await mountApp ( ) ;
628+
629+ try {
630+ const subscribeCountBeforeDelete = subscribeThreadRequestCountById . get ( THREAD_ID ) ?? 0 ;
631+ fixture . snapshot = {
632+ ...fixture . snapshot ,
633+ snapshotSequence : 2 ,
634+ threads : [ ] ,
635+ } ;
636+ sendShellEventPush ( {
637+ kind : "thread-removed" ,
638+ sequence : 2 ,
639+ threadId : THREAD_ID ,
640+ } ) ;
641+
642+ await vi . waitFor (
643+ ( ) => {
644+ expect ( mounted . router . state . location . pathname ) . not . toBe ( `/${ THREAD_ID } ` ) ;
645+ expect (
646+ JSON . parse ( localStorage . getItem ( "synara:sidebar-ui:v1" ) ?? "null" ) ?. lastThreadRoute ,
647+ ) . toBeNull ( ) ;
648+ } ,
649+ { timeout : 5_000 , interval : 16 } ,
650+ ) ;
651+ await new Promise ( ( resolve ) => window . setTimeout ( resolve , 120 ) ) ;
652+ expect ( subscribeThreadRequestCountById . get ( THREAD_ID ) ) . toBe ( subscribeCountBeforeDelete ) ;
653+ } finally {
654+ fixture = buildFixture ( ) ;
655+ await mounted . cleanup ( ) ;
656+ }
657+ } ) ;
658+
525659 it ( "drops duplicate thread events after the thread snapshot sequence advances" , async ( ) => {
526660 const mounted = await mountApp ( ) ;
527661
@@ -1919,7 +2053,7 @@ describe("EventRouter scoped orchestration sync", () => {
19192053 }
19202054 } ) ;
19212055
1922- it ( "keeps a live assistant intro when a lagging thread snapshot arrives right after it" , async ( ) => {
2056+ async function assertLiveAssistantIntroSurvivesLaggingSnapshot ( ) {
19232057 const mounted = await mountApp ( ) ;
19242058
19252059 try {
@@ -1997,5 +2131,5 @@ describe("EventRouter scoped orchestration sync", () => {
19972131 fixture = buildFixture ( ) ;
19982132 await mounted . cleanup ( ) ;
19992133 }
2000- } ) ;
2134+ }
20012135} ) ;
0 commit comments