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
5 changes: 5 additions & 0 deletions .changeset/web-hide-provider-manager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Hide the provider management dialog in the web UI until the server supports it.
5 changes: 5 additions & 0 deletions .changeset/web-workspace-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the web workspace rename not persisting after a page refresh.
6 changes: 5 additions & 1 deletion apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;
Expand Down
76 changes: 64 additions & 12 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -495,14 +501,25 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
api.listWorkspaces().catch(() => [] as AppWorkspace[]),
api.getFsHome().catch(() => ({ home: '', recentRoots: [] })),
]);
rawState.workspaces = list;
rawState.workspaces = applyWorkspaceNameOverrides(list);

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 Preserve overrides when registering derived workspaces

When a user renames a derived workspace and then starts a new chat in it, startSessionAndSendPrompt registers that root with api.addWorkspace({ root: ws.root }) and upserts the daemon response. Because the saved override is only applied to the initial loadWorkspaces() list here, that upsert replaces the renamed workspace with the daemon's default basename; the name only comes back after a refresh. Please apply the saved override to workspace upserts, or pass the override name when registering, so the fallback survives the derived→registered transition.

Useful? React with 👍 / 👎.

rawState.fsHome = home.home || null;
rawState.recentRoots = home.recentRoots;
} catch {
// Defensive — derived workspaces still work off the loaded sessions.
}
}

/** 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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 */
Expand Down
1 change: 0 additions & 1 deletion apps/kimi-web/src/lib/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
23 changes: 23 additions & 0 deletions apps/kimi-web/src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
}
111 changes: 110 additions & 1 deletion apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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';

const apiMock = vi.hoisted(() => ({
abortPrompt: vi.fn(),
abortSession: vi.fn(),
addWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
}));

vi.mock('../src/api', () => ({
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading