From c15a1ffd3e3d32ef84bd619bcba48ad08d24505f Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 30 Jun 2026 21:50:32 +0800 Subject: [PATCH 1/4] fix(web): persist workspace rename via the daemon update API --- .changeset/web-workspace-rename.md | 5 +++++ apps/kimi-web/src/api/daemon/client.ts | 12 ++++++++++++ apps/kimi-web/src/api/types.ts | 1 + .../src/composables/client/useWorkspaceState.ts | 15 ++++++++++----- 4 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 .changeset/web-workspace-rename.md diff --git a/.changeset/web-workspace-rename.md b/.changeset/web-workspace-rename.md new file mode 100644 index 000000000..f22dda324 --- /dev/null +++ b/.changeset/web-workspace-rename.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web workspace rename not persisting after a page refresh. diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 8904ef228..31d9917fb 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -991,6 +991,18 @@ export class DaemonKimiWebApi implements KimiWebApi { await this.http.delete(`/workspaces/${encodeURIComponent(id)}`); } + /** + * Rename a workspace (display name only). + * PATCH /api/v1/workspaces/:id { name }. On error this throws. + */ + async updateWorkspace(id: string, input: { name: string }): Promise { + const data = await this.http.patch( + `/workspaces/${encodeURIComponent(id)}`, + { name: input.name }, + ); + return toAppWorkspace(data); + } + /** * Browse directories under `path` (defaults to $HOME on the daemon). * PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty path so diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 486ddc8ba..d4d7732a4 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -666,6 +666,7 @@ export interface KimiWebApi { // derived workspaces (cwds with sessions that were never explicitly registered). listWorkspaces(): Promise; addWorkspace(input: { root: string; name?: string }): Promise; + updateWorkspace(id: string, input: { name: string }): Promise; deleteWorkspace(id: string): Promise; browseFs(path?: string): Promise; getFsHome(): Promise<{ home: string; recentRoots: string[] }>; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index be2a3ae63..2baf9a7fb 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1337,11 +1337,16 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } - /** Rename a workspace — local-only until the daemon ships a workspace update API. */ - function renameWorkspace(id: string, name: string): void { - rawState.workspaces = rawState.workspaces.map((w) => - w.id === id ? { ...w, name } : w, - ); + /** Rename a workspace — persists via the daemon update API, then applies locally. */ + async function renameWorkspace(id: string, name: string): Promise { + try { + await getKimiWebApi().updateWorkspace(id, { name }); + rawState.workspaces = rawState.workspaces.map((w) => + w.id === id ? { ...w, name } : w, + ); + } catch (err) { + pushOperationFailure('renameWorkspace', err); + } } /** Delete a workspace — calls API, removes locally */ From fd17dafe31c04677cbaf54e00f2b9f0789e81140 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 30 Jun 2026 21:50:38 +0800 Subject: [PATCH 2/4] fix(web): hide the unshipped provider manager --- .changeset/web-hide-provider-manager.md | 5 +++++ apps/kimi-web/src/App.vue | 6 +++++- apps/kimi-web/src/lib/slashCommands.ts | 1 - 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .changeset/web-hide-provider-manager.md diff --git a/.changeset/web-hide-provider-manager.md b/.changeset/web-hide-provider-manager.md new file mode 100644 index 000000000..0fae394dc --- /dev/null +++ b/.changeset/web-hide-provider-manager.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Hide the provider management dialog in the web UI until the server supports it. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 8b43e4936..63ad3aca1 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -234,6 +234,10 @@ function handleSelectWorkspaces(ids: string[]): void { // Dialog visibility refs const showModelPicker = ref(false); const showProviders = ref(false); + +// Provider management (add / delete) is not shipped by the daemon yet — hide the +// manager UI entry points for now. Re-enable once POST/DELETE /providers land. +const PROVIDER_MANAGER_ENABLED = false; const showLogin = ref(false); const showAddWorkspace = ref(false); const showStatusPanel = ref(false); @@ -439,7 +443,7 @@ function handleCommand(cmd: string): void { void openModelPicker(); break; case '/provider': - void openProviders(); + if (PROVIDER_MANAGER_ENABLED) void openProviders(); break; case '/login': openLogin(); diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index cca86e1b3..8eb24ed75 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -26,7 +26,6 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: '/new', desc: 'commands.new.desc' }, { name: '/clear', desc: 'commands.clear.desc' }, { name: '/model', desc: 'commands.model.desc' }, - { name: '/provider', desc: 'commands.provider.desc' }, { name: '/login', desc: 'commands.login.desc' }, { name: '/permission', desc: 'commands.permission.desc' }, { name: '/plan', desc: 'commands.plan.desc' }, From d3ef70496652281e76ef9fd1977a85cd80eb677f Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 30 Jun 2026 23:16:53 +0800 Subject: [PATCH 3/4] fix(web): fall back to a local override for derived workspace renames --- .../composables/client/useWorkspaceState.ts | 52 +++++++++- apps/kimi-web/src/lib/storage.ts | 23 +++++ apps/kimi-web/test/workspace-state.test.ts | 98 ++++++++++++++++++- 3 files changed, 167 insertions(+), 6 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 2baf9a7fb..8b3e6b86a 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -21,7 +21,12 @@ import type { KimiEventConnection, QuestionResponse, } from '../../api/types'; -import { safeRemove, STORAGE_KEYS } from '../../lib/storage'; +import { + loadWorkspaceNameOverrides, + safeRemove, + saveWorkspaceNameOverrides, + STORAGE_KEYS, +} from '../../lib/storage'; import { parseDiff } from '../../lib/parseDiff'; import { basename } from '../../lib/pathBasename'; import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute'; @@ -40,6 +45,7 @@ import type { UseTaskPoller } from './useTaskPoller'; const MESSAGES_PAGE_SIZE = 50; const PROMPT_NOT_FOUND_CODE = 40402; +const WORKSPACE_NOT_FOUND_CODE = 40410; type SyncSessionResult = 'ok' | 'not-found' | 'failed'; @@ -496,7 +502,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta api.listWorkspaces().catch(() => [] as AppWorkspace[]), api.getFsHome().catch(() => ({ home: '', recentRoots: [] })), ]); - rawState.workspaces = list; + rawState.workspaces = applyWorkspaceNameOverrides(list); rawState.fsHome = home.home || null; rawState.recentRoots = home.recentRoots; } catch { @@ -504,6 +510,17 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } + /** Overlay locally-persisted name overrides (see renameWorkspace fallback) + * onto a freshly loaded workspace list, keyed by root. */ + function applyWorkspaceNameOverrides(workspaces: AppWorkspace[]): AppWorkspace[] { + const overrides = loadWorkspaceNameOverrides(); + if (Object.keys(overrides).length === 0) return workspaces; + return workspaces.map((w) => { + const override = overrides[w.root]; + return override !== undefined ? { ...w, name: override } : w; + }); + } + /** Set the active workspace and persist it. */ function selectWorkspace(id: string): void { rawState.activeWorkspaceId = id; @@ -1337,14 +1354,39 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } } - /** Rename a workspace — persists via the daemon update API, then applies locally. */ + /** Rename a workspace — persists via the daemon update API, then applies + * locally. Derived workspaces (a cwd with sessions that was never explicitly + * registered) can't be renamed by the daemon yet: PATCH rejects them with + * 404. In that case the name is persisted in localStorage (keyed by root) + * and overlaid onto the loaded list, so the rename still survives a refresh. */ async function renameWorkspace(id: string, name: string): Promise { - try { - await getKimiWebApi().updateWorkspace(id, { name }); + const root = rawState.workspaces.find((w) => w.id === id)?.root; + const applyLocal = (): void => { rawState.workspaces = rawState.workspaces.map((w) => w.id === id ? { ...w, name } : w, ); + }; + try { + await getKimiWebApi().updateWorkspace(id, { name }); + // Server accepted the rename — drop any local override for this root. + if (root !== undefined) { + const overrides = loadWorkspaceNameOverrides(); + if (root in overrides) { + delete overrides[root]; + saveWorkspaceNameOverrides(overrides); + } + } + applyLocal(); } catch (err) { + if ( + root !== undefined && + isDaemonApiError(err) && + err.code === WORKSPACE_NOT_FOUND_CODE + ) { + saveWorkspaceNameOverrides({ ...loadWorkspaceNameOverrides(), [root]: name }); + applyLocal(); + return; + } pushOperationFailure('renameWorkspace', err); } } diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index cd7556b59..d40f7d311 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -24,6 +24,7 @@ export const STORAGE_KEYS = { hiddenWorkspaces: 'kimi-web.hidden-workspaces', collapsedWorkspaces: 'kimi-web.collapsed-workspaces', workspaceOrder: 'kimi-web.workspace-order', + workspaceNameOverrides: 'kimi-web.workspace-name-overrides', betaToc: 'kimi-web.beta-toc', notifyOnComplete: 'kimi-web.notify-on-complete', notifyOnQuestion: 'kimi-web.notify-on-question', @@ -158,3 +159,25 @@ export function loadWorkspaceOrder(): string[] { export function saveWorkspaceOrder(ids: Iterable): void { safeSetJson(STORAGE_KEYS.workspaceOrder, Array.from(ids)); } + +/** + * Local display-name overrides for workspaces the daemon cannot rename — today + * that is derived workspaces (a cwd with sessions that was never explicitly + * registered), which `PATCH /workspaces/:id` rejects with 404. Keyed by + * workspace root (stable across the derived → registered transition) and + * applied on top of the daemon list so the rename survives a refresh. Cleared + * once the daemon accepts a rename for that root. + */ +export function loadWorkspaceNameOverrides(): Record { + const parsed = safeGetJson(STORAGE_KEYS.workspaceNameOverrides); + if (!parsed || typeof parsed !== 'object') return {}; + const out: Record = {}; + for (const [root, name] of Object.entries(parsed as Record)) { + if (typeof name === 'string') out[root] = name; + } + return out; +} + +export function saveWorkspaceNameOverrides(overrides: Record): void { + safeSetJson(STORAGE_KEYS.workspaceNameOverrides, overrides); +} diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index ef6ff7a31..3f926c1a9 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1,14 +1,17 @@ import { computed, ref } from 'vue'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AppSession } from '../src/api/types'; +import { DaemonApiError } from '../src/api/errors'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { mergeWorkspaces } from '../src/lib/mergeWorkspaces'; +import { loadWorkspaceNameOverrides } from '../src/lib/storage'; import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState'; import type { ExtendedState } from '../src/composables/useKimiWebClient'; const apiMock = vi.hoisted(() => ({ abortPrompt: vi.fn(), abortSession: vi.fn(), + updateWorkspace: vi.fn(), })); vi.mock('../src/api', () => ({ @@ -125,6 +128,41 @@ function createDeps(): UseWorkspaceStateDeps { } as unknown as UseWorkspaceStateDeps; } +function createMemoryStorage(): Storage { + const data = new Map(); + return { + get length() { + return data.size; + }, + clear() { + data.clear(); + }, + getItem(key: string) { + return data.get(key) ?? null; + }, + key(index: number) { + return Array.from(data.keys()).at(index) ?? null; + }, + removeItem(key: string) { + data.delete(key); + }, + setItem(key: string, value: string) { + data.set(key, String(value)); + }, + }; +} + +function installStorage(storage: Storage): void { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: storage, + }); +} + +function workspace(id: string, root: string, name: string) { + return { id, root, name, isGitRepo: false, sessionCount: 0 }; +} + describe('useWorkspaceState — abortCurrentPrompt', () => { beforeEach(() => { apiMock.abortPrompt.mockReset(); @@ -214,3 +252,61 @@ describe('mergeWorkspaces', () => { expect(result.map((w) => w.root)).not.toContain('/agent/A'); }); }); + +describe('useWorkspaceState — renameWorkspace', () => { + beforeEach(() => { + apiMock.updateWorkspace.mockReset(); + installStorage(createMemoryStorage()); + }); + + afterEach(() => { + installStorage(createMemoryStorage()); + }); + + it('renames via the daemon and applies the name locally', async () => { + apiMock.updateWorkspace.mockResolvedValue({}); + const state = createState(); + state.workspaces = [workspace('wd_1', '/abs/path', 'Old')]; + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + await ws.renameWorkspace('wd_1', 'New'); + + expect(apiMock.updateWorkspace).toHaveBeenCalledWith('wd_1', { name: 'New' }); + expect(state.workspaces[0]?.name).toBe('New'); + expect(loadWorkspaceNameOverrides()).toEqual({}); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('falls back to a local override when the daemon reports not found', async () => { + apiMock.updateWorkspace.mockRejectedValue( + new DaemonApiError({ code: 40410, msg: 'workspace not found', requestId: 'r' }), + ); + const state = createState(); + state.workspaces = [workspace('wd_1', '/abs/path', 'Old')]; + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + await ws.renameWorkspace('wd_1', 'New'); + + expect(state.workspaces[0]?.name).toBe('New'); + expect(loadWorkspaceNameOverrides()).toEqual({ '/abs/path': 'New' }); + expect(deps.pushOperationFailure).not.toHaveBeenCalled(); + }); + + it('surfaces daemon errors other than not-found', async () => { + apiMock.updateWorkspace.mockRejectedValue( + new DaemonApiError({ code: 50000, msg: 'boom', requestId: 'r' }), + ); + const state = createState(); + state.workspaces = [workspace('wd_1', '/abs/path', 'Old')]; + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + await ws.renameWorkspace('wd_1', 'New'); + + expect(state.workspaces[0]?.name).toBe('Old'); + expect(loadWorkspaceNameOverrides()).toEqual({}); + expect(deps.pushOperationFailure).toHaveBeenCalled(); + }); +}); From 7549afc628af0dc4670d4bb608cac6be7429f86e Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 30 Jun 2026 23:32:01 +0800 Subject: [PATCH 4/4] fix(web): preserve workspace name override across registration --- .../src/composables/client/useWorkspaceState.ts | 15 ++++++++++----- apps/kimi-web/test/workspace-state.test.ts | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 16a2e01e2..1a169771a 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -547,20 +547,25 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta /** Upsert a workspace: preserve existing order when updating; prepend only * for truly new workspaces. */ function upsertWorkspacePreserveOrder(workspace: AppWorkspace): void { + // A locally-renamed derived workspace may carry a saved name override; apply + // it so a daemon upsert (e.g. registering the root on first chat) doesn't + // clobber the name with the default basename. + const override = loadWorkspaceNameOverrides()[workspace.root]; + const ws = override !== undefined ? { ...workspace, name: override } : workspace; // Re-adding a path the user previously removed should bring it back. - if (rawState.hiddenWorkspaceRoots.includes(workspace.root)) { - rawState.hiddenWorkspaceRoots = rawState.hiddenWorkspaceRoots.filter((r) => r !== workspace.root); + if (rawState.hiddenWorkspaceRoots.includes(ws.root)) { + rawState.hiddenWorkspaceRoots = rawState.hiddenWorkspaceRoots.filter((r) => r !== ws.root); saveHiddenWorkspacesToStorage(rawState.hiddenWorkspaceRoots); } const index = rawState.workspaces.findIndex( - (w) => w.id === workspace.id || w.root === workspace.root, + (w) => w.id === ws.id || w.root === ws.root, ); if (index === -1) { - rawState.workspaces = [workspace, ...rawState.workspaces]; + rawState.workspaces = [ws, ...rawState.workspaces]; return; } const next = [...rawState.workspaces]; - next[index] = workspace; + next[index] = ws; rawState.workspaces = next; } diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 4248d3575..3a3ddfead 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -4,7 +4,7 @@ import type { AppSession } from '../src/api/types'; import { DaemonApiError } from '../src/api/errors'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { mergeWorkspaces } from '../src/lib/mergeWorkspaces'; -import { loadWorkspaceNameOverrides } from '../src/lib/storage'; +import { loadWorkspaceNameOverrides, saveWorkspaceNameOverrides } from '../src/lib/storage'; import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState'; import type { ExtendedState } from '../src/composables/useKimiWebClient'; @@ -310,6 +310,19 @@ describe('useWorkspaceState — renameWorkspace', () => { expect(loadWorkspaceNameOverrides()).toEqual({}); expect(deps.pushOperationFailure).toHaveBeenCalled(); }); + + it('keeps a saved name override when a workspace is upserted (derived → registered)', () => { + // Simulates: user renamed a derived workspace, then the daemon registers + // the root (e.g. on first chat) and returns the default basename. + saveWorkspaceNameOverrides({ '/abs/path': 'Renamed' }); + const state = createState(); + const deps = createDeps(); + const ws = useWorkspaceState(state, deps); + + ws.upsertWorkspacePreserveOrder(workspace('wd_1', '/abs/path', 'path')); + + expect(state.workspaces[0]?.name).toBe('Renamed'); + }); }); describe('useWorkspaceState — addWorkspaceByPath', () => {