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/fix-web-add-workspace-invalid-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix adding a workspace by path in the web UI failing silently when the daemon rejects the path; it now shows an error instead of a broken workspace.
17 changes: 16 additions & 1 deletion apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ type SubmitPayload = {
attachments: { fileId: string; kind: 'image' | 'video' }[];
};
const pendingWorkspaceSubmit = ref<SubmitPayload | null>(null);
// Inline error shown inside the add-workspace picker after the daemon rejects
// a path. Kept separate from the global toast so the feedback is visible above
// the picker's backdrop and persists until the user retries or closes.
const addWorkspaceError = ref<string | null>(null);

// Any of these modal/overlay layers, when open, owns Escape. The global
// capture-phase handler must NOT close a background side panel out from under an
Expand Down Expand Up @@ -483,8 +487,17 @@ async function handleSubmit(payload: SubmitPayload): Promise<void> {
}

async function handleAddWorkspace(root: string): Promise<void> {
addWorkspaceError.value = null;
const added = await client.addWorkspaceByPath(root);
// Keep the picker open (and the pending submission intact) when the daemon
// rejects the path so the user can retry with a valid one. The error is shown
// inline in the picker. Closing via Escape goes through handleCloseAddWorkspace,
// which drops the pending prompt.
if (!added) {
addWorkspaceError.value = t('workspace.addFailed');
return;
}
showAddWorkspace.value = false;
await client.addWorkspaceByPath(root);
const pending = pendingWorkspaceSubmit.value;
pendingWorkspaceSubmit.value = null;
const wsId = client.activeWorkspaceId.value;
Expand All @@ -495,6 +508,7 @@ async function handleAddWorkspace(root: string): Promise<void> {

function handleCloseAddWorkspace(): void {
pendingWorkspaceSubmit.value = null;
addWorkspaceError.value = null;
showAddWorkspace.value = false;
}

Expand Down Expand Up @@ -881,6 +895,7 @@ function openPr(url: string): void {
:browse-fs="client.browseFs"
:get-fs-home="client.getFsHome"
:default-path="client.visibleWorkspace.value?.root ?? client.status.value.cwd"
:error="addWorkspaceError"
@add="handleAddWorkspace($event)"
@close="handleCloseAddWorkspace"
/>
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,8 +973,8 @@ export class DaemonKimiWebApi implements KimiWebApi {

/**
* Register a workspace by folder path.
* PRESUMED — POST /api/v1/workspaces { root, name? }. On error this throws so
* the composable can fall back to a locally-derived workspace from the path.
* PRESUMED — POST /api/v1/workspaces { root, name? }. Throws on error (e.g.
* path not found) so the caller can surface it to the user.
*/
async addWorkspace(input: { root: string; name?: string }): Promise<AppWorkspace> {
const body: Record<string, unknown> = { root: input.root };
Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-web/src/components/dialogs/AddWorkspaceDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const props = defineProps<{
getFsHome: () => Promise<{ home: string; recentRoots: string[] }>;
/** Where the browser opens by default — the path kimi-web is working in. */
defaultPath?: string;
/** Inline error from a failed add attempt (e.g. daemon rejected the path). */
error?: string | null;
}>();

const emit = defineEmits<{
Expand Down Expand Up @@ -336,6 +338,10 @@ onUnmounted(() => {
</template>
</div>

<!-- Inline error from a failed add attempt. Shown inside the dialog so it
is visible above the backdrop and persists until the next attempt. -->
<div v-if="error" class="add-error" role="alert">{{ error }}</div>

<!-- Actions -->
<div class="actions">
<button
Expand Down Expand Up @@ -591,6 +597,16 @@ onUnmounted(() => {
.paste-add:disabled { opacity: 0.5; cursor: not-allowed; }

/* Actions */
.add-error {
margin: 0 14px 8px;
padding: 6px 10px;
font-family: var(--mono);
font-size: var(--ui-font-size-xs);
color: #b3261e;
background: rgba(179, 38, 30, 0.08);
border: 1px solid rgba(179, 38, 30, 0.25);
border-radius: 3px;
}
.actions {
display: flex;
gap: 8px;
Expand Down
30 changes: 9 additions & 21 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ 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 @@ -661,34 +660,23 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
}

/**
* Add a workspace by folder path. Tries the daemon registry; on failure (or in
* fallback mode) creates a locally-derived workspace from the path and
* remembers it, then selects it.
* Add a workspace by folder path, registering it with the daemon. Returns true
* when the workspace was registered and selected; false when the daemon
* rejected the path, so callers can keep the picker open and any pending
* submission instead of dropping it. The caller surfaces the failure to the
* user (e.g. an inline error in the picker).
*/
async function addWorkspaceByPath(root: string): Promise<void> {
async function addWorkspaceByPath(root: string): Promise<boolean> {
const trimmed = root.trim();
if (!trimmed) return;
if (!trimmed) return false;
const api = getKimiWebApi();
try {
const ws = await api.addWorkspace({ root: trimmed });
upsertWorkspacePreserveOrder(ws);
openWorkspaceDraft(ws.id);
return true;
} catch {
// Fallback: remember a derived workspace locally (id = root = path).
const existing = rawState.workspaces.find((w) => w.root === trimmed);
if (!existing) {
rawState.workspaces = [
{
id: trimmed,
root: trimmed,
name: basename(trimmed),
isGitRepo: false,
sessionCount: 0,
},
...rawState.workspaces,
];
}
openWorkspaceDraft(trimmed);
return false;
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/en/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default {
add: 'Add',
cancel: 'Cancel',
addHint: 'Paste an absolute folder path, or pick a recent one.',
addFailed: "Couldn't open this folder. Check the path and try again.",
// Folder browser
openThisFolder: 'Open this folder',
up: 'Up',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/zh/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default {
add: '添加',
cancel: '取消',
addHint: '粘贴一个绝对路径,或从最近用过的文件夹中选择。',
addFailed: '无法打开此文件夹,请检查路径后重试。',
// Folder browser
openThisFolder: '打开此文件夹',
up: '上一级',
Expand Down
45 changes: 45 additions & 0 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ExtendedState } from '../src/composables/useKimiWebClient';
const apiMock = vi.hoisted(() => ({
abortPrompt: vi.fn(),
abortSession: vi.fn(),
addWorkspace: vi.fn(),
}));

vi.mock('../src/api', () => ({
Expand Down Expand Up @@ -214,3 +215,47 @@ describe('mergeWorkspaces', () => {
expect(result.map((w) => w.root)).not.toContain('/agent/A');
});
});

describe('useWorkspaceState — addWorkspaceByPath', () => {
beforeEach(() => {
apiMock.addWorkspace.mockReset();
});

it('registers the workspace with the daemon and selects it', async () => {
const registered = {
id: 'wd_abc',
root: '/abs/path',
name: 'path',
isGitRepo: false,
sessionCount: 0,
};
apiMock.addWorkspace.mockResolvedValue(registered);
const state = createState();
const deps = createDeps();
const workspace = useWorkspaceState(state, deps);

const ok = await workspace.addWorkspaceByPath(' /abs/path ');

expect(ok).toBe(true);
expect(apiMock.addWorkspace).toHaveBeenCalledWith({ root: '/abs/path' });
expect(state.workspaces).toContainEqual(registered);
expect(state.activeWorkspaceId).toBe('wd_abc');
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});

it('returns false and adds no local workspace on failure', async () => {
const err = new Error('path not found');
apiMock.addWorkspace.mockRejectedValue(err);
const state = createState();
const deps = createDeps();
const workspace = useWorkspaceState(state, deps);

const ok = await workspace.addWorkspaceByPath('/abs/missing');

expect(ok).toBe(false);
// The caller (the picker) is responsible for surfacing the failure inline.
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
expect(state.workspaces).toEqual([]);
expect(state.activeWorkspaceId).toBeNull();
});
});
Loading