mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
ec51324230
commit
a3f9cec8a9
8 changed files with 271 additions and 71 deletions
6
.changeset/fix-web-duplicate-workspaces.md
Normal file
6
.changeset/fix-web-duplicate-workspaces.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
119
apps/kimi-web/src/lib/mergeWorkspaces.ts
Normal file
119
apps/kimi-web/src/lib/mergeWorkspaces.ts
Normal 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;
|
||||
}
|
||||
7
apps/kimi-web/src/lib/pathBasename.ts
Normal file
7
apps/kimi-web/src/lib/pathBasename.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue