Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-web-duplicate-workspaces.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -140,7 +140,6 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
saveActiveWorkspaceToStorage,
saveHiddenWorkspacesToStorage,
goalErrorMessage,
basename,
resetFastMoon,
initialized,
selectedDiffPath,
Expand Down
79 changes: 11 additions & 68 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the now-unused basename helper

With the workspace merge moved into mergeWorkspaces, this module no longer references its local basename helper; apps/kimi-web/tsconfig.json enables noUnusedLocals, so the web typecheck will fail with an unused-local error until that helper is removed from useKimiWebClient.ts.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -2056,7 +2000,6 @@ const workspaceState = useWorkspaceState(rawState, {
saveActiveWorkspaceToStorage,
saveHiddenWorkspacesToStorage,
goalErrorMessage,
basename,
resetFastMoon: appearance.resetFastMoon,
initialized,
selectedDiffPath,
Expand Down
119 changes: 119 additions & 0 deletions apps/kimi-web/src/lib/mergeWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map duplicate-root sessions to the kept workspace id

When listWorkspaces still returns two entries for the same root (the compatibility case this helper is trying to tolerate), byRoot keeps the last entry but this count path maps sessions through workspaces.find(...), which returns the first entry. For an input like [wd_legacy(root), wd_current(root)], the collapsed workspace is wd_current but loaded sessions for that cwd are counted under wd_legacy; the outer sidebar uses the same first-match mapping to filter sessions, so the only visible workspace can show no sessions/count until the daemon-side dedupe is present. Use the same root-to-kept-workspace mapping for session assignment as the collapsed workspace list.

Useful? React with 👍 / 👎.

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 changes: 7 additions & 0 deletions apps/kimi-web/src/lib/pathBasename.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Loading
Loading