mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
86e0c9201e
commit
882cf355a9
9 changed files with 225 additions and 15 deletions
5
.changeset/web-hide-provider-manager.md
Normal file
5
.changeset/web-hide-provider-manager.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Hide the provider management dialog in the web UI until the server supports it.
|
||||
5
.changeset/web-workspace-rename.md
Normal file
5
.changeset/web-workspace-rename.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix the web workspace rename not persisting after a page refresh.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] }>;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue