@@ -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 ;
@@ -522,6 +526,128 @@ describe("EventRouter scoped orchestration sync", () => {
522526 }
523527 } ) ;
524528
529+ it ( "recovers a stale remembered route without opening a missing detail subscription" , async ( ) => {
530+ const staleThreadId = ThreadId . makeUnsafe ( "thread-stale-route" ) ;
531+ localStorage . setItem (
532+ "synara:sidebar-ui:v1" ,
533+ JSON . stringify ( {
534+ chatSectionExpanded : true ,
535+ chatThreadListExtraPages : 2 ,
536+ projectThreadListExtraPagesByCwd : { "/repo/project" : 1 } ,
537+ dismissedThreadStatusKeyByThreadId : { } ,
538+ lastThreadRoute : { threadId : staleThreadId } ,
539+ activityViewEnabled : false ,
540+ } ) ,
541+ ) ;
542+ const consoleError = vi . spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
543+ const mounted = await mountApp ( { routeThreadId : staleThreadId , waitForThreadId : null } ) ;
544+
545+ try {
546+ await vi . waitFor (
547+ ( ) => {
548+ expect ( mounted . router . state . location . pathname ) . not . toBe ( `/${ staleThreadId } ` ) ;
549+ expect (
550+ JSON . parse ( localStorage . getItem ( "synara:sidebar-ui:v1" ) ?? "null" ) ?. lastThreadRoute ,
551+ ) . toBeNull ( ) ;
552+ } ,
553+ { timeout : 4_000 , interval : 16 } ,
554+ ) ;
555+ await new Promise ( ( resolve ) => window . setTimeout ( resolve , 120 ) ) ;
556+ expect ( subscribeThreadRequestCountById . get ( staleThreadId ) ) . toBeUndefined ( ) ;
557+ expect ( consoleError ) . not . toHaveBeenCalled ( ) ;
558+ } finally {
559+ consoleError . mockRestore ( ) ;
560+ await mounted . cleanup ( ) ;
561+ }
562+ } ) ;
563+
564+ it ( "restores a valid remembered thread from the root route" , async ( ) => {
565+ localStorage . setItem (
566+ "synara:sidebar-ui:v1" ,
567+ JSON . stringify ( {
568+ lastThreadRoute : { threadId : THREAD_ID } ,
569+ } ) ,
570+ ) ;
571+ const mounted = await mountApp ( { initialEntry : "/" } ) ;
572+
573+ try {
574+ await vi . waitFor (
575+ ( ) => {
576+ expect ( mounted . router . state . location . pathname ) . toBe ( `/${ THREAD_ID } ` ) ;
577+ expect ( subscribeThreadRequestCountById . get ( THREAD_ID ) ) . toBeGreaterThanOrEqual ( 1 ) ;
578+ } ,
579+ { timeout : 4_000 , interval : 16 } ,
580+ ) ;
581+ } finally {
582+ await mounted . cleanup ( ) ;
583+ }
584+ } ) ;
585+
586+ it ( "clears a missing remembered thread while resolving the root route" , async ( ) => {
587+ const staleThreadId = ThreadId . makeUnsafe ( "thread-stale-root-restore" ) ;
588+ localStorage . setItem (
589+ "synara:sidebar-ui:v1" ,
590+ JSON . stringify ( {
591+ chatSectionExpanded : true ,
592+ lastThreadRoute : { threadId : staleThreadId } ,
593+ } ) ,
594+ ) ;
595+ const mounted = await mountApp ( { initialEntry : "/" , waitForThreadId : null } ) ;
596+
597+ try {
598+ await vi . waitFor (
599+ ( ) => {
600+ expect (
601+ JSON . parse ( localStorage . getItem ( "synara:sidebar-ui:v1" ) ?? "null" ) ?. lastThreadRoute ,
602+ ) . toBeNull ( ) ;
603+ expect ( mounted . router . state . location . pathname ) . not . toBe ( `/${ staleThreadId } ` ) ;
604+ } ,
605+ { timeout : 4_000 , interval : 16 } ,
606+ ) ;
607+ expect ( subscribeThreadRequestCountById . get ( staleThreadId ) ) . toBeUndefined ( ) ;
608+ } finally {
609+ await mounted . cleanup ( ) ;
610+ }
611+ } ) ;
612+
613+ it ( "stops leasing a deleted route and clears its remembered location after recovery" , async ( ) => {
614+ localStorage . setItem (
615+ "synara:sidebar-ui:v1" ,
616+ JSON . stringify ( {
617+ lastThreadRoute : { threadId : THREAD_ID } ,
618+ } ) ,
619+ ) ;
620+ const mounted = await mountApp ( ) ;
621+
622+ try {
623+ const subscribeCountBeforeDelete = subscribeThreadRequestCountById . get ( THREAD_ID ) ?? 0 ;
624+ fixture . snapshot = {
625+ ...fixture . snapshot ,
626+ snapshotSequence : 2 ,
627+ threads : [ ] ,
628+ } ;
629+ sendShellEventPush ( {
630+ kind : "thread-removed" ,
631+ sequence : 2 ,
632+ threadId : THREAD_ID ,
633+ } ) ;
634+
635+ await vi . waitFor (
636+ ( ) => {
637+ expect ( mounted . router . state . location . pathname ) . not . toBe ( `/${ THREAD_ID } ` ) ;
638+ expect (
639+ JSON . parse ( localStorage . getItem ( "synara:sidebar-ui:v1" ) ?? "null" ) ?. lastThreadRoute ,
640+ ) . toBeNull ( ) ;
641+ } ,
642+ { timeout : 5_000 , interval : 16 } ,
643+ ) ;
644+ await new Promise ( ( resolve ) => window . setTimeout ( resolve , 120 ) ) ;
645+ expect ( subscribeThreadRequestCountById . get ( THREAD_ID ) ) . toBe ( subscribeCountBeforeDelete ) ;
646+ } finally {
647+ await mounted . cleanup ( ) ;
648+ }
649+ } ) ;
650+
525651 it ( "drops duplicate thread events after the thread snapshot sequence advances" , async ( ) => {
526652 const mounted = await mountApp ( ) ;
527653
0 commit comments