-
{{ t('workspace.browsing') }}
+
+
+
+
+
+
-
-
-
- {{ t('workspace.noFilterMatch', { q: filter.trim() }) }}
- {{ t('workspace.searching') }}
-
+
+
+
{{ t('workspace.browsing') }}
-
-
+
+
+ {{ t('workspace.checkingPath') }}
+
+ {{ t('workspace.pathPickHint') }}
- {{ t('workspace.noSubfolders') }}
-
-
-
-
-
-
-
-
-
-
+
+
{{ t('workspace.degradedHint') }}
+
{{ error }}
@@ -342,7 +488,7 @@ onUnmounted(() => {
-
+
@@ -409,6 +555,10 @@ onUnmounted(() => {
.filter-input::placeholder { color: var(--color-text-muted); }
.search-rel { color: var(--color-text); }
+/* Path-mode error: tint the shared box's border + icon. */
+.filterbar.has-error { border-bottom-color: var(--color-danger); }
+.filterbar.has-error .filter-icon { color: var(--color-danger); }
+
/* Folder list */
.folder-list {
height: 300px;
@@ -421,6 +571,12 @@ onUnmounted(() => {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
+.fl-note {
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+}
+.fl-error { color: var(--color-danger); }
.folder-row {
display: flex;
align-items: center;
@@ -449,18 +605,13 @@ onUnmounted(() => {
}
.git-branch { color: var(--color-text-muted); }
-/* Paste-path escape hatch */
-.paste-section {
- padding: var(--space-3) var(--space-5);
- border-top: 1px solid var(--color-line);
-}
-.paste-section.paste-only { border-top: none; }
-.paste-row {
- display: flex;
- align-items: center;
- gap: var(--space-2);
+/* Degraded mode (daemon can't browse): compact hint under the input box. */
+.degraded-hint {
+ padding: var(--space-6) var(--space-5);
+ text-align: center;
+ color: var(--color-text-muted);
+ font-size: var(--text-sm);
}
-.paste-input-wrap { flex: 1; min-width: 0; }
/* Actions */
.add-error {
diff --git a/apps/kimi-web/src/i18n/locales/en/workspace.ts b/apps/kimi-web/src/i18n/locales/en/workspace.ts
index c9dbf16f3b..bb6556eb53 100644
--- a/apps/kimi-web/src/i18n/locales/en/workspace.ts
+++ b/apps/kimi-web/src/i18n/locales/en/workspace.ts
@@ -22,25 +22,28 @@ export default {
newInGroup: 'New session in this workspace',
// Add-workspace dialog
addTitle: 'Add workspace',
- pathLabel: 'Path',
- pathPlaceholder: '/absolute/path/to/project',
recentLabel: 'Recent folders',
- 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',
browsing: 'Browsing…',
filterPlaceholder: 'Filter subfolders…',
- searchPlaceholder: 'Fuzzy-search under this folder…',
+ searchPlaceholder: 'Fuzzy-search subfolders, or paste an absolute path…',
searching: 'Searching…',
- pasteToggle: 'Enter an absolute path',
noFilterMatch: 'No subfolders match “{q}”',
noSubfolders: 'No subfolders here',
gitTag: 'git',
browseHint: 'Click a folder to enter it, then "Open this folder" to add it as a workspace.',
+ // Path entry (absolute path typed into the same box)
+ checkingPath: 'Checking path…',
+ pathPickHint: 'Path not found — did you mean:',
+ noPathMatch: 'Path not found — no matching folders under {parent}',
+ badParent: 'Parent directory does not exist: {parent}',
+ pathFollowHint: 'Folder located — press Enter or "Open this folder" to add it.',
+ degradedPlaceholder: 'Type an absolute path, press Enter to add…',
+ degradedHint: 'File browsing is unavailable — type an absolute path and press Enter to add.',
// Attention marker
attentionTitle: '{count} item needs your attention | {count} items need your attention',
// Per-session pending tags (sidebar)
diff --git a/apps/kimi-web/src/i18n/locales/zh/workspace.ts b/apps/kimi-web/src/i18n/locales/zh/workspace.ts
index aae5fee9ee..23e6e38ada 100644
--- a/apps/kimi-web/src/i18n/locales/zh/workspace.ts
+++ b/apps/kimi-web/src/i18n/locales/zh/workspace.ts
@@ -22,25 +22,28 @@ export default {
newInGroup: '在此工作区新建会话',
// Add-workspace dialog
addTitle: '添加工作区',
- pathLabel: '路径',
- pathPlaceholder: '/项目的绝对路径',
recentLabel: '最近的文件夹',
- add: '添加',
cancel: '取消',
- addHint: '粘贴一个绝对路径,或从最近用过的文件夹中选择。',
addFailed: '无法打开此文件夹,请检查路径后重试。',
// Folder browser
openThisFolder: '打开此文件夹',
up: '上一级',
browsing: '加载中…',
filterPlaceholder: '过滤子文件夹…',
- searchPlaceholder: '在此目录下模糊搜索…',
+ searchPlaceholder: '模糊搜索子文件夹,或粘贴绝对路径…',
searching: '搜索中…',
- pasteToggle: '直接输入绝对路径',
noFilterMatch: '没有匹配「{q}」的子文件夹',
noSubfolders: '此处没有子文件夹',
gitTag: 'git',
browseHint: '点击文件夹进入,再点"打开此文件夹"将其添加为工作区。',
+ // Path entry (absolute path typed into the same box)
+ checkingPath: '检查路径…',
+ pathPickHint: '此路径不存在,你是不是想找:',
+ noPathMatch: '此路径不存在,「{parent}」下没有匹配的文件夹',
+ badParent: '上级目录不存在:{parent}',
+ pathFollowHint: '已定位到目标文件夹,按回车或点击"打开此文件夹"完成添加。',
+ degradedPlaceholder: '输入绝对路径,回车添加…',
+ degradedHint: '守护进程无法浏览文件系统,请直接输入绝对路径后回车添加。',
// Attention marker
attentionTitle: '{count} 项待处理',
// Per-session pending tags (sidebar)
diff --git a/apps/kimi-web/src/lib/workspacePathInput.ts b/apps/kimi-web/src/lib/workspacePathInput.ts
new file mode 100644
index 0000000000..050c3d5b95
--- /dev/null
+++ b/apps/kimi-web/src/lib/workspacePathInput.ts
@@ -0,0 +1,93 @@
+const PATH_LIKE = /^(?:\/|~(?:\/|$)|[A-Za-z]:[\\/]|\\\\)/;
+const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
+const FORWARD_UNC = /^\/\/(?!\/)/;
+
+export type WorkspacePathSeparator = '/' | '\\';
+
+export interface ParsedWorkspacePathInput {
+ target: string;
+ parent: string;
+ base: string;
+ separator: WorkspacePathSeparator;
+}
+
+export function isWorkspacePathInput(raw: string): boolean {
+ return PATH_LIKE.test(raw.trim());
+}
+
+function expandTilde(raw: string, homePath: string): string {
+ if (raw === '~') return homePath || raw;
+ if (raw.startsWith('~/')) return (homePath || '~') + raw.slice(1);
+ return raw;
+}
+
+function normalizeForwardSlashes(path: string): string {
+ if (FORWARD_UNC.test(path)) {
+ return `//${path.slice(2).replaceAll(/\/{2,}/g, '/')}`;
+ }
+ return path.replaceAll(/\/{2,}/g, '/');
+}
+
+function isWindowsPath(path: string): boolean {
+ return WINDOWS_DRIVE.test(path) || path.startsWith('\\\\') || path.startsWith('//');
+}
+
+function rootLength(path: string): number {
+ if (WINDOWS_DRIVE.test(path)) return 3;
+ if (path.startsWith('\\\\') || path.startsWith('//')) return 2;
+ if (path.startsWith('/')) return 1;
+ return 0;
+}
+
+export function parseWorkspacePathInput(
+ raw: string,
+ homePath: string,
+): ParsedWorkspacePathInput {
+ let target = normalizeForwardSlashes(expandTilde(raw.trim(), homePath));
+ const windowsPath = isWindowsPath(target);
+ const isRoot =
+ target === '/' ||
+ target === '//' ||
+ target === '\\\\' ||
+ /^[A-Za-z]:[\\/]$/.test(target);
+
+ // A trailing backslash is a separator only for Windows-shaped paths. On
+ // POSIX it may be part of the directory name and must stay untouched.
+ const hasTrailingSeparator = windowsPath ? /[\\/]$/.test(target) : target.endsWith('/');
+ if (!isRoot && hasTrailingSeparator) target = target.slice(0, -1);
+
+ const slash = target.lastIndexOf('/');
+ const backslash = windowsPath ? target.lastIndexOf('\\') : -1;
+ const lastSeparator = Math.max(slash, backslash);
+ const separator: WorkspacePathSeparator = backslash > slash ? '\\' : '/';
+ const root = rootLength(target);
+ const parent =
+ lastSeparator < root
+ ? target.slice(0, root) || '/'
+ : target.slice(0, lastSeparator) || '/';
+
+ return {
+ target,
+ parent,
+ base: target.slice(lastSeparator + 1),
+ separator,
+ };
+}
+
+export function joinWorkspacePathCandidate(
+ parent: string,
+ name: string,
+ separator: WorkspacePathSeparator,
+): string {
+ return `${parent}${parent.endsWith(separator) ? '' : separator}${name}`;
+}
+
+export function currentValidatedWorkspacePath(
+ raw: string,
+ homePath: string,
+ validatedPath: string | null,
+): string | null {
+ if (validatedPath === null) return null;
+ const { target } = parseWorkspacePathInput(raw, homePath);
+ return target === validatedPath ? validatedPath : null;
+}
diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts
index 3afb035ca2..ab2d517e52 100644
--- a/apps/kimi-web/test/lib-logic.test.ts
+++ b/apps/kimi-web/test/lib-logic.test.ts
@@ -11,6 +11,12 @@ import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync';
import { mergeSnapshotMessages } from '../src/lib/snapshotMessages';
import { normalizeToolName, toolSummary } from '../src/lib/toolMeta';
import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize';
+import {
+ currentValidatedWorkspacePath,
+ isWorkspacePathInput,
+ joinWorkspacePathCandidate,
+ parseWorkspacePathInput,
+} from '../src/lib/workspacePathInput';
import {
coerceThinkingForModel,
commitLevel,
@@ -26,6 +32,66 @@ import EditTool from '../src/components/chat/tool-calls/EditTool.vue';
import GenericTool from '../src/components/chat/tool-calls/GenericTool.vue';
import type { ToolCall } from '../src/types';
+describe('workspace path input', () => {
+ it('recognizes the supported absolute path forms', () => {
+ expect(isWorkspacePathInput('/tmp/project')).toBe(true);
+ expect(isWorkspacePathInput('~/project')).toBe(true);
+ expect(isWorkspacePathInput('C:\\project')).toBe(true);
+ expect(isWorkspacePathInput('C:/project')).toBe(true);
+ expect(isWorkspacePathInput('\\\\server\\share')).toBe(true);
+ expect(isWorkspacePathInput('project')).toBe(false);
+ });
+
+ it('normalizes separators without changing UNC roots or POSIX backslashes', () => {
+ expect(parseWorkspacePathInput('/tmp//project/', '').target).toBe('/tmp/project');
+ expect(parseWorkspacePathInput('//server//share/project/', '').target).toBe('//server/share/project');
+ expect(parseWorkspacePathInput('///tmp//project/', '').target).toBe('/tmp/project');
+ expect(parseWorkspacePathInput('/tmp/project\\', '').target).toBe('/tmp/project\\');
+ expect(parseWorkspacePathInput('~/project', '/home/alice').target).toBe('/home/alice/project');
+ });
+
+ it('preserves Windows root separators in parent paths', () => {
+ expect(parseWorkspacePathInput('C:\\Use', '')).toMatchObject({
+ parent: 'C:\\',
+ base: 'Use',
+ separator: '\\',
+ });
+ expect(parseWorkspacePathInput('C:/Use', '')).toMatchObject({
+ parent: 'C:/',
+ base: 'Use',
+ separator: '/',
+ });
+ expect(parseWorkspacePathInput('\\\\server\\share\\pro', '')).toMatchObject({
+ parent: '\\\\server\\share',
+ base: 'pro',
+ separator: '\\',
+ });
+ expect(parseWorkspacePathInput('//server/share/pro', '')).toMatchObject({
+ parent: '//server/share',
+ base: 'pro',
+ separator: '/',
+ });
+ });
+
+ it('treats backslashes as literal characters in POSIX paths', () => {
+ expect(parseWorkspacePathInput('/tmp/foo\\bar', '')).toMatchObject({
+ parent: '/tmp',
+ base: 'foo\\bar',
+ separator: '/',
+ });
+ });
+
+ it('builds completion paths from the lexical parent', () => {
+ const parsed = parseWorkspacePathInput('/tmp/link/proje', '');
+ expect(joinWorkspacePathCandidate(parsed.parent, 'project', parsed.separator)).toBe('/tmp/link/project');
+ });
+
+ it('only returns a validated path while it still matches the current input', () => {
+ expect(currentValidatedWorkspacePath('/var', '', '/tmp')).toBeNull();
+ expect(currentValidatedWorkspacePath('/tmp/', '', '/tmp')).toBe('/tmp');
+ });
+});
+
describe('parseDiff', () => {
it('parses multiple files and keeps hunk line numbers', () => {
const diff = [