diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml new file mode 100644 index 0000000000..47416d5a30 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125 +2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md new file mode 100644 index 0000000000..847b7a7c78 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -0,0 +1,51 @@ +# Agent Note: Native workspace directory picker + +Status: implemented + +English | [中文](2026-07-27-native-workspace-directory-picker.zh.md) + +## Problem + +The desktop GUI asks users to type an absolute path when they add an existing workspace. This is slower and more error-prone than choosing a directory with the operating system's native picker. The GUI is delivered through the local Web carrier, so opening a native dialog also creates a privileged boundary that ordinary remote requests must not cross. + +## Decision + +Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session. + +The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title. + +## Interaction contract + +- The picker accepts one directory on macOS, Windows, and Linux. +- Cancelling the system dialog is silent and returns `null`. +- A duplicate path selects the existing workspace. +- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions. +- Other picker failures show a compact retryable error. +- The existing create-by-name flow remains unchanged. + +## Host boundary + +The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. + +Platform adapters invoke native tools without a shell: + +- macOS: `osascript` and the system folder chooser. +- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. + +## Alternatives considered + +- A custom directory browser duplicates operating-system behavior and permissions, and belongs to the Web implementation rather than this desktop-only change. +- Reusing the manual path field keeps the current error-prone interaction. +- Adding authentication infrastructure for one local native dialog would expand the change beyond its threat model; loopback and same-origin checks are sufficient for this carrier. + +## Consequences + +The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available. + +The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision. + +## Risks + +- Linux desktop environments may provide neither supported picker. The GUI reports that limitation instead of falling back to a typed path. +- Browser metadata varies outside the supported local carrier. The endpoint intentionally rejects requests that cannot prove the required local same-origin context. diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md new file mode 100644 index 0000000000..e484a2ba58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 原生工作区目录选择器 + +Status: implemented + +[English](2026-07-27-native-workspace-directory-picker.md) | 中文 + +## 问题 + +桌面端 GUI 在添加现有工作区时要求用户输入绝对路径。相比使用操作系统原生选择器选取目录,这种操作速度更慢,也更容易出错。GUI 由本地 Web 载体提供,因此打开原生对话框也会形成一条特权边界,普通远程请求不得越过这条边界。 + +## 决策 + +新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作:**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。 + +工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。 + +## 交互契约 + +- 在 macOS、Windows 和 Linux 上,选择器一次只允许选择一个目录。 +- 取消系统对话框不会显示提示,并返回 `null`。 +- 路径重复时,选中现有工作区。 +- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择** 和 **取消** 操作。 +- 选择器的其他故障会显示简洁且可重试的错误提示。 +- 现有的按名称创建流程保持不变。 + +## 宿主边界 + +只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 + +平台适配器不经 shell,直接调用原生工具: + +- macOS:`osascript` 和系统文件夹选择器。 +- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 + +## 考虑过的替代方案 + +- 自定义目录浏览器会重复实现操作系统的行为和权限逻辑,而且应属于 Web 实现,而非本次仅面向桌面端的变更。 +- 继续使用手动路径字段会保留当前容易出错的交互方式。 +- 为一个本地原生对话框添加身份认证基础设施,会使变更范围超出其威胁模型;对当前载体而言,回环与同源检查已经足够。 + +## 后果 + +当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用。 + +新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。 + +## 风险 + +- Linux 桌面环境可能不提供任何一种受支持的选择器。GUI 会报告这项限制,而不会回退到要求用户输入路径。 +- 在受支持的本地载体之外,浏览器元数据可能有所不同。对于无法证明其满足所需本地同源上下文的请求,该端点会按设计拒绝。 diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index 29c1d68f7a..ac47ca4cd0 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -128,7 +128,6 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- // Session+Agent and the provider swaps in the live blank-session hero. fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' }) .find(el => el.getAttribute('aria-haspopup') === 'menu')!) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 8041df307d..5521d984da 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -45,7 +45,6 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise { await page.getByRole('button', { name: 'Choose workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) await dialog.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 0166c6a9d9..e1fe183ef1 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -134,10 +134,9 @@ function setComposerText(composer: HTMLElement, value: string): void { expect((composer as HTMLTextAreaElement).value).toBe(value) } -/** Drive the picker's create flow: chip → Create workspace → name dialog. */ +/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */ async function createWorkspaceViaPicker(name: string): Promise { fireEvent.click(workspaceChip()) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 98a2338064..fd18d89087 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -30,9 +30,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType + let pickedDirectory: string | null = null beforeAll(async () => { scaffold = await launchWebScaffold({}) + scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({ + rpcId: request.rpcId, + result: { ok: true, value: { path: pickedDirectory } }, + }) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) @@ -55,8 +60,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create')) const createByName = async (name: string): Promise => { await page.getByRole('button', { name: 'Create workspace' }).click() - // The pick menu's Create workspace submenu opens on hover/focus. - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -134,14 +137,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. + pickedDirectory = scaffold.workspaceCwd await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) - await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) - await useFolder.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), + { timeout: 10_000 }, + ).not.toBeUndefined() const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -197,13 +200,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. + pickedDirectory = scaffold.workspaceCwd await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) - await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) - await reuseFolder.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), + { timeout: 10_000 }, + ).not.toBeUndefined() const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) expect(reregistered?.id).toBeDefined() expect(reregistered?.id).not.toBe(workspace.id) @@ -269,13 +272,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) + pickedDirectory = oldPath await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) - await adopt.getByLabel('Existing folder path').fill(oldPath) - await adopt.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(oldPath), + { timeout: 10_000 }, + ).not.toBeUndefined() const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') @@ -288,7 +291,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const create = page.getByRole('dialog', { name: 'Create a new workspace' }) await create.getByLabel('New workspace name').fill(title) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9aa43145ed..f53f7ac22e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -704,6 +704,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), + pickDirectory: request => ok(request, { path: null }), }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -952,6 +953,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f06b2bd2f6..bc73e0e054 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -5,6 +5,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' +import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' export { API_PATH } from './api-path.ts' @@ -23,7 +24,16 @@ export function apply(ctx: Context): void { const route: WebRoute = { kind: 'prefix', path: API_PATH, - handler: (req, res) => bridge(req, res, apiHandler), + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + if (pathname === `${API_PATH}/host.pickDirectory` + && !isTrustedNativeDialogRequest(req)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, apiHandler) + }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') } diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts new file mode 100644 index 0000000000..fe91bbae2d --- /dev/null +++ b/packages/client/connection/src/native-dialog-request.ts @@ -0,0 +1,52 @@ +/** Trust check for browser requests that can open an operating-system dialog. */ + +import type { IncomingHttpHeaders } from 'node:http' + +interface NativeDialogRequest { + headers: IncomingHttpHeaders + socket: { remoteAddress?: string | undefined } +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +function isLoopback(address: string | undefined): boolean { + if (address === undefined) return false + if (address === '::1') return true + const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address + const first = ipv4.split('.')[0] + return first === '127' +} + +function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +/** + * Require a local socket plus browser-controlled same-origin metadata. + * @param request - the node HTTP request facts used by the carrier guard. + * @returns true only for a same-origin browser request whose peer and URL are loopback. + */ +export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { + if (!isLoopback(request.socket.remoteAddress)) return false + if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false + const origin = header(request.headers, 'origin') + const host = header(request.headers, 'host') + if (origin === undefined || host === undefined) return false + try { + const parsed = new URL(origin) + const hostUrl = new URL(`http://${host}`) + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') + && parsed.host === host + && isLoopbackHostname(parsed.hostname) + && isLoopbackHostname(hostUrl.hostname) + } catch { + return false + } +} diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1c58e604ab..cfabe476e3 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -52,6 +52,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -70,6 +72,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts new file mode 100644 index 0000000000..4607f32bae --- /dev/null +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { describe, expect, it } from 'vitest' +import { bridge } from '../src/http-bridge.ts' + +describe('HTTP bridge abort', () => { + it('aborts a pending native picker request when the browser disconnects', async () => { + const body = JSON.stringify({ + type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + }) + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/host.pickDirectory', + method: 'POST', + headers: { 'content-type': 'application/json' }, + }) + + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead() { return this }, + write() { return true }, + end() { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + let carrierSignal: AbortSignal | undefined + const pending = bridge(request, response, { + fetch: async (input) => { + const fetchRequest = input as Request + carrierSignal = fetchRequest.signal + resolveStarted() + if (!fetchRequest.signal.aborted) { + await new Promise((resolve) => { + fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return Response.json({ aborted: fetchRequest.signal.aborted }) + }, + }) + await started + response.emit('close') + await pending + expect(carrierSignal?.aborted).toBe(true) + }) +}) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts new file mode 100644 index 0000000000..1a3d70dd15 --- /dev/null +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -0,0 +1,57 @@ +import type { IncomingHttpHeaders } from 'node:http' +import { describe, expect, it } from 'vitest' +import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' + +function request( + remoteAddress: string | undefined, + headers: IncomingHttpHeaders = { + host: '127.0.0.1:3080', + origin: 'http://127.0.0.1:3080', + 'sec-fetch-site': 'same-origin', + }, +) { + return { socket: { remoteAddress }, headers } +} + +describe('native dialog request trust', () => { + it('accepts loopback same-origin browser requests', () => { + expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::1', { + host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.2', { + host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + }) + + it('rejects remote sockets and requests without matching browser metadata', () => { + expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) + expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index e9e880cfb4..86af61ba0d 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,6 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' +import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject } from '../src/index.ts' @@ -27,6 +28,23 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + let status: number | undefined + let body: unknown + const deniedRequest = { + url: '/api/host.pickDirectory', + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + } as unknown as IncomingMessage + const deniedResponse = { + writeHead(value: number) { status = value; return this }, + end(value?: unknown) { body = value; return this }, + } as unknown as ServerResponse + await routes[0]!.handler(deniedRequest, deniedResponse) + expect(status).toBe(403) + expect(body).toBe('forbidden') + await fiber.dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b4a418628f..ec39ce9e1c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' -export { WorkspacesService } from './workspaces/service.ts' +export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 4cb26aa220..fe345801c6 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -21,6 +21,14 @@ export interface WorkspaceListState { recentWorkspaceId: WorkspaceId | undefined } +/** Structured create failure for UI flows that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + constructor(readonly rpcError: RpcError) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) + this.name = 'WorkspaceCreateError' + } +} + /** Real Workspace object layer and Host actions. */ export class WorkspacesService { /** UI-facing immutable projection; the manager remains wire truth. */ @@ -37,7 +45,7 @@ export class WorkspacesService { * @param api - shared wire client. * @param sessions - lower-level Session service used for recency and blank-session reuse. */ - constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { + constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ items: [], state: 'idle', phase: 'pending', error: null, @@ -158,10 +166,22 @@ export class WorkspacesService { */ async create(input: { name: string } | { path: string }): Promise { const result = await this.manager.create(input) - if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`) + if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace } + /** + * Open the Host's native directory picker. + * @returns the selected path, or null when the user cancelled. + */ + async pickDirectory(): Promise { + const response = await this.api.host.pickDirectory({}) + if (!response.result.ok) { + throw new Error(`directory picker failed: ${response.result.error.message}`) + } + return response.result.value.path + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index d906ff92a8..6987d82d7a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -69,6 +69,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -87,6 +89,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 210c04d896..5327066651 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId @@ -210,12 +210,30 @@ describe('WorkspacesService', () => { const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) - await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' }) - expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }]) + api.onWorkspaceCreate = () => Promise.resolve(ok({ + workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, + })) + await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' }) + expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }]) api.onWorkspaceCreate = () => Promise.resolve(err({ code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, })) - await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) + const rejected = workspaces.create({ path: '/missing' }) + await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/) + await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError) + }) + + it('passes native directory selection and cancellation through without local state', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') + api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + await expect(workspaces.pickDirectory()).resolves.toBeNull() + expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) }) it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7a229758b1..789f86aeb6 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -90,20 +90,17 @@ export function apply(ctx: Context): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ - selectWorkspace: (workspaceId) => { - void workspaces.connectWorkspace(workspaceId).then((nextId) => { - if (sessionId !== undefined && nextId !== sessionId) { - const from = inputHub.shell(sessionId) - const draft = from.snapshot.draft - if (draft !== '') { - inputHub.shell(nextId).setDraft(draft) - from.setDraft('') - } + selectWorkspace: async (workspaceId) => { + const nextId = await workspaces.connectWorkspace(workspaceId) + if (sessionId !== undefined && nextId !== sessionId) { + const from = inputHub.shell(sessionId) + const draft = from.snapshot.draft + if (draft !== '') { + inputHub.shell(nextId).setDraft(draft) + from.setDraft('') } - sessions.open(nextId) - }).catch(() => { - // Failure leaves the current Hero state available to retry. - }) + } + sessions.open(nextId) }, }), }, ConversationRoot) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1e620f0905..9086e11d7c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -175,7 +175,7 @@ export interface ConversationInjected { * Connect the selected Workspace and open its reusable/new blank session. * When a blank session is already current, carry its draft to the target. */ - selectWorkspace(workspaceId: WorkspaceId): void + selectWorkspace(workspaceId: WorkspaceId): Promise } /** Business callbacks injected into the strict session content seat. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..780e74be4c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,8 +2,9 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' @@ -25,8 +26,23 @@ export function ConversationRoot({ const workspaces = useWorkspaces(s => s) const [pickerOpen, setPickerOpen] = useState(false) + const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) + const sessionWorkspace = sessionId === undefined + ? undefined + : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) + const pendingWorkspace = workspaces.items.find( + workspace => workspace.workspaceId === pendingWorkspaceId, + ) + + useEffect(() => { + if (pendingWorkspaceId !== undefined + && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + setPendingWorkspaceId(undefined) + } + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -36,9 +52,10 @@ export function ConversationRoot({ w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '') + : sessionWorkspace?.title ?? workspaceLabel(cwd ?? '')) } menuOpen={pickerOpen} onClick={() => { setPickerOpen(open => !open) }} @@ -48,7 +65,10 @@ export function ConversationRoot({ anchorRef: pickerAnchor, onPick: (workspaceId) => { setPickerOpen(false) - selectWorkspace(workspaceId) + setPendingWorkspaceId(workspaceId) + void selectWorkspace(workspaceId).catch(() => { + setPendingWorkspaceId(current => current === workspaceId ? undefined : current) + }) }, onClose: () => { setPickerOpen(false) }, })} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9b1eac5b93..b777c85ac3 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -3,7 +3,7 @@ // hero (blank session) and active phases — same textarea DOM node, machine- // owned draft, and the hero workspace picker (switching = retargetWorkspace). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { @@ -55,7 +55,11 @@ function conversationSnapshot(overrides: Partial = {}): Co } } -function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) { +function mount( + snapshot: ConversationSnapshot, + workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], + retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), +) { const root = sid('root') const sessions = createSnapshotStore({ ids: [root, SID], @@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = const inputActions = wiring.actions const stop = vi.fn() const open = vi.fn() - const retargetWorkspace = vi.fn() const slotCalls: string[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { @@ -158,7 +161,13 @@ describe('ConversationRoot resident composer', () => { }) it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount( + conversationSnapshot({ composerPhase: 'blank', blank: true }), + [ + { ...workspace('one'), sessionIds: [SID] }, + { ...workspace('second'), title: 'Selected Folder' }, + ], + ) // Hero chrome present, view ring absent. expect(b.view.getByText("Let's start building")).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() @@ -173,8 +182,9 @@ describe('ConversationRoot resident composer', () => { fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } expect(owner.open).toBe(true) - owner.onPick(wid('second')) + act(() => { owner.onPick(wid('second')) }) expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second')) + expect(b.view.getByText('Selected Folder')).toBeTruthy() }) it('textarea DOM identity survives the hero → active flip', () => { @@ -191,6 +201,24 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByTestId('view-chat')).toBeTruthy() }) + it('rolls the pending workspace label back when switching fails', async () => { + const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) + const b = mount( + conversationSnapshot({ composerPhase: 'blank', blank: true }), + [ + { ...workspace('one'), sessionIds: [SID] }, + { ...workspace('second'), title: 'Selected Folder' }, + ], + selectWorkspace, + ) + fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) + const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void } + await act(async () => { owner.onPick(wid('second')); await Promise.resolve() }) + expect(selectWorkspace).toHaveBeenCalledWith(wid('second')) + expect(b.view.queryByText('Selected Folder')).toBeNull() + expect(b.view.getByText('one')).toBeTruthy() + }) + it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const chip = b.view.getByRole('button', { name: 'Choose workspace' }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 88638b9e35..0d78a8d648 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259 -README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951 +README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 +README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index b5a78c30dd..edd6c2f937 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. +Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. -- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. +- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 904543a48f..f7b73dde95 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 +共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -19,4 +19,4 @@ ## 已知限制与暂缓事项 - **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 -- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 +- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index c56de93c56..c5609b176a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -250,6 +250,7 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, + pickDirectory, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -367,6 +368,7 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} + pickDirectory={pickDirectory} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 99ed2831a6..dd0cf6f562 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -9,15 +9,17 @@ import { useCallback, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import { + WorkspaceCreateError, + type WorkspaceId, type WorkspaceListState, type WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' -const CREATE_WORKSPACE = '::create-workspace' -const USE_EXISTING = '::use-existing' +const OPEN_LOCAL_FOLDER = '::open-local-folder' const CREATE_NEW = '::create-new' -type ModalKind = 'path' | 'create' | null +type ModalKind = 'create' | 'folder-error' | null /** Core flow props: the owner supplies popover control and pick semantics. */ export interface WorkspaceCreateFlowProps { @@ -29,6 +31,8 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Open the Host's native single-directory picker. */ + pickDirectory: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -45,6 +49,7 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, + pickDirectory, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -55,10 +60,11 @@ export function WorkspaceCreateFlow({ [anchorRef], ) const [modalKind, setModalKind] = useState(null) - const [pathDraft, setPathDraft] = useState('') const [workspaceName, setWorkspaceName] = useState('') const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) + const [pickingFolder, setPickingFolder] = useState(false) + const [folderConflict, setFolderConflict] = useState(false) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) @@ -68,17 +74,11 @@ export function WorkspaceCreateFlow({ id: workspace.workspaceId as string, label: workspace.title, icon: , + disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { - id: CREATE_WORKSPACE, - label: 'Create workspace', - icon: , - submenu: [ - { id: USE_EXISTING, label: 'Use an existing folder' }, - { id: CREATE_NEW, label: 'Create a new workspace' }, - ], - }, + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] const closeModal = (): void => { @@ -87,12 +87,29 @@ export function WorkspaceCreateFlow({ setModalError(null) } + const openLocalFolder = (): void => { + onClose() + setModalKind(null) + setModalError(null) + setFolderConflict(false) + setPickingFolder(true) + void pickDirectory().then(async (path) => { + if (path === null) return + const workspace = await createWorkspace({ path }) + onPick(workspace.workspaceId) + }).catch((reason: unknown) => { + setFolderConflict( + reason instanceof WorkspaceCreateError + && reason.rpcError.code === 'workspace-name-conflict', + ) + setModalError(reason instanceof Error ? reason.message : String(reason)) + setModalKind('folder-error') + }).finally(() => { setPickingFolder(false) }) + } + const handleSelect = (id: string): void => { - if (id === USE_EXISTING) { - onClose() - setPathDraft('') - setModalError(null) - setModalKind('path') + if (id === OPEN_LOCAL_FOLDER) { + openLocalFolder() return } if (id === CREATE_NEW) { @@ -120,11 +137,6 @@ export function WorkspaceCreateFlow({ }) } - const confirmPath = (): void => { - const path = pathDraft.trim() - if (path !== '') create({ path }) - } - const confirmCreate = (): void => { if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) { create({ name: normalizedWorkspaceName }) @@ -144,40 +156,21 @@ export function WorkspaceCreateFlow({ /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} - - + + )} > - { setPathDraft(event.target.value) }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - confirmPath() - } - }} - /> - {creating &&
Creating workspace…
} - {modalError !== null &&
{modalError}
} +
+ {folderConflict + ? 'Choose a folder with a different name.' + : modalError} +
diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 2e4c88dd6b..1f2f3aae70 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -42,6 +42,8 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Ask the local Host to open its native single-directory picker. */ + pickDirectory: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -58,6 +60,8 @@ export type WorkspaceBrowserProps = export type WorkspacePickerInjected = { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace(input: { name: string } | { path: string }): Promise + /** Ask the local Host to open its native single-directory picker. */ + pickDirectory(): Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 98adfecce3..f27448f926 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -44,9 +44,11 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), + pickDirectory: () => ctx.workspaces.pickDirectory(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), + pickDirectory: () => ctx.workspaces.pickDirectory(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 8961ccdb83..24434f22aa 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -14,16 +14,17 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) + const pickDirectory = vi.fn(async () => '/tmp/picked') const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, startSession, rename, insertSessionBefore, + create, pickDirectory, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear } + return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -72,10 +73,14 @@ describe('ui-workspace apply', () => { expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') await browser.createWorkspace({ name: 'project' }) expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + await browser.pickDirectory() + expect(b.pickDirectory).toHaveBeenCalledOnce() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) + await picker.pickDirectory() + expect(b.pickDirectory).toHaveBeenCalledTimes(2) }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 33abdc1231..1f06055ece 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -57,6 +57,7 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), + pickDirectory: vi.fn(async () => null), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index a5510178e7..04a320139f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' +import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -32,7 +33,11 @@ function anchor(): { current: HTMLElement } { return { current: element } } -function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) { +function mount( + items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], + createWorkspace = vi.fn(), + pickDirectory = vi.fn(async () => null as string | null), +) { const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() @@ -45,20 +50,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} + pickDirectory={pickDirectory} /> ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, + view, onPick, onClose, createWorkspace, pickDirectory, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } -function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void { - const parent = screen.getByRole('menuitem', { name: 'Create workspace' }) - fireEvent.mouseEnter(parent.parentElement as HTMLElement) +function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void { fireEvent.click(screen.getByRole('menuitem', { name })) } @@ -73,7 +77,7 @@ describe('WorkspacePicker', () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -81,31 +85,78 @@ describe('WorkspacePicker', () => { await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) - it('adopts an existing path through the same immediate create action', async () => { - const created = workspace('adopted') + it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => { + const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' } const createWorkspace = vi.fn(async () => created) - const b = mount([], createWorkspace) - chooseCreateItem('Use an existing folder') - const input = screen.getByLabelText('Existing folder path') - fireEvent.keyDown(input, { key: 'ArrowRight' }) - fireEvent.keyDown(input, { key: 'Enter' }) - expect(createWorkspace).not.toHaveBeenCalled() - fireEvent.change(input, { target: { value: ' /tmp/project ' } }) - fireEvent.keyDown(input, { key: 'Enter' }) + const pickDirectory = vi.fn(async () => '/tmp/project') + const b = mount([], createWorkspace, pickDirectory) + chooseItem('Open local folder…') + expect(pickDirectory).toHaveBeenCalledOnce() + await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) + it('treats native picker cancellation as a silent no-op', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null)) + chooseItem('Open local folder…') + await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + expect(b.createWorkspace).not.toHaveBeenCalled() + expect(b.onPick).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('shows a name conflict and retries through the native picker', async () => { + const pickDirectory = vi.fn() + .mockResolvedValueOnce('/one/project') + .mockResolvedValueOnce(null) + const createWorkspace = vi.fn(async () => { + throw new WorkspaceCreateError({ + code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, + }) + }) + const b = mount([], createWorkspace, pickDirectory) + chooseItem('Open local folder…') + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() + }) + expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.') + fireEvent.click(screen.getByRole('button', { name: 'Choose again' })) + await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('disables the folder action while the native picker is already open', async () => { + let resolve!: (path: string | null) => void + const pending = new Promise((settle) => { resolve = settle }) + const b = mount([], vi.fn(), vi.fn(() => pending)) + chooseItem('Open local folder…') + expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) + expect(b.pickDirectory).toHaveBeenCalledTimes(1) + await act(async () => { resolve(null); await pending }) + }) + + it('reports non-Error native picker failures', async () => { + const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) + chooseItem('Open local folder…') + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('picker unavailable') + }) + expect(b.createWorkspace).not.toHaveBeenCalled() + }) + it('closes a creation modal when the user cancels', () => { mount([]) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).toBeNull() }) it('blocks a create-new name already present in the Workspace list', () => { const b = mount([workspace('alpha', 'Alpha')]) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true) @@ -118,7 +169,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -134,7 +185,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.keyDown(input, { key: 'ArrowRight' }) fireEvent.change(input, { target: { value: 'broken' } }) @@ -151,7 +202,7 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') @@ -163,7 +214,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.queryByRole('menu')).toBeNull() @@ -176,7 +227,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index bf9f1194d4..aee09384de 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1 -README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020 +README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86 +README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index adcffaf553..253c0974cc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests. + `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. @@ -35,3 +37,4 @@ None; this package neither assembles nor sends a provider request. - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. +- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 957b7967c9..d79628ca3e 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。 + `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 @@ -35,3 +37,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 +- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a357eee13b..eb7abab8e4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,7 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' +import { pickNativeDirectory } from './native-directory-picker.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -194,6 +195,8 @@ export interface ApiProxyDefaults { cwd: string /** Parent directory for name-created workspaces. */ workspaceRoot: string + /** Native single-directory picker; injectable for carrier tests. */ + pickDirectory?: (signal: AbortSignal) => Promise } /** The tool/call payload fields the presenter path reads. */ @@ -823,6 +826,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro attachedSessions: ctx.agents.list().length, })) }, + + async pickDirectory(request, signal) { + try { + const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal) + return ok(request, { path }) + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { + code: 'cancelled', + message: 'directory picker was aborted', + details: {}, + }) + } + return err(request, { + code: 'internal', + message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + }, }, commands: { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index cb8de392f5..c7353c4360 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,3 +17,11 @@ export const hostDescribeValueSchema = z.object({ model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> + +/** host.pickDirectory request payload (empty object literal). */ +export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> + +/** host.pickDirectory response value; null means the user cancelled. */ +export const hostPickDirectoryValueSchema = z.object({ + path: z.string().nullable(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index fd07b33ced..e3573346f4 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -22,4 +22,10 @@ export interface HostApi { model?: string attachedSessions: number }>> + + /** Open the operating system's single-directory picker; cancellation returns null. */ + pickDirectory( + request: RpcRequest<{}>, + signal: AbortSignal, + ): Promise> } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 68ccc9ec89..768c93ed2f 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -23,6 +23,7 @@ export interface RpcMethodMap { 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] + 'host.pickDirectory': HostApi['pickDirectory'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8762670cd7..445bb0d563 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,7 +13,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' -import { hostDescribeValueSchema } from '../api/host.schema.ts' +import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts' import { sessionCancelValueSchema, sessionCreateValueSchema, @@ -56,6 +56,7 @@ export interface IApiClient { } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> + pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> } workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> @@ -90,6 +91,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType { - const timeout = AbortSignal.timeout(this.timeoutMs) + private async postJson( + path: string, + body: ClientRequest | ClientResponse, + signal: AbortSignal | undefined, + useDefaultTimeout = true, + ): Promise { + const requestSignal = useDefaultTimeout + ? signal === undefined + ? AbortSignal.timeout(this.timeoutMs) + : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) + : signal const response = await this.doFetch(new URL(path, this.resolveBase()), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]), + ...requestSignal === undefined ? {} : { signal: requestSignal }, }) if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) return response @@ -198,10 +209,11 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, + useDefaultTimeout = true, ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal) + const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout) const full = serverResponseSchema.parse(await response.json()) this.onEnvelope(full) if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) @@ -282,6 +294,9 @@ export abstract class AbstractApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), + // A native system dialog is user-paced and may legitimately stay open + // longer than the normal unary deadline. Caller/connection aborts remain. + pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 3bbcbffba1..9b7bb5edda 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -21,7 +21,7 @@ import { sessionListRequestSchema, sessionPromptRequestSchema, } from '../api/sessions.schema.ts' -import { hostDescribeRequestSchema } from '../api/host.schema.ts' +import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceDeleteRequestSchema, @@ -55,6 +55,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, + 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/apiproxy/src/native-directory-picker.ts new file mode 100644 index 0000000000..8bc0bc99ce --- /dev/null +++ b/packages/host/apiproxy/src/native-directory-picker.ts @@ -0,0 +1,136 @@ +/** Cross-platform native single-directory picker used by the local GUI carrier. */ + +import { execFile } from 'node:child_process' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type DirectoryPickerRunner = ( + command: string, + args: readonly string[], + signal: AbortSignal, +) => Promise<{ stdout: string; stderr: string }> + +/** Injectable platform facts for deterministic adapter tests. */ +export interface DirectoryPickerInternals { + platform?: NodeJS.Platform + run?: DirectoryPickerRunner +} + +const runCommand: DirectoryPickerRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + [...args], + { encoding: 'utf8', signal, windowsHide: true }, + (error, stdout, stderr) => { + if (error !== null) { + const failure = Object.assign(new Error(error.message, { cause: error }), { + code: error.code, + stdout, + stderr, + }) + reject(failure) + return + } + resolve({ stdout, stderr }) + }, + ) + }) + +function outputPath(stdout: string): string | null { + const path = stdout.replace(/[\r\n]+$/, '') + return path === '' ? null : path +} + +function errorCode(error: unknown): string | number | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) return undefined + const code = (error as { code?: unknown }).code + return typeof code === 'string' || typeof code === 'number' ? code : undefined +} + +function errorStderr(error: unknown): string { + if (typeof error !== 'object' || error === null || !('stderr' in error)) return '' + const stderr = (error as { stderr?: unknown }).stderr + return typeof stderr === 'string' ? stderr : '' +} + +function isMissingCommand(error: unknown): boolean { + return errorCode(error) === 'ENOENT' +} + +function rethrowIfAborted(signal: AbortSignal, error: unknown): void { + if (signal.aborted) throw error +} + +/** + * Open the platform directory picker. + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - platform and runner seam for deterministic tests. + * @returns the selected path, or null when the user cancels. + */ +export async function pickNativeDirectory( + signal: AbortSignal, + internals: DirectoryPickerInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const run = internals.run ?? runCommand + + if (platform === 'darwin') { + try { + const result = await run('osascript', [ + '-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', + '-e', 'POSIX path of selectedFolder', + ], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + if (!signal.aborted && errorCode(error) === 1 + && /(?:User canceled|-128)/i.test(errorStderr(error))) return null + throw error + } + } + + if (platform === 'win32') { + const script = [ + "$ErrorActionPreference = 'Stop'", + 'Add-Type -AssemblyName System.Windows.Forms', + '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', + "$dialog.Description = 'Select Workspace Directory'", + '$dialog.ShowNewFolderButton = $true', + '$result = $dialog.ShowDialog()', + 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', + ' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + ' [Console]::WriteLine($dialog.SelectedPath)', + '}', + ].join('; ') + const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) + return outputPath(result.stdout) + } + + if (platform === 'linux') { + try { + const result = await run('zenity', [ + '--file-selection', '--directory', '--title=Select Workspace Directory', + ], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + if (errorCode(error) === 1) return null + if (!isMissingCommand(error)) throw error + } + + try { + const result = await run('kdialog', [ + '--getexistingdirectory', '.', '--title', 'Select Workspace Directory', + ], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + if (errorCode(error) === 1) return null + if (isMissingCommand(error)) { + throw new Error('no supported native directory picker found (install zenity or kdialog)') + } + throw error + } + } + + throw new Error(`native directory picker is unsupported on ${platform}`) +} diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index bbd57cb6dc..873971e338 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -57,6 +57,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + pickDirectory?: (signal: AbortSignal) => Promise, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -96,10 +97,33 @@ async function harness( model: 'test-model', cwd: workspaceRoot, workspaceRoot, + ...pickDirectory === undefined ? {} : { pickDirectory }, }) return { api, ctx, storageDomain, workspaceRoot } } +describe('host.pickDirectory', () => { + it('returns a selected path or explicit cancellation from the injected native boundary', async () => { + const selected = await harness(undefined, async () => '/tmp/project') + expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: '/tmp/project' } }) + + const cancelled = await harness(undefined, async () => null) + expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: null } }) + }) + + it('propagates abort into the native boundary as a cancelled RPC error', async () => { + const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + })) + const abort = new AbortController() + const pending = api.host.pickDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) + describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() @@ -131,6 +155,13 @@ describe('workspace.create', () => { expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } }) + expectOk(await api.workspace.rename(request({ + workspaceId: first.workspace.workspaceId, + title: 'renamed-existing', + }))) + const reopened = expectOk(await api.workspace.create(request({ path: existing }))) + expect(reopened.workspace.title).toBe('renamed-existing') + const missing = join(workspaceRoot, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) @@ -141,6 +172,20 @@ describe('workspace.create', () => { expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) } }) + + it('rejects different paths that derive the same Workspace title', async () => { + const { api, workspaceRoot } = await harness() + const first = join(workspaceRoot, 'one', 'project') + const second = join(workspaceRoot, 'two', 'project') + mkdirSync(first, { recursive: true }) + mkdirSync(second, { recursive: true }) + expectOk(await api.workspace.create(request({ path: first }))) + const conflict = await api.workspace.create(request({ path: second })) + expect(conflict.result).toMatchObject({ + ok: false, + error: { code: 'workspace-name-conflict', details: { name: 'project' } }, + }) + }) }) describe('session creation and Workspace membership', () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 38ad7a52c9..2cf82f75b2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -35,7 +35,11 @@ function scriptedApi(overrides: { cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, }, - host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, + host: { + describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), + pickDirectory: r => ok(r, { path: null }), + ...overrides.host, + }, workspace: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3978b2cfe4..f90fd72e8a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -47,6 +47,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async describe(request) { return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, + async pickDirectory(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } + }, }, workspace: { async list(request) { @@ -108,8 +111,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra } } -function client(api: ApiProxy = fakeApi()): InProcessApiClient { - return new InProcessApiClient(toFetchHandler(api)) +function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient { + return new InProcessApiClient(toFetchHandler(api), timeoutMs) } async function collect(stream: AsyncIterable>): Promise[]> { @@ -145,6 +148,16 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) + it('round-trips the native picker without the default unary timeout', async () => { + const api = fakeApi() + api.host.pickDirectory = async (request) => { + await new Promise(resolve => setTimeout(resolve, 15)) + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } } + } + const response = await client(api, 1).host.pickDirectory({}) + expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) @@ -170,6 +183,30 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(parsed.rpcId).toBe('r-sig') expect(parsed.result.error?.code).toBe('cancelled') }) + + it('propagates the carrier Request signal into host.pickDirectory', async () => { + const api = fakeApi() + api.host.pickDirectory = async (request, signal) => { + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return { + rpcId: request.rpcId, + result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, + } + } + const handler = toFetchHandler(api) + const controller = new AbortController() + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) + const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { + method: 'POST', body, signal: controller.signal, + })) + controller.abort() + const parsed = await (await pending).json() as { result: { error?: { code: string } } } + expect(parsed.result.error?.code).toBe('cancelled') + }) }) describe('handler carrier-layer statuses', () => { diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/apiproxy/tests/native-directory-picker.spec.ts new file mode 100644 index 0000000000..783a67a04b --- /dev/null +++ b/packages/host/apiproxy/tests/native-directory-picker.spec.ts @@ -0,0 +1,139 @@ +type ExecFileCallback = ( + error: (Error & { code?: string | number }) | null, + stdout: string, + stderr: string, +) => void +type ExecFileMock = ( + command: string, + args: readonly string[], + options: { encoding: string; signal: AbortSignal; windowsHide: boolean }, + callback: ExecFileCallback, +) => void + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { describe, expect, it, vi } from 'vitest' +import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' + +function failure(code: string | number, stderr = ''): Error { + return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr }) +} + +const signal = () => new AbortController().signal + +describe('native directory picker', () => { + it('uses the macOS folder chooser and maps user cancellation to null', async () => { + const run = vi.fn(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/') + expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal)) + + run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)')) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull() + + run.mockRejectedValueOnce(failure(2, 'permission denied')) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed') + }) + + it.each([ + ['a primitive error', 'failed'], + ['an invalid code type', { code: true }], + ['a missing stderr property', { code: 1 }], + ['a non-string stderr property', { code: 1, stderr: 42 }], + ])('does not mistake %s for macOS cancellation', async (_label, reason) => { + const run = vi.fn(async () => { throw reason }) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) + }) + + it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { + const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project') + expect(run).toHaveBeenCalledWith( + 'powershell.exe', + expect.arrayContaining(['-NoProfile', '-STA', '-Command']), + expect.any(AbortSignal), + ) + expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'") + run.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() + run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') + }) + + it('runs the default command adapter without a shell and preserves command failures', async () => { + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'C:\\work\\default\r\n', '') + }) + await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') + const [command, args, options] = execFileMock.mock.calls[0]! + expect(command).toBe('powershell.exe') + expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(options.encoding).toBe('utf8') + expect(options.windowsHide).toBe(true) + expect(options.signal).toBeInstanceOf(AbortSignal) + + const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(commandError, 'partial output', 'failure details') + }) + await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({ + message: 'powershell failed', cause: commandError, code: 7, + stdout: 'partial output', stderr: 'failure details', + }) + }) + + it('uses the current process platform when no platform override is supplied', async () => { + const run = vi.fn(async () => ({ stdout: '/default/platform\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform') + }) + + it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { + const run = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project') + expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog']) + + const zenity = vi.fn(async () => ({ stdout: '/home/test/direct\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity })) + .resolves.toBe('/home/test/direct') + expect(zenity).toHaveBeenCalledOnce() + }) + + it('maps Linux cancellation to null and reports a missing desktop picker', async () => { + const cancelled = vi.fn(async () => { throw failure(1) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull() + + const missing = vi.fn(async () => { throw failure('ENOENT') }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing })) + .rejects.toThrow('install zenity or kdialog') + + const kdialogCancelled = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(1)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled })) + .resolves.toBeNull() + + const zenityFailed = vi.fn(async () => { throw failure(2) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed })) + .rejects.toThrow('command failed') + + const kdialogFailed = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(2)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed })) + .rejects.toThrow('command failed') + }) + + it('does not convert caller aborts into user cancellation', async () => { + const abort = new AbortController() + abort.abort(new Error('closed')) + const run = vi.fn(async () => { throw failure('ABORT_ERR') }) + await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed') + }) + + it('reports unsupported platforms', async () => { + await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix') + }) +})