fix(web): deduplicate workspaces shown in the sidebar (#1221)

Collapse registered workspaces that share a root in the daemon registry (preferring the canonical id) and in the web sidebar merge, so the same folder no longer renders as two identical, synchronously-selected entries.
This commit is contained in:
qer 2026-06-30 17:25:24 +08:00 committed by GitHub
parent ec51324230
commit a3f9cec8a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 271 additions and 71 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once.

View file

@ -23,6 +23,7 @@ import type {
} from '../../api/types';
import { safeRemove, STORAGE_KEYS } from '../../lib/storage';
import { parseDiff } from '../../lib/parseDiff';
import { basename } from '../../lib/pathBasename';
import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute';
import type { SessionUrlMode } from '../../lib/sessionRoute';
import type {
@ -97,7 +98,6 @@ export interface UseWorkspaceStateDeps {
saveActiveWorkspaceToStorage: (id: string) => void;
saveHiddenWorkspacesToStorage: (roots: string[]) => void;
goalErrorMessage: (err: unknown) => string | undefined;
basename: (path: string) => string;
resetFastMoon: () => void;
initialized: Ref<boolean>;
selectedDiffPath: Ref<string | null>;
@ -140,7 +140,6 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
saveActiveWorkspaceToStorage,
saveHiddenWorkspacesToStorage,
goalErrorMessage,
basename,
resetFastMoon,
initialized,
selectedDiffPath,

View file

@ -7,6 +7,7 @@ import { i18n } from '../i18n';
import { getKimiWebApi } from '../api';
import { isDaemonApiError, isDaemonNetworkError } from '../api/errors';
import { reconcileWorkspaceOrder, sortByWorkspaceOrder } from '../lib/workspaceOrder';
import { mergeWorkspaces } from '../lib/mergeWorkspaces';
import { createCoalescedAsyncRunner } from '../lib/snapshotSync';
import {
loadUnread,
@ -228,12 +229,6 @@ function saveActiveWorkspaceToStorage(id: string): void {
}
}
/** basename of an absolute path (last non-empty segment), defaulting to the path. */
function basename(path: string): string {
const parts = path.split('/').filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1]! : path;
}
/** Shorten a $HOME-prefixed absolute path to `~/…` for dim display. */
function shortenHome(path: string, home: string | null): string {
if (home && path.startsWith(home)) {
@ -1761,67 +1756,16 @@ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string
* derived workspace (id = root = cwd). This makes the switcher + grouping work
* immediately off existing sessions until /workspaces ships.
*/
const mergedWorkspaces = computed<AppWorkspace[]>(() => {
const hidden = new Set(rawState.hiddenWorkspaceRoots);
const byRoot = new Map<string, AppWorkspace>();
// Real workspaces win on root (unless the user removed them from the sidebar).
for (const w of rawState.workspaces) {
if (hidden.has(w.root)) continue;
byRoot.set(w.root, { ...w });
}
// Derive from sessions for any cwd without a real workspace.
for (const s of rawState.sessions) {
const root = s.cwd;
if (!root) continue;
if (hidden.has(root)) continue; // removed from the sidebar — keep it hidden
if (!byRoot.has(root)) {
byRoot.set(root, {
// Use the session's REAL daemon workspace_id (wd_<slug>_<hash>) so
// createSession({ workspaceId }) is accepted; fall back to cwd only
// when the daemon hasn't tagged the session yet.
id: s.workspaceId ?? root,
root,
name: basename(root),
isGitRepo: false,
sessionCount: 0,
});
}
}
// Compute live session counts + a branch hint from the active session's git.
const counts = new Map<string, number>();
for (const s of rawState.sessions) {
const wid = workspaceIdForSession(s);
counts.set(wid, (counts.get(wid) ?? 0) + 1);
}
const activeGit = gitInfo.value;
const activeRoot = rawState.sessions.find((s) => s.id === rawState.activeSessionId)?.cwd;
// Order: real workspaces in listWorkspaces order, then derived workspaces
// sorted by root path so the order is stable (not tied to session activity).
// Hidden roots must be excluded here too — `byRoot` skips them, so a hidden
// real workspace would otherwise make `byRoot.get(root)` return undefined.
const realRoots = rawState.workspaces.filter((w) => !hidden.has(w.root)).map((w) => w.root);
const derivedRoots = [...byRoot.keys()].filter((r) => !realRoots.includes(r));
derivedRoots.sort((a, b) => a.localeCompare(b));
const result: AppWorkspace[] = [];
for (const root of [...realRoots, ...derivedRoots]) {
const w = byRoot.get(root)!;
// When a workspace's sessions are fully loaded (hasMore === false), the
// local count is exact — prefer it so archiving the last session drops the
// count to 0 immediately. While pages remain, the local count is only a
// lower bound, so keep the daemon session_count as a floor.
const localCount = counts.get(w.id) ?? counts.get(w.root) ?? 0;
const count =
rawState.sessionsHasMoreByWorkspace[w.id] === false
? localCount
: Math.max(w.sessionCount, localCount);
let branch = w.branch;
if (!branch && activeGit && activeRoot === w.root) branch = activeGit.branch;
result.push({ ...w, sessionCount: count, branch });
}
return result;
});
const mergedWorkspaces = computed<AppWorkspace[]>(() =>
mergeWorkspaces({
workspaces: rawState.workspaces,
sessions: rawState.sessions,
hiddenWorkspaceRoots: rawState.hiddenWorkspaceRoots,
activeRoot: rawState.sessions.find((s) => s.id === rawState.activeSessionId)?.cwd,
activeBranch: gitInfo.value?.branch ?? null,
sessionsHasMoreByWorkspace: rawState.sessionsHasMoreByWorkspace,
}),
);
/**
* User-defined display order of workspace ids, persisted to localStorage. The
@ -2056,7 +2000,6 @@ const workspaceState = useWorkspaceState(rawState, {
saveActiveWorkspaceToStorage,
saveHiddenWorkspacesToStorage,
goalErrorMessage,
basename,
resetFastMoon: appearance.resetFastMoon,
initialized,
selectedDiffPath,

View file

@ -0,0 +1,119 @@
// apps/kimi-web/src/lib/mergeWorkspaces.ts
// Pure helper that merges registered (daemon) workspaces with workspaces
// DERIVED from the current sessions' cwds. Extracted from the
// `useKimiWebClient` composable so the merge is unit-testable without a Vue
// reactivity harness.
import type { AppSession, AppWorkspace } from '../api/types';
import { basename } from './pathBasename';
/** The workspace id a session belongs to: prefer the registered workspace whose
* root matches the session cwd; otherwise the daemon-provided workspaceId;
* otherwise the cwd itself (derived/fallback mode). */
function workspaceIdForSession(
workspaces: AppWorkspace[],
s: { workspaceId?: string; cwd: string },
): string {
return workspaces.find((w) => w.root === s.cwd)?.id ?? s.workspaceId ?? s.cwd;
}
export interface MergeWorkspacesInput {
/** Registered workspaces from the daemon (listWorkspaces). */
workspaces: AppWorkspace[];
/** Currently loaded sessions (only id/cwd/workspaceId are read). */
sessions: Pick<AppSession, 'id' | 'cwd' | 'workspaceId'>[];
/** Root paths the user removed from the sidebar. */
hiddenWorkspaceRoots: string[];
/** cwd of the active session, used to hint the branch on the active workspace. */
activeRoot: string | undefined;
/** Live git branch of the active session, or null when unknown. */
activeBranch: string | null;
/** Per-workspace "server has more sessions" flag; false means the local
* session count is exact. */
sessionsHasMoreByWorkspace: Record<string, boolean>;
}
/**
* Merge real (daemon) workspaces with workspaces DERIVED from the current
* sessions' cwds. Each distinct cwd with no matching real workspace becomes one
* derived workspace (id = root = cwd). Real workspaces win on root.
*/
export function mergeWorkspaces(input: MergeWorkspacesInput): AppWorkspace[] {
const {
workspaces,
sessions,
hiddenWorkspaceRoots,
activeRoot,
activeBranch,
sessionsHasMoreByWorkspace,
} = input;
const hidden = new Set(hiddenWorkspaceRoots);
const byRoot = new Map<string, AppWorkspace>();
// Real workspaces win on root (unless the user removed them from the sidebar).
// Keep the FIRST entry per root: the daemon orders by last_opened_at desc, so
// the most recently opened (typically the canonical re-add) comes first. This
// must match `workspaceIdForSession` / the sidebar's first-match session
// assignment — if byRoot kept a different id than sessions are counted and
// grouped under, the only rendered workspace would look empty.
for (const w of workspaces) {
if (hidden.has(w.root)) continue;
if (!byRoot.has(w.root)) byRoot.set(w.root, { ...w });
}
// Derive from sessions for any cwd without a real workspace.
for (const s of sessions) {
const root = s.cwd;
if (!root) continue;
if (hidden.has(root)) continue; // removed from the sidebar — keep it hidden
if (!byRoot.has(root)) {
byRoot.set(root, {
// Use the session's REAL daemon workspace_id (wd_<slug>_<hash>) so
// createSession({ workspaceId }) is accepted; fall back to cwd only
// when the daemon hasn't tagged the session yet.
id: s.workspaceId ?? root,
root,
name: basename(root),
isGitRepo: false,
sessionCount: 0,
});
}
}
// Compute live session counts.
const counts = new Map<string, number>();
for (const s of sessions) {
const wid = workspaceIdForSession(workspaces, s);
counts.set(wid, (counts.get(wid) ?? 0) + 1);
}
// Order: real workspaces in listWorkspaces order, then derived workspaces
// sorted by root path so the order is stable (not tied to session activity).
// Hidden roots must be excluded here too — `byRoot` skips them, so a hidden
// real workspace would otherwise make `byRoot.get(root)` return undefined.
//
// Dedup by root: the registry can legitimately hold two entries for the same
// folder (e.g. a legacy id from an older encodeWorkDirKey plus the current
// one). `byRoot` already collapses them, but a duplicated root in the
// ordering list would render the same workspace twice — and because both
// copies share an id, selecting one would highlight both.
const realRoots = [...new Set(workspaces.filter((w) => !hidden.has(w.root)).map((w) => w.root))];
const derivedRoots = [...byRoot.keys()].filter((r) => !realRoots.includes(r));
derivedRoots.sort((a, b) => a.localeCompare(b));
const result: AppWorkspace[] = [];
for (const root of [...realRoots, ...derivedRoots]) {
const w = byRoot.get(root)!;
// When a workspace's sessions are fully loaded (hasMore === false), the
// local count is exact — prefer it so archiving the last session drops the
// count to 0 immediately. While pages remain, the local count is only a
// lower bound, so keep the daemon session_count as a floor.
const localCount = counts.get(w.id) ?? counts.get(w.root) ?? 0;
const count =
sessionsHasMoreByWorkspace[w.id] === false
? localCount
: Math.max(w.sessionCount, localCount);
let branch = w.branch;
if (!branch && activeBranch && activeRoot === w.root) branch = activeBranch;
result.push({ ...w, sessionCount: count, branch });
}
return result;
}

View file

@ -0,0 +1,7 @@
// apps/kimi-web/src/lib/pathBasename.ts
/** basename of an absolute path (last non-empty segment), defaulting to the path. */
export function basename(path: string): string {
const parts = path.split('/').filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1]! : path;
}

View file

@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AppSession } from '../src/api/types';
import { createInitialState } from '../src/api/daemon/eventReducer';
import { mergeWorkspaces } from '../src/lib/mergeWorkspaces';
import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState';
import type { ExtendedState } from '../src/composables/useKimiWebClient';
@ -153,3 +154,63 @@ describe('useWorkspaceState — abortCurrentPrompt', () => {
expect(apiMock.abortSession).not.toHaveBeenCalled();
});
});
describe('mergeWorkspaces', () => {
it('collapses registered workspaces that share a root, keeping the first entry and its sessions', () => {
const result = mergeWorkspaces({
workspaces: [
// Server orders by last_opened_at desc, so the most recently opened
// (typically the canonical re-add) comes first.
{ id: 'wd_current', root: '/agent/GEO', name: 'GEO', isGitRepo: false, sessionCount: 0 },
{ id: 'wd_legacy', root: '/agent/GEO', name: 'GEO', isGitRepo: false, sessionCount: 0 },
],
// A session whose daemon workspace_id points at the dropped (legacy) entry.
sessions: [{ id: 's1', cwd: '/agent/GEO', workspaceId: 'wd_legacy' }],
hiddenWorkspaceRoots: [],
activeRoot: undefined,
activeBranch: null,
sessionsHasMoreByWorkspace: { wd_current: false },
});
expect(result).toHaveLength(1);
expect(result[0]?.root).toBe('/agent/GEO');
// Keeps the first (most recent) entry, matching the sidebar's first-match
// session assignment so the rendered workspace is the one sessions land under.
expect(result[0]?.id).toBe('wd_current');
expect(result[0]?.sessionCount).toBe(1);
});
it('keeps distinct roots separate and appends derived cwds after real ones', () => {
const result = mergeWorkspaces({
workspaces: [
{ id: 'wd_a', root: '/agent/A', name: 'A', isGitRepo: false, sessionCount: 1 },
],
sessions: [
{ id: 's1', cwd: '/agent/A', workspaceId: 'wd_a' },
{ id: 's2', cwd: '/agent/B', workspaceId: 'wd_b' },
],
hiddenWorkspaceRoots: [],
activeRoot: undefined,
activeBranch: null,
sessionsHasMoreByWorkspace: {},
});
expect(result.map((w) => w.root)).toEqual(['/agent/A', '/agent/B']);
expect(result.find((w) => w.root === '/agent/B')?.id).toBe('wd_b');
});
it('hides workspaces whose root the user removed', () => {
const result = mergeWorkspaces({
workspaces: [
{ id: 'wd_a', root: '/agent/A', name: 'A', isGitRepo: false, sessionCount: 1 },
],
sessions: [{ id: 's1', cwd: '/agent/A', workspaceId: 'wd_a' }],
hiddenWorkspaceRoots: ['/agent/A'],
activeRoot: undefined,
activeBranch: null,
sessionsHasMoreByWorkspace: {},
});
expect(result.map((w) => w.root)).not.toContain('/agent/A');
});
});

View file

@ -68,8 +68,30 @@ export class WorkspaceRegistryService extends Disposable implements IWorkspaceRe
const deleted = new Set(file.deleted_workspace_ids);
const result: Workspace[] = [];
// Registered workspaces (explicitly added by the user).
// Registered workspaces (explicitly added by the user). Dedup by root: the
// registry can hold legacy entries whose id was computed by an older
// encodeWorkDirKey (e.g. realpath-based on Windows) for the same folder, so
// a single root may map to multiple ids. Prefer the entry whose id matches
// the current canonical key so sessions' workspace_id still resolves and
// the sidebar doesn't render the same workspace twice.
//
// The session count is intentionally scoped to the representative's own
// bucket (via hydrate) rather than aggregated across every id for the root:
// GET /sessions?workspace_id=<representative> only pages the canonical
// bucket, so the count must reflect what the list can actually retrieve.
const byRoot = new Map<string, { id: string; entry: WorkspaceRegistryEntry }>();
for (const [id, entry] of Object.entries(file.workspaces)) {
const existing = byRoot.get(entry.root);
if (existing === undefined) {
byRoot.set(entry.root, { id, entry });
continue;
}
const canonicalId = encodeWorkDirKey(normalizeWorkDir(entry.root));
if (existing.id !== canonicalId && id === canonicalId) {
byRoot.set(entry.root, { id, entry });
}
}
for (const { id, entry } of byRoot.values()) {
result.push(await this.hydrate(id, entry));
}

View file

@ -211,4 +211,47 @@ describe('WorkspaceRegistryService', () => {
expect((await ctx.registry.list()).map((w) => w.id)).not.toContain(derivedId);
});
it('collapses duplicate registered entries for the same root, preferring the canonical id', async () => {
const root = await makeProjectRoot('dup');
const canonicalId = encodeWorkDirKey(root);
// Simulate a registry that also holds a legacy id for the same folder (e.g.
// one produced by an older, realpath-based encodeWorkDirKey on Windows).
const legacyId = 'wd_duplegacy_deadbeef0000';
const registryPath = join(ctx.homeDir, 'workspaces.json');
const entry = { root, name: 'dup', created_at: '2026-01-01T00:00:00.000Z', last_opened_at: '2026-01-01T00:00:00.000Z' };
await writeFile(
registryPath,
JSON.stringify(
{
version: 1,
// Legacy first so the canonical entry must actively replace it.
workspaces: { [legacyId]: entry, [canonicalId]: entry },
deleted_workspace_ids: [],
},
null,
2,
),
'utf-8',
);
// One active session in the canonical bucket (via the index)...
await seedSessionBucket(root, 'sess-canonical-1');
// ...and one stranded in the legacy bucket. It is NOT counted: the returned
// workspace can only page the canonical bucket via GET /sessions, so the
// count stays consistent with what the list can retrieve.
const legacySessionDir = join(ctx.homeDir, 'sessions', legacyId, 'sess-legacy-1');
await mkdir(legacySessionDir, { recursive: true });
await writeFile(
join(legacySessionDir, 'state.json'),
JSON.stringify({ archived: false }),
'utf-8',
);
const list = await ctx.registry.list();
const matches = list.filter((w) => w.root === root);
expect(matches).toHaveLength(1);
expect(matches[0]?.id).toBe(canonicalId);
// Count is scoped to the representative's (canonical) bucket only.
expect(matches[0]?.session_count).toBe(1);
});
});