fix(web): drop workspace session count after archiving the last session (#896)

The daemon's workspace session_count counted archived session directories,
so a workspace still reported a non-zero count after its last session was
archived. The web sidebar then showed the workspace as non-empty, making it
look like the archive had failed (and a retry would error out).

Count only non-archived sessions in the workspace registry, and trust the
live local count in the web app once sessions have loaded.
This commit is contained in:
qer 2026-06-18 19:14:47 +08:00 committed by GitHub
parent cde7ca51cc
commit de610deb5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 246 additions and 6 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1.

View file

@ -2353,8 +2353,12 @@ const mergedWorkspaces = computed<AppWorkspace[]>(() => {
const result: AppWorkspace[] = [];
for (const root of [...realRoots, ...derivedRoots]) {
const w = byRoot.get(root)!;
// Match count by either id or root (derived id === root).
const count = counts.get(w.id) ?? counts.get(w.root) ?? w.sessionCount;
// Match count by either id or root (derived id === root). Once sessions
// have loaded, trust the live local count (0 when no sessions remain) rather
// than the daemon's sessionCount, which historically counted archived
// sessions and would keep a workspace looking non-empty after its last
// session was archived.
const count = counts.get(w.id) ?? counts.get(w.root) ?? (rawState.loading ? w.sessionCount : 0);
let branch = w.branch;
if (!branch && activeGit && activeRoot === w.root) branch = activeGit.branch;
result.push({ ...w, sessionCount: count, branch });

View file

@ -0,0 +1,167 @@
// apps/kimi-web/test/archive-last-session.test.ts
//
// Reproduces / verifies the bug where archiving the only session in a workspace
// does not behave correctly.
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AppSession, AppWorkspace, KimiEventHandlers, KimiWebApi } from '../src/api/types';
const now = '2026-06-11T00:00:00.000Z';
function session(id: string, overrides?: Partial<AppSession>): AppSession {
return {
id,
title: id,
createdAt: now,
updatedAt: now,
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,
};
}
async function setup(opts: {
sessions?: AppSession[];
workspaces?: AppWorkspace[];
}) {
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(),
};
const listed = opts.sessions ?? [];
const workspaces = opts.workspaces ?? [];
const api = {
getHealth: vi.fn(async () => ({ status: 'ok', uptimeSec: 1 })),
getMeta: vi.fn(async () => ({ daemonVersion: 't', serverId: 's', startedAt: now, capabilities: {} })),
getAuth: vi.fn(async () => ({ ready: true, defaultModel: 'kimi-test', managedProvider: null })),
listModels: vi.fn(async () => []),
listWorkspaces: vi.fn(async () => workspaces),
getFsHome: vi.fn(async () => ({ home: '/home', recentRoots: [] })),
listSessions: vi.fn(async () => ({ items: listed, hasMore: false })),
getSession: vi.fn(async (id: string) => {
const found = listed.find((s) => s.id === id);
if (!found) throw new Error('SESSION_NOT_FOUND');
return found;
}),
archiveSession: vi.fn(async () => ({ archived: true })),
getSessionSnapshot: vi.fn(async (id: string) => {
const found = listed.find((s) => s.id === id) ?? session(id);
return {
asOfSeq: 0,
epoch: 'ep_test',
session: found,
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('archive last session in workspace', () => {
it('removes the only session and clears active session', async () => {
const { client } = await setup({
sessions: [session('sess_1', { cwd: '/repo' })],
workspaces: [{ id: 'ws_repo', root: '/repo', name: 'repo', sessionCount: 1 }],
});
await client.load();
expect(client.activeSessionId.value).toBe('sess_1');
expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_1']);
expect(client.workspacesView.value.map((w) => ({ id: w.id, sessionCount: w.sessionCount }))).toEqual([
{ id: 'ws_repo', sessionCount: 1 },
]);
await client.archiveSession('sess_1');
expect(client.sessions.value).toEqual([]);
expect(client.activeSessionId.value).toBe('');
expect(window.location.pathname).toBe('/');
expect(client.workspacesView.value.map((w) => ({ id: w.id, sessionCount: w.sessionCount }))).toEqual([
{ id: 'ws_repo', sessionCount: 0 },
]);
});
it('removes the only session in one workspace when another workspace exists', async () => {
const { client } = await setup({
sessions: [
session('sess_a', { cwd: '/repo-a' }),
session('sess_b', { cwd: '/repo-b' }),
],
workspaces: [
{ id: 'ws_a', root: '/repo-a', name: 'repo-a', sessionCount: 1 },
{ id: 'ws_b', root: '/repo-b', name: 'repo-b', sessionCount: 1 },
],
});
await client.load();
expect(client.activeSessionId.value).toBe('sess_a');
await client.archiveSession('sess_a');
expect(client.sessions.value.map((s) => s.id)).toEqual(['sess_b']);
expect(client.activeSessionId.value).toBe('sess_b');
});
});

View file

@ -56,4 +56,15 @@ describe('SessionRow status / busy', () => {
expect(w.find('.tag-ask').exists()).toBe(false);
expect(w.find('.tag-aborted').exists()).toBe(false);
});
it('emits archive after confirming via the kebab menu', async () => {
const w = row({ id: 'only', title: 'Only' });
await w.find('.kebab').trigger('click');
await w.find('.menu-item.archive').trigger('click');
expect(w.find('.archive-confirm').exists()).toBe(true);
await w.find('.btn-confirm').trigger('click');
expect(w.emitted('archive')).toEqual([['only']]);
});
});

View file

@ -171,7 +171,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
): Promise<Workspace> {
const [{ is_git_repo, branch }, session_count] = await Promise.all([
detectGit(entry.root),
countSessionDirs(join(this.sessionsDir, workspaceId)),
countActiveSessions(join(this.sessionsDir, workspaceId)),
]);
return {
id: workspaceId,
@ -343,7 +343,7 @@ export async function detectGit(root: string): Promise<GitInfo> {
return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null };
}
async function countSessionDirs(dir: string): Promise<number> {
async function countActiveSessions(dir: string): Promise<number> {
let dirents;
try {
dirents = await fsp.readdir(dir, { withFileTypes: true });
@ -354,11 +354,25 @@ async function countSessionDirs(dir: string): Promise<number> {
}
let count = 0;
for (const d of dirents) {
if (d.isDirectory()) count += 1;
if (!d.isDirectory()) continue;
if (await isSessionArchived(join(dir, d.name))) continue;
count += 1;
}
return count;
}
async function isSessionArchived(sessionDir: string): Promise<boolean> {
try {
const raw = await fsp.readFile(join(sessionDir, 'state.json'), 'utf8');
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === 'object' && parsed !== null && (parsed as { archived?: boolean }).archived === true;
} catch {
// Treat unreadable/missing state.json as non-archived so the directory still
// counts as a session (matches the session store's own loading behavior).
return false;
}
}
export function userHomeDir(): string {
return os.homedir();
}

View file

@ -25,7 +25,7 @@
* bearing piece of Chain 2).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -712,6 +712,45 @@ describe('POST /api/v1/sessions/{session_id}:archive — archive', () => {
expect(listEnv.data!.items.find((s) => s.id === created.id)).toBeUndefined();
});
it('updates the workspace session_count to 0 when the last session is archived', async () => {
const r = await bootDaemon();
const cwd = join(tmpDir, 'workspace-archive-count');
mkdirSync(cwd, { recursive: true });
const ws = envelopeOf<{ id: string; session_count: number; root: string }>(
(await appOf(r).inject({ method: 'POST', url: '/api/v1/workspaces', payload: { root: cwd } })).json(),
).data!;
expect(ws.session_count).toBe(0);
const created = envelopeOf<{ id: string }>(
(await appOf(r).inject({
method: 'POST',
url: '/api/v1/sessions',
payload: { workspace_id: ws.id, metadata: { cwd: ws.root } },
})).json(),
).data!;
const listBefore = envelopeOf<{ items: Array<{ id: string; session_count: number }> }>(
(await appOf(r).inject({ method: 'GET', url: '/api/v1/workspaces' })).json(),
).data!;
const before = listBefore.items.find((w) => w.id === ws.id);
expect(before).toBeDefined();
expect(before!.session_count).toBe(1);
const archiveRes = await appOf(r).inject({
method: 'POST',
url: `/api/v1/sessions/${created.id}:archive`,
payload: {},
});
expect(envelopeOf<{ archived: boolean }>(archiveRes.json()).data).toEqual({ archived: true });
const listAfter = envelopeOf<{ items: Array<{ id: string; session_count: number }> }>(
(await appOf(r).inject({ method: 'GET', url: '/api/v1/workspaces' })).json(),
).data!;
const after = listAfter.items.find((w) => w.id === ws.id);
expect(after).toBeDefined();
expect(after!.session_count).toBe(0);
});
it('includes archived sessions when include_archive=true and marks archived flag', async () => {
const r = await bootDaemon();
const cwd = join(tmpDir, 'workspace-archive-include');