mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
23277a574c
commit
8ab9e96963
3 changed files with 211 additions and 8 deletions
5
.changeset/web-sessions-pagination.md
Normal file
5
.changeset/web-sessions-pagination.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
176
apps/kimi-web/test/useKimiWebClient-session-list.test.ts
Normal file
176
apps/kimi-web/test/useKimiWebClient-session-list.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue