From a3f9cec8a975f11e37e992e42f954789ed394207 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 30 Jun 2026 17:25:24 +0800 Subject: [PATCH] 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. --- .changeset/fix-web-duplicate-workspaces.md | 6 + .../composables/client/useWorkspaceState.ts | 3 +- .../src/composables/useKimiWebClient.ts | 79 ++---------- apps/kimi-web/src/lib/mergeWorkspaces.ts | 119 ++++++++++++++++++ apps/kimi-web/src/lib/pathBasename.ts | 7 ++ apps/kimi-web/test/workspace-state.test.ts | 61 +++++++++ .../workspace/workspaceRegistryService.ts | 24 +++- .../test/services/workspace-registry.test.ts | 43 +++++++ 8 files changed, 271 insertions(+), 71 deletions(-) create mode 100644 .changeset/fix-web-duplicate-workspaces.md create mode 100644 apps/kimi-web/src/lib/mergeWorkspaces.ts create mode 100644 apps/kimi-web/src/lib/pathBasename.ts diff --git a/.changeset/fix-web-duplicate-workspaces.md b/.changeset/fix-web-duplicate-workspaces.md new file mode 100644 index 000000000..b389fcae7 --- /dev/null +++ b/.changeset/fix-web-duplicate-workspaces.md @@ -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. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 15fdc8388..be2a3ae63 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -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; selectedDiffPath: Ref; @@ -140,7 +140,6 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta saveActiveWorkspaceToStorage, saveHiddenWorkspacesToStorage, goalErrorMessage, - basename, resetFastMoon, initialized, selectedDiffPath, diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 143ba57ea..36e1e242d 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -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(() => { - const hidden = new Set(rawState.hiddenWorkspaceRoots); - const byRoot = new Map(); - // 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__) 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(); - 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(() => + 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, diff --git a/apps/kimi-web/src/lib/mergeWorkspaces.ts b/apps/kimi-web/src/lib/mergeWorkspaces.ts new file mode 100644 index 000000000..68a0925e9 --- /dev/null +++ b/apps/kimi-web/src/lib/mergeWorkspaces.ts @@ -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[]; + /** 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; +} + +/** + * 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(); + // 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__) 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(); + 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; +} diff --git a/apps/kimi-web/src/lib/pathBasename.ts b/apps/kimi-web/src/lib/pathBasename.ts new file mode 100644 index 000000000..bec427f54 --- /dev/null +++ b/apps/kimi-web/src/lib/pathBasename.ts @@ -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; +} diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 533b138c9..ef6ff7a31 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -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'); + }); +}); diff --git a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts index ce3c60a84..aa5886c2a 100644 --- a/packages/agent-core/src/services/workspace/workspaceRegistryService.ts +++ b/packages/agent-core/src/services/workspace/workspaceRegistryService.ts @@ -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= only pages the canonical + // bucket, so the count must reflect what the list can actually retrieve. + const byRoot = new Map(); 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)); } diff --git a/packages/agent-core/test/services/workspace-registry.test.ts b/packages/agent-core/test/services/workspace-registry.test.ts index 5c895f006..ca618489b 100644 --- a/packages/agent-core/test/services/workspace-registry.test.ts +++ b/packages/agent-core/test/services/workspace-registry.test.ts @@ -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); + }); });