Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-web-session-load-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Show session list loading failures without discarding sessions that are still available.
167 changes: 129 additions & 38 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,22 +490,34 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta

/** Drain every page of sessions, newest first. A single global walk (instead of
* per-workspace) so sessions whose cwd is not a registered workspace root are
* still reachable after a refresh. */
async function listAllSessionsGlobal(): Promise<AppSession[]> {
* still reachable after a refresh. A later-page failure returns the pages
* already fetched plus the error; only a first-page failure rejects. */
async function listAllSessionsGlobal(): Promise<{
sessions: AppSession[];
error?: unknown;
}> {
const api = getKimiWebApi();
const items: AppSession[] = [];
let beforeId: string | undefined;
let continuationError: unknown;
for (;;) {
const page = await api.listSessions({
pageSize: SESSION_PAGE_SIZE,
beforeId,
excludeEmpty: true,
});
let page: { items: AppSession[]; hasMore: boolean };
try {
page = await api.listSessions({
pageSize: SESSION_PAGE_SIZE,
beforeId,
excludeEmpty: true,
});
} catch (error) {
if (items.length === 0) throw error;
continuationError = error;
break;
}
items.push(...page.items);
if (!page.hasMore || page.items.length === 0) break;
beforeId = page.items[page.items.length - 1]!.id;
}
return items;
return { sessions: items, error: continuationError };
}

/**
Expand All @@ -528,6 +540,20 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
);
}

/** Keep fresh rows authoritative while retaining cached rows a partial list
* request never reached. */
function mergePartialSessionsWithCached(sessions: AppSession[]): AppSession[] {
const merged = [...sessions];
const loadedIds = new Set(merged.map((session) => session.id));
for (const session of rawState.sessions) {
if (loadedIds.has(session.id)) continue;
merged.push(session);
Comment on lines +548 to +550

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep loaded ranges authoritative in partial merges

When a later /sessions page fails, this loop appends every cached session that was not returned by the successful prefix. The first page is already authoritative for the newest range it covers; if a cached session in that range was deleted, archived, or became empty on the server, it is absent from sessions but gets added back here, so the no-workspace fallback or loadAllSessions can keep showing stale rows until a full drain eventually succeeds. Only cached rows older than the oldest successfully fetched row should be retained as unreached.

Useful? React with 👍 / 👎.

loadedIds.add(session.id);
}
merged.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return merged;
}

/** Load the initial page of sessions for one workspace, then keep fetching
* older pages while the oldest loaded session is still within
* SESSIONS_RECENT_WINDOW_MS. Every page (including continuations) uses the
Expand All @@ -536,14 +562,19 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
* keeping only up to the first session that falls outside the window. */
async function loadInitialSessionsForWorkspace(
workspaceId: string,
): Promise<{ workspaceId: string; page: { items: AppSession[]; hasMore: boolean } }> {
): Promise<{
workspaceId: string;
page: { items: AppSession[]; hasMore: boolean };
error?: unknown;
}> {
const api = getKimiWebApi();
const items: AppSession[] = [];
const now = Date.now();
const ageOf = (s: AppSession): number => now - new Date(s.updatedAt).getTime();
let beforeId: string | undefined;
let hasMore = false;
let isFirstPage = true;
let continuationError: unknown;
for (;;) {
let page: { items: AppSession[]; hasMore: boolean };
try {
Expand All @@ -555,9 +586,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
});
} catch (error) {
// A failed continuation page must not discard sessions already loaded
// from earlier pages; only a page-1 failure propagates (the caller then
// falls back to an empty page for that workspace).
// from earlier pages; only a page-1 failure rejects the workspace load.
if (isFirstPage) throw error;
continuationError = error;
hasMore = true;
break;
}
hasMore = page.hasMore;
Expand All @@ -584,45 +616,97 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
if (!page.hasMore || oldestBeyondWindow) break;
beforeId = oldest.id;
}
return { workspaceId, page: { items, hasMore } };
return { workspaceId, page: { items, hasMore }, error: continuationError };
}

/** Fetch the first page of sessions for every known workspace concurrently.
* Returns the merged, recency-sorted list and seeds per-workspace hasMore. */
async function loadInitialSessionsByWorkspace(): Promise<AppSession[]> {
* Returns the merged, recency-sorted list and seeds per-workspace hasMore.
* When every workspace request fails, returns undefined so the caller keeps
* the previously loaded sessions instead of committing a false empty list. */
async function loadInitialSessionsByWorkspace(): Promise<AppSession[] | undefined> {
const workspaces = rawState.workspaces;
if (workspaces.length === 0) {
// /workspaces may be unavailable or empty on older / partially-failing
// daemons while /sessions still works. Fall back to the legacy global
// walk so history still shows and mergedWorkspaces can derive workspaces
// from session cwds, instead of rendering a blank sidebar.
const fallback = await listAllSessionsGlobal().catch((err) => {
console.warn('[kimi-web] global session fallback load failed', err);
return [] as AppSession[];
});
const fallback = await listAllSessionsGlobal();
const sessions =
fallback.error === undefined
? fallback.sessions
: mergePartialSessionsWithCached(fallback.sessions);
rawState.sessionsHasMoreByWorkspace = {};
rawState.sessionsCursorByWorkspace = {};
rawState.sessionsInitialCountByWorkspace = {};
rawState.sessionsFullyLoaded = true;
return fallback;
}
const pages = await Promise.all(
workspaces.map((w) =>
loadInitialSessionsForWorkspace(w.id).catch((err) => {
console.warn('[kimi-web] initial session load failed for workspace', w.id, err);
return {
workspaceId: w.id,
page: { items: [] as AppSession[], hasMore: false },
};
}),
),
rawState.sessionsFullyLoaded = fallback.error === undefined;
if (fallback.error !== undefined) pushOperationFailure('load', fallback.error);
return sessions;
}
const results = await Promise.allSettled(
workspaces.map((w) => loadInitialSessionsForWorkspace(w.id)),
);
const loaded: AppSession[] = [];
const loadedIds = new Set<string>();
const successfulPages = new Map<string, { items: AppSession[]; hasMore: boolean }>();
const failedWorkspaceIds = new Set<string>();
let firstError: unknown;
for (let index = 0; index < results.length; index++) {
const result = results[index]!;
if (result.status === 'fulfilled') {
successfulPages.set(result.value.workspaceId, result.value.page);
if (result.value.error !== undefined) {
if (failedWorkspaceIds.size === 0) firstError = result.value.error;
failedWorkspaceIds.add(result.value.workspaceId);
}
for (const session of result.value.page.items) {
if (loadedIds.has(session.id)) continue;
loaded.push(session);
loadedIds.add(session.id);
}
continue;
}
if (failedWorkspaceIds.size === 0) firstError = result.reason;
failedWorkspaceIds.add(workspaces[index]!.id);
}

// One failed workspace must not erase another workspace's successful page,
// nor the failed workspace's last usable rows. If every request failed,
// leave both sessions and pagination state untouched for a natural retry.
if (successfulPages.size === 0) {
pushOperationFailure('load', firstError);
return undefined;
}
const failedWorkspaceRoots = new Set(
workspaces
.filter((workspace) => failedWorkspaceIds.has(workspace.id))
.map((workspace) => workspace.root),
);
const registeredWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
for (const session of rawState.sessions) {
const belongsToFailedWorkspace =
session.workspaceId !== undefined && registeredWorkspaceIds.has(session.workspaceId)
? failedWorkspaceIds.has(session.workspaceId)
: failedWorkspaceRoots.has(session.cwd) ||
failedWorkspaceIds.has(workspaceIdForSession(session));
if (!belongsToFailedWorkspace || loadedIds.has(session.id)) continue;
loaded.push(session);
Comment thread
wbxl2000 marked this conversation as resolved.
loadedIds.add(session.id);
}

const hasMore: Record<string, boolean> = {};
const cursors: Record<string, string | undefined> = {};
const counts: Record<string, number> = {};
for (const { workspaceId, page } of pages) {
loaded.push(...page.items);
for (const { id: workspaceId } of workspaces) {
const page = successfulPages.get(workspaceId);
if (page === undefined) {
const previousHasMore = rawState.sessionsHasMoreByWorkspace[workspaceId];
const previousCursor = rawState.sessionsCursorByWorkspace[workspaceId];
const previousCount = rawState.sessionsInitialCountByWorkspace[workspaceId];
if (previousHasMore !== undefined) hasMore[workspaceId] = previousHasMore;
if (previousCursor !== undefined) cursors[workspaceId] = previousCursor;
if (previousCount !== undefined) counts[workspaceId] = previousCount;
continue;
}
// Trust the server's hasMore — the per-workspace session_count is only a
// (possibly stale) label total, not an authority on whether more pages exist.
hasMore[workspaceId] = page.hasMore;
Expand All @@ -646,6 +730,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// Keep rawState.sessions newest-first for readers that pick sessions[0]
// (e.g. auto-selecting the most recent session on first load).
loaded.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
if (failedWorkspaceIds.size > 0) pushOperationFailure('load', firstError);
return loaded;
}

Expand Down Expand Up @@ -699,13 +784,18 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
* first search; a no-op once the full list is loaded. */
async function loadAllSessions(): Promise<void> {
if (rawState.sessionsFullyLoaded) return;
const sessions = await listAllSessionsGlobal().catch((err) => {
const result = await listAllSessionsGlobal().catch((err) => {
console.warn('[kimi-web] loadAllSessions failed; search covers only loaded sessions', err);
return null;
});
if (sessions === null) return;
if (result === null) return;
const sessions =
result.error === undefined
? result.sessions
: mergePartialSessionsWithCached(result.sessions);
setSessionsPreservingLiveUsage(sessions);
rawState.sessionsFullyLoaded = true;
rawState.sessionsFullyLoaded = result.error === undefined;
if (result.error !== undefined) return;
const cleared: Record<string, boolean> = {};
for (const w of rawState.workspaces) cleared[w.id] = false;
rawState.sessionsHasMoreByWorkspace = cleared;
Expand Down Expand Up @@ -764,8 +854,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// the old full global walk: the sidebar now truncates by loading, not by
// hiding already-fetched rows.
await loadWorkspaces();
const sessions = await loadInitialSessionsByWorkspace();
setSessionsPreservingLiveUsage(sessions);
const loadedSessions = await loadInitialSessionsByWorkspace();
const sessions = loadedSessions ?? rawState.sessions;
if (loadedSessions !== undefined) setSessionsPreservingLiveUsage(loadedSessions);

// First load: pick the workspace of the most-recent session, unless the
// user already has a persisted active workspace that still exists.
Expand Down
Loading
Loading