Skip to content

Commit b6ae0a1

Browse files
authored
fix(web): surface session list load failures (#1641)
* fix(web): surface session list load failures * fix(web): preserve partial session pages
1 parent d8d4e8c commit b6ae0a1

3 files changed

Lines changed: 419 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Show session list loading failures without discarding sessions that are still available.

apps/kimi-web/src/composables/client/useWorkspaceState.ts

Lines changed: 129 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)