diff --git a/.changeset/fix-archive-last-session-count.md b/.changeset/fix-archive-last-session-count.md new file mode 100644 index 000000000..73ad0cebf --- /dev/null +++ b/.changeset/fix-archive-last-session-count.md @@ -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. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 2468607d1..eed900b3a 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -2353,8 +2353,12 @@ const mergedWorkspaces = computed(() => { 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 }); diff --git a/apps/kimi-web/test/archive-last-session.test.ts b/apps/kimi-web/test/archive-last-session.test.ts new file mode 100644 index 000000000..582a01db8 --- /dev/null +++ b/apps/kimi-web/test/archive-last-session.test.ts @@ -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 { + 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'); + }); +}); diff --git a/apps/kimi-web/test/session-row.test.ts b/apps/kimi-web/test/session-row.test.ts index 70a00b70f..5b6dc1760 100644 --- a/apps/kimi-web/test/session-row.test.ts +++ b/apps/kimi-web/test/session-row.test.ts @@ -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']]); + }); }); diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index 26d26aebe..4d18a0798 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -171,7 +171,7 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe ): Promise { 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 { return { is_git_repo: true, branch: ref ? (ref[1] ?? null) : null }; } -async function countSessionDirs(dir: string): Promise { +async function countActiveSessions(dir: string): Promise { let dirents; try { dirents = await fsp.readdir(dir, { withFileTypes: true }); @@ -354,11 +354,25 @@ async function countSessionDirs(dir: string): Promise { } 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 { + 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(); } diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 44b034706..52231b3d6 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -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');