diff --git a/.changeset/web-sessions-pagination.md b/.changeset/web-sessions-pagination.md new file mode 100644 index 000000000..95b2d765d --- /dev/null +++ b/.changeset/web-sessions-pagination.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 1d9e22b7f..2468607d1 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2668,18 +2668,37 @@ async function updateConfig(patch: Partial): Promise { // global connecting-splash so a page refresh doesn't flash a half-empty app. const initialized = ref(false); +// Backend max page size for GET /sessions. Bigger pages mean fewer round-trips +// when draining the full session list. +const SESSION_PAGE_SIZE = 100; + +/** 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 { + const api = getKimiWebApi(); + const items: AppSession[] = []; + let beforeId: string | undefined; + for (;;) { + const page = await api.listSessions({ pageSize: SESSION_PAGE_SIZE, beforeId }); + items.push(...page.items); + if (!page.hasMore || page.items.length === 0) break; + beforeId = page.items[page.items.length - 1]!.id; + } + return items; +} + async function load(): Promise { rawState.loading = true; try { const api = getKimiWebApi(); - // Parallel: health + meta + sessions + models - const [, , sessionsPage] = await Promise.all([ + // Parallel: health + meta + models + await Promise.all([ api.getHealth().catch(() => null), api.getMeta().then((m) => { rawState.serverVersion = m.serverVersion; rawState.availableOpenInApps = m.openInApps; }).catch(() => null), - api.listSessions({ pageSize: 20 }).catch(() => ({ items: [], hasMore: false })), loadModels(), ]); @@ -2687,14 +2706,17 @@ async function load(): Promise { await checkAuth(); await loadConfig(); - rawState.sessions = sessionsPage.items; + // Drain every session via a single global walk so sessions whose cwd is not + // a registered workspace root are still reachable after a refresh. + const sessions = await listAllSessionsGlobal().catch(() => [] as AppSession[]); + rawState.sessions = sessions; // Load workspaces (real if available, else derived from session cwds). await loadWorkspaces(); // First load: pick the workspace of the most-recent session, unless the // user already has a persisted active workspace that still exists. - const mostRecent = sessionsPage.items[0]; + const mostRecent = sessions[0]; const persisted = rawState.activeWorkspaceId; const persistedStillExists = persisted !== null && mergedWorkspaces.value.some((w) => w.id === persisted); @@ -2703,7 +2725,7 @@ async function load(): Promise { } // URL deep link (/sessions/) takes priority over auto-select. The - // session may live beyond the first listSessions page — fetch it then. + // session may live outside the loaded pages (e.g. archived) — fetch it then. // selectSession syncs the active workspace off the (now present) entry. bindSessionRoute(); const urlSessionId = @@ -2719,8 +2741,8 @@ async function load(): Promise { // Auto-select first session if none selected (also the fallback for a dead // deep link — 'replace' rewrites the URL to the session actually shown). - if (!rawState.activeSessionId && sessionsPage.items.length > 0) { - await selectSession(sessionsPage.items[0]!.id, { urlMode: 'replace' }); + if (!rawState.activeSessionId && sessions.length > 0) { + await selectSession(sessions[0]!.id, { urlMode: 'replace' }); } } catch (err) { pushOperationFailure('load', err); diff --git a/apps/kimi-web/test/useKimiWebClient-session-list.test.ts b/apps/kimi-web/test/useKimiWebClient-session-list.test.ts new file mode 100644 index 000000000..908389b78 --- /dev/null +++ b/apps/kimi-web/test/useKimiWebClient-session-list.test.ts @@ -0,0 +1,176 @@ +// apps/kimi-web/test/useKimiWebClient-session-list.test.ts +// +// load() must drain every session page (not just the first), so a session +// beyond the initial page is still reachable from the sidebar. A single global +// walk is used so sessions whose cwd is not a registered workspace root are +// included too. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AppSession, + KimiEventHandlers, + KimiWebApi, + Page, +} from '../src/api/types'; + +const t0 = '2026-06-11T00:00:00.000Z'; + +function session(id: string, overrides?: Partial): AppSession { + return { + id, + title: id, + createdAt: t0, + updatedAt: t0, + status: 'idle', + archived: false, + cwd: '/repo', + model: 'kimi-test', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 128_000, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + ...overrides, + }; +} + +interface SetupOpts { + pages: Array>; + listWorkspaces?: () => Promise; + listSessionsError?: Error; +} + +async function setup(opts: SetupOpts) { + vi.resetModules(); + vi.stubGlobal('WebSocket', class WebSocket {}); + window.history.replaceState(null, '', '/'); + + let handlers: KimiEventHandlers | undefined; + const eventConn = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + bindNextPromptId: vi.fn(), + seedSnapshot: vi.fn(), + abort: vi.fn(), + close: vi.fn(), + }; + + let cursor = 0; + const listSessions = vi.fn( + async (_input?: { workspaceId?: string; beforeId?: string; pageSize?: number }) => { + if (opts.listSessionsError) throw opts.listSessionsError; + const page = opts.pages[cursor] ?? { items: [], hasMore: false }; + cursor += 1; + return page; + }, + ); + + const api = { + getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })), + getMeta: vi.fn(async () => ({ daemonVersion: 't', serverId: 's', startedAt: t0, capabilities: {} })), + getAuth: vi.fn(async () => ({ ready: true, defaultModel: 'kimi-test', managedProvider: null })), + listModels: vi.fn(async () => []), + listWorkspaces: vi.fn(opts.listWorkspaces ?? (async () => [])), + getFsHome: vi.fn(async () => ({ home: '/home', recentRoots: [] })), + listSessions, + getSession: vi.fn(async (id: string) => session(id)), + getSessionSnapshot: vi.fn(async (id: string) => ({ + asOfSeq: 0, + epoch: 'ep_test', + session: session(id), + messages: [], + hasMoreMessages: false, + inFlightTurn: null, + pendingApprovals: [], + pendingQuestions: [], + })), + listTasks: vi.fn(async () => []), + getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })), + getSessionStatus: vi.fn(async () => ({ + model: 'kimi-test', + thinkingLevel: 'high', + permission: 'manual', + planMode: false, + swarmMode: false, + contextTokens: 0, + maxContextTokens: 128_000, + contextUsage: 0, + })), + connectEvents: vi.fn((nextHandlers: KimiEventHandlers) => { + handlers = nextHandlers; + return eventConn; + }), + getFileUrl: vi.fn((fileId: string) => `/files/${fileId}`), + } as unknown as KimiWebApi; + + vi.doMock('../src/api', () => ({ getKimiWebApi: () => api })); + const { useKimiWebClient } = await import('../src/composables/useKimiWebClient'); + + return { + api, + client: useKimiWebClient(), + getHandlers: () => { + if (!handlers) throw new Error('connectEvents was not called'); + return handlers; + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +describe('load() session listing', () => { + it('drains every page using the beforeId cursor', async () => { + const { api, client } = await setup({ + pages: [ + { items: [session('sess_1'), session('sess_2')], hasMore: true }, + { items: [session('sess_3')], hasMore: false }, + ], + }); + + await client.load(); + + const calls = (api.listSessions as ReturnType).mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0]![0]).toMatchObject({ beforeId: undefined }); + expect(calls[1]![0]).toMatchObject({ beforeId: 'sess_2' }); + + const ids = client.sessions.value.map((s) => s.id).sort(); + expect(ids).toEqual(['sess_1', 'sess_2', 'sess_3']); + }); + + it('never passes a workspaceId so unregistered-cwd sessions are included', async () => { + const { api, client } = await setup({ + pages: [{ items: [session('sess_orphan', { cwd: '/tmp/scratch' })], hasMore: false }], + }); + + await client.load(); + + const calls = (api.listSessions as ReturnType).mock.calls; + expect(calls).toHaveLength(1); + expect(calls[0]![0]?.workspaceId).toBeUndefined(); + expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_orphan']); + }); + + it('surfaces an empty list when listSessions rejects', async () => { + const { client } = await setup({ + pages: [], + listSessionsError: new Error('boom'), + }); + + await expect(client.load()).resolves.toBeUndefined(); + expect(client.sessions.value).toEqual([]); + }); +});