@@ -490,22 +490,34 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
490490
491491 /** Drain every page of sessions, newest first. A single global walk (instead of
492492 * per-workspace) so sessions whose cwd is not a registered workspace root are
493- * still reachable after a refresh. */
494- async function listAllSessionsGlobal ( ) : Promise < AppSession [ ] > {
493+ * still reachable after a refresh. A later-page failure returns the pages
494+ * already fetched plus the error; only a first-page failure rejects. */
495+ async function listAllSessionsGlobal ( ) : Promise < {
496+ sessions : AppSession [ ] ;
497+ error ?: unknown ;
498+ } > {
495499 const api = getKimiWebApi ( ) ;
496500 const items : AppSession [ ] = [ ] ;
497501 let beforeId : string | undefined ;
502+ let continuationError : unknown ;
498503 for ( ; ; ) {
499- const page = await api . listSessions ( {
500- pageSize : SESSION_PAGE_SIZE ,
501- beforeId,
502- excludeEmpty : true ,
503- } ) ;
504+ let page : { items : AppSession [ ] ; hasMore : boolean } ;
505+ try {
506+ page = await api . listSessions ( {
507+ pageSize : SESSION_PAGE_SIZE ,
508+ beforeId,
509+ excludeEmpty : true ,
510+ } ) ;
511+ } catch ( error ) {
512+ if ( items . length === 0 ) throw error ;
513+ continuationError = error ;
514+ break ;
515+ }
504516 items . push ( ...page . items ) ;
505517 if ( ! page . hasMore || page . items . length === 0 ) break ;
506518 beforeId = page . items [ page . items . length - 1 ] ! . id ;
507519 }
508- return items ;
520+ return { sessions : items , error : continuationError } ;
509521 }
510522
511523 /**
@@ -528,6 +540,20 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
528540 ) ;
529541 }
530542
543+ /** Keep fresh rows authoritative while retaining cached rows a partial list
544+ * request never reached. */
545+ function mergePartialSessionsWithCached ( sessions : AppSession [ ] ) : AppSession [ ] {
546+ const merged = [ ...sessions ] ;
547+ const loadedIds = new Set ( merged . map ( ( session ) => session . id ) ) ;
548+ for ( const session of rawState . sessions ) {
549+ if ( loadedIds . has ( session . id ) ) continue ;
550+ merged . push ( session ) ;
551+ loadedIds . add ( session . id ) ;
552+ }
553+ merged . sort ( ( a , b ) => new Date ( b . updatedAt ) . getTime ( ) - new Date ( a . updatedAt ) . getTime ( ) ) ;
554+ return merged ;
555+ }
556+
531557 /** Load the initial page of sessions for one workspace, then keep fetching
532558 * older pages while the oldest loaded session is still within
533559 * SESSIONS_RECENT_WINDOW_MS. Every page (including continuations) uses the
@@ -536,14 +562,19 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
536562 * keeping only up to the first session that falls outside the window. */
537563 async function loadInitialSessionsForWorkspace (
538564 workspaceId : string ,
539- ) : Promise < { workspaceId : string ; page : { items : AppSession [ ] ; hasMore : boolean } } > {
565+ ) : Promise < {
566+ workspaceId : string ;
567+ page : { items : AppSession [ ] ; hasMore : boolean } ;
568+ error ?: unknown ;
569+ } > {
540570 const api = getKimiWebApi ( ) ;
541571 const items : AppSession [ ] = [ ] ;
542572 const now = Date . now ( ) ;
543573 const ageOf = ( s : AppSession ) : number => now - new Date ( s . updatedAt ) . getTime ( ) ;
544574 let beforeId : string | undefined ;
545575 let hasMore = false ;
546576 let isFirstPage = true ;
577+ let continuationError : unknown ;
547578 for ( ; ; ) {
548579 let page : { items : AppSession [ ] ; hasMore : boolean } ;
549580 try {
@@ -555,9 +586,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
555586 } ) ;
556587 } catch ( error ) {
557588 // A failed continuation page must not discard sessions already loaded
558- // from earlier pages; only a page-1 failure propagates (the caller then
559- // falls back to an empty page for that workspace).
589+ // from earlier pages; only a page-1 failure rejects the workspace load.
560590 if ( isFirstPage ) throw error ;
591+ continuationError = error ;
592+ hasMore = true ;
561593 break ;
562594 }
563595 hasMore = page . hasMore ;
@@ -584,45 +616,97 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
584616 if ( ! page . hasMore || oldestBeyondWindow ) break ;
585617 beforeId = oldest . id ;
586618 }
587- return { workspaceId, page : { items, hasMore } } ;
619+ return { workspaceId, page : { items, hasMore } , error : continuationError } ;
588620 }
589621
590622 /** Fetch the first page of sessions for every known workspace concurrently.
591- * Returns the merged, recency-sorted list and seeds per-workspace hasMore. */
592- async function loadInitialSessionsByWorkspace ( ) : Promise < AppSession [ ] > {
623+ * Returns the merged, recency-sorted list and seeds per-workspace hasMore.
624+ * When every workspace request fails, returns undefined so the caller keeps
625+ * the previously loaded sessions instead of committing a false empty list. */
626+ async function loadInitialSessionsByWorkspace ( ) : Promise < AppSession [ ] | undefined > {
593627 const workspaces = rawState . workspaces ;
594628 if ( workspaces . length === 0 ) {
595629 // /workspaces may be unavailable or empty on older / partially-failing
596630 // daemons while /sessions still works. Fall back to the legacy global
597631 // walk so history still shows and mergedWorkspaces can derive workspaces
598632 // from session cwds, instead of rendering a blank sidebar.
599- const fallback = await listAllSessionsGlobal ( ) . catch ( ( err ) => {
600- console . warn ( '[kimi-web] global session fallback load failed' , err ) ;
601- return [ ] as AppSession [ ] ;
602- } ) ;
633+ const fallback = await listAllSessionsGlobal ( ) ;
634+ const sessions =
635+ fallback . error === undefined
636+ ? fallback . sessions
637+ : mergePartialSessionsWithCached ( fallback . sessions ) ;
603638 rawState . sessionsHasMoreByWorkspace = { } ;
604639 rawState . sessionsCursorByWorkspace = { } ;
605640 rawState . sessionsInitialCountByWorkspace = { } ;
606- rawState . sessionsFullyLoaded = true ;
607- return fallback ;
608- }
609- const pages = await Promise . all (
610- workspaces . map ( ( w ) =>
611- loadInitialSessionsForWorkspace ( w . id ) . catch ( ( err ) => {
612- console . warn ( '[kimi-web] initial session load failed for workspace' , w . id , err ) ;
613- return {
614- workspaceId : w . id ,
615- page : { items : [ ] as AppSession [ ] , hasMore : false } ,
616- } ;
617- } ) ,
618- ) ,
641+ rawState . sessionsFullyLoaded = fallback . error === undefined ;
642+ if ( fallback . error !== undefined ) pushOperationFailure ( 'load' , fallback . error ) ;
643+ return sessions ;
644+ }
645+ const results = await Promise . allSettled (
646+ workspaces . map ( ( w ) => loadInitialSessionsForWorkspace ( w . id ) ) ,
619647 ) ;
620648 const loaded : AppSession [ ] = [ ] ;
649+ const loadedIds = new Set < string > ( ) ;
650+ const successfulPages = new Map < string , { items : AppSession [ ] ; hasMore : boolean } > ( ) ;
651+ const failedWorkspaceIds = new Set < string > ( ) ;
652+ let firstError : unknown ;
653+ for ( let index = 0 ; index < results . length ; index ++ ) {
654+ const result = results [ index ] ! ;
655+ if ( result . status === 'fulfilled' ) {
656+ successfulPages . set ( result . value . workspaceId , result . value . page ) ;
657+ if ( result . value . error !== undefined ) {
658+ if ( failedWorkspaceIds . size === 0 ) firstError = result . value . error ;
659+ failedWorkspaceIds . add ( result . value . workspaceId ) ;
660+ }
661+ for ( const session of result . value . page . items ) {
662+ if ( loadedIds . has ( session . id ) ) continue ;
663+ loaded . push ( session ) ;
664+ loadedIds . add ( session . id ) ;
665+ }
666+ continue ;
667+ }
668+ if ( failedWorkspaceIds . size === 0 ) firstError = result . reason ;
669+ failedWorkspaceIds . add ( workspaces [ index ] ! . id ) ;
670+ }
671+
672+ // One failed workspace must not erase another workspace's successful page,
673+ // nor the failed workspace's last usable rows. If every request failed,
674+ // leave both sessions and pagination state untouched for a natural retry.
675+ if ( successfulPages . size === 0 ) {
676+ pushOperationFailure ( 'load' , firstError ) ;
677+ return undefined ;
678+ }
679+ const failedWorkspaceRoots = new Set (
680+ workspaces
681+ . filter ( ( workspace ) => failedWorkspaceIds . has ( workspace . id ) )
682+ . map ( ( workspace ) => workspace . root ) ,
683+ ) ;
684+ const registeredWorkspaceIds = new Set ( workspaces . map ( ( workspace ) => workspace . id ) ) ;
685+ for ( const session of rawState . sessions ) {
686+ const belongsToFailedWorkspace =
687+ session . workspaceId !== undefined && registeredWorkspaceIds . has ( session . workspaceId )
688+ ? failedWorkspaceIds . has ( session . workspaceId )
689+ : failedWorkspaceRoots . has ( session . cwd ) ||
690+ failedWorkspaceIds . has ( workspaceIdForSession ( session ) ) ;
691+ if ( ! belongsToFailedWorkspace || loadedIds . has ( session . id ) ) continue ;
692+ loaded . push ( session ) ;
693+ loadedIds . add ( session . id ) ;
694+ }
695+
621696 const hasMore : Record < string , boolean > = { } ;
622697 const cursors : Record < string , string | undefined > = { } ;
623698 const counts : Record < string , number > = { } ;
624- for ( const { workspaceId, page } of pages ) {
625- loaded . push ( ...page . items ) ;
699+ for ( const { id : workspaceId } of workspaces ) {
700+ const page = successfulPages . get ( workspaceId ) ;
701+ if ( page === undefined ) {
702+ const previousHasMore = rawState . sessionsHasMoreByWorkspace [ workspaceId ] ;
703+ const previousCursor = rawState . sessionsCursorByWorkspace [ workspaceId ] ;
704+ const previousCount = rawState . sessionsInitialCountByWorkspace [ workspaceId ] ;
705+ if ( previousHasMore !== undefined ) hasMore [ workspaceId ] = previousHasMore ;
706+ if ( previousCursor !== undefined ) cursors [ workspaceId ] = previousCursor ;
707+ if ( previousCount !== undefined ) counts [ workspaceId ] = previousCount ;
708+ continue ;
709+ }
626710 // Trust the server's hasMore — the per-workspace session_count is only a
627711 // (possibly stale) label total, not an authority on whether more pages exist.
628712 hasMore [ workspaceId ] = page . hasMore ;
@@ -646,6 +730,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
646730 // Keep rawState.sessions newest-first for readers that pick sessions[0]
647731 // (e.g. auto-selecting the most recent session on first load).
648732 loaded . sort ( ( a , b ) => new Date ( b . updatedAt ) . getTime ( ) - new Date ( a . updatedAt ) . getTime ( ) ) ;
733+ if ( failedWorkspaceIds . size > 0 ) pushOperationFailure ( 'load' , firstError ) ;
649734 return loaded ;
650735 }
651736
@@ -699,13 +784,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
699784 * first search; a no-op once the full list is loaded. */
700785 async function loadAllSessions ( ) : Promise < void > {
701786 if ( rawState . sessionsFullyLoaded ) return ;
702- const sessions = await listAllSessionsGlobal ( ) . catch ( ( err ) => {
787+ const result = await listAllSessionsGlobal ( ) . catch ( ( err ) => {
703788 console . warn ( '[kimi-web] loadAllSessions failed; search covers only loaded sessions' , err ) ;
704789 return null ;
705790 } ) ;
706- if ( sessions === null ) return ;
791+ if ( result === null ) return ;
792+ const sessions =
793+ result . error === undefined
794+ ? result . sessions
795+ : mergePartialSessionsWithCached ( result . sessions ) ;
707796 setSessionsPreservingLiveUsage ( sessions ) ;
708- rawState . sessionsFullyLoaded = true ;
797+ rawState . sessionsFullyLoaded = result . error === undefined ;
798+ if ( result . error !== undefined ) return ;
709799 const cleared : Record < string , boolean > = { } ;
710800 for ( const w of rawState . workspaces ) cleared [ w . id ] = false ;
711801 rawState . sessionsHasMoreByWorkspace = cleared ;
@@ -764,8 +854,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
764854 // the old full global walk: the sidebar now truncates by loading, not by
765855 // hiding already-fetched rows.
766856 await loadWorkspaces ( ) ;
767- const sessions = await loadInitialSessionsByWorkspace ( ) ;
768- setSessionsPreservingLiveUsage ( sessions ) ;
857+ const loadedSessions = await loadInitialSessionsByWorkspace ( ) ;
858+ const sessions = loadedSessions ?? rawState . sessions ;
859+ if ( loadedSessions !== undefined ) setSessionsPreservingLiveUsage ( loadedSessions ) ;
769860
770861 // First load: pick the workspace of the most-recent session, unless the
771862 // user already has a persisted active workspace that still exists.
0 commit comments