mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
cde7ca51cc
commit
de610deb5f
6 changed files with 246 additions and 6 deletions
5
.changeset/fix-archive-last-session-count.md
Normal file
5
.changeset/fix-archive-last-session-count.md
Normal 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.
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
167
apps/kimi-web/test/archive-last-session.test.ts
Normal file
167
apps/kimi-web/test/archive-last-session.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue