fix(web): paginate session list on load (#882)

* fix(web): list sessions per workspace with pagination

* fix(web): use global paginated session list instead of per-workspace
This commit is contained in:
qer 2026-06-18 17:21:50 +08:00 committed by GitHub
parent 23277a574c
commit 8ab9e96963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 211 additions and 8 deletions

View file

@ -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.

View file

@ -2668,18 +2668,37 @@ async function updateConfig(patch: Partial<AppConfig>): Promise<boolean> {
// 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<AppSession[]> {
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<void> {
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<void> {
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<void> {
}
// URL deep link (/sessions/<id>) 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<void> {
// 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);

View file

@ -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>): 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<Page<AppSession>>;
listWorkspaces?: () => Promise<unknown[]>;
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<typeof vi.fn>).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<typeof vi.fn>).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([]);
});
});