fix(web): persist workspace rename and hide provider manager (#1234)

* fix(web): persist workspace rename via the daemon update API

* fix(web): hide the unshipped provider manager

* fix(web): fall back to a local override for derived workspace renames

* fix(web): preserve workspace name override across registration
This commit is contained in:
qer 2026-07-01 02:03:19 +08:00 committed by GitHub
parent 86e0c9201e
commit 882cf355a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 225 additions and 15 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Hide the provider management dialog in the web UI until the server supports it.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the web workspace rename not persisting after a page refresh.

View file

@ -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);
@ -443,7 +447,7 @@ function handleCommand(cmd: string): void {
void openModelPicker();
break;
case '/provider':
void openProviders();
if (PROVIDER_MANAGER_ENABLED) void openProviders();
break;
case '/login':
openLogin();

View file

@ -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<AppWorkspace> {
const data = await this.http.patch<WireWorkspace>(
`/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

View file

@ -666,6 +666,7 @@ export interface KimiWebApi {
// derived workspaces (cwds with sessions that were never explicitly registered).
listWorkspaces(): Promise<AppWorkspace[]>;
addWorkspace(input: { root: string; name?: string }): Promise<AppWorkspace>;
updateWorkspace(id: string, input: { name: string }): Promise<AppWorkspace>;
deleteWorkspace(id: string): Promise<void>;
browseFs(path?: string): Promise<FsBrowseResult>;
getFsHome(): Promise<{ home: string; recentRoots: string[] }>;

View file

@ -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 { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute';
import type { SessionUrlMode } from '../../lib/sessionRoute';
@ -39,6 +44,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';
@ -495,7 +501,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 {
@ -503,6 +509,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;
@ -530,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;
}
@ -1325,11 +1347,41 @@ 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. 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<void> {
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);
}
}
/** Delete a workspace — calls API, removes locally */

View file

@ -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' },

View file

@ -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<string>): 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<string, string> {
const parsed = safeGetJson<unknown>(STORAGE_KEYS.workspaceNameOverrides);
if (!parsed || typeof parsed !== 'object') return {};
const out: Record<string, string> = {};
for (const [root, name] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof name === 'string') out[root] = name;
}
return out;
}
export function saveWorkspaceNameOverrides(overrides: Record<string, string>): void {
safeSetJson(STORAGE_KEYS.workspaceNameOverrides, overrides);
}

View file

@ -1,8 +1,10 @@
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, saveWorkspaceNameOverrides } from '../src/lib/storage';
import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState';
import type { ExtendedState } from '../src/composables/useKimiWebClient';
@ -10,6 +12,7 @@ const apiMock = vi.hoisted(() => ({
abortPrompt: vi.fn(),
abortSession: vi.fn(),
addWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
}));
vi.mock('../src/api', () => ({
@ -126,6 +129,41 @@ function createDeps(): UseWorkspaceStateDeps {
} as unknown as UseWorkspaceStateDeps;
}
function createMemoryStorage(): Storage {
const data = new Map<string, string>();
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();
@ -216,6 +254,77 @@ describe('mergeWorkspaces', () => {
});
});
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();
});
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', () => {
beforeEach(() => {
apiMock.addWorkspace.mockReset();