diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml index 53ae33271d..f0999e1aa3 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.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 .agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md -2026-08-18-settings-font-size-control.md: 34ee7f28f58a0a5a5dc8cd39bc4cecf5e081359f -2026-08-18-settings-font-size-control.zh.md: 2c2ea714d50ca1f934d78ede6175935338488331 +2026-08-18-settings-font-size-control.md: 778893a2c0943f1d3af8905a27efb526071e073b +2026-08-18-settings-font-size-control.zh.md: 71ff3b556622e7091891f8e18f5eefcbda2b0851 diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md index 34ee7f28f5..778893a2c0 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md @@ -14,7 +14,7 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde **Presentation rides the existing snapshot pipeline.** The service never touches the DOM: ui-layout's `ThemePresenter` writes `--dsh-content-font-size` on `body` from each snapshot (and retracts it on dispose), and the Host boot script embeds the durable value in the index response so first paint uses the chosen size — the same pre-plugin path the dark-mode attribute takes, avoiding a font-size flash. -**One CSS delta variable moves the ladder.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Table, small, and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder read `var(--dsh-content-font-size, 14px)` (or `calc( + var(--dsh-content-font-delta, 0px))` for smaller steps) and `calc( + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (reference summaries and their inline glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), and the flow chrome around them — the shared DisclosureRow header (tool calls, think, commands; row height, title, and leading box all move) with its expanded bodies' `22px + delta` indent keeping content aligned under the shifted title start, ToolRow/bash-row summaries and file links, think text (12px keeping its 2px step under the body), compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel (run/phase headers and expanded member rows), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The 14px fallbacks keep every surface pixel-identical when the variable is absent (tests, storybook-like mounts, remote compositions before adoption). +**One CSS delta variable moves the ladder, plus a derived secondary tier.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Secondary-tier text — one step under the body — reads `--dsh-content-font-size-secondary: min(setting − 1px, max(13px, setting − 2px))`: setting −1 at ≤14, setting −2 above (12→11, 13→12, 14→13, 15→13, 16→14, 17→15), with `--dsh-content-font-delta-secondary` (secondary − 13px) moving its line heights. The secondary tier covers the markdown table variants, the shared DisclosureRow title (tool calls, think, commands), ToolRow/bash-row summaries and file links, think text, compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel headers/counts/statuses, the turn-status clock, reference summaries, and the note-trigger label. Small and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder at the body size read `var(--dsh-content-font-size, 14px)` and `calc( + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (inline reference glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), the compaction/DisclosureRow geometry (row height, leading box, expanded bodies' `22px + delta` indent), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The fallbacks (14px body, 13px secondary) keep every surface pixel-identical when the variables are absent (tests, storybook-like mounts, remote compositions before adoption). **The stepper is a pill, not a menu.** The row reuses the selector-pill geometry (h36 r18 module fill) with the value centered in the pill, the up/down arrow column revealed on hover/focus-within and absolutely anchored to the pill's right edge (so revealing never moves the value), and a `px` unit label after the pill. A tertiary description line under the title states the scope — the size only affects conversation content, not the application chrome. Arrows disable at the bounds; the display follows the store mirror, never the click echo — the same store/face pattern as the Appearance row. @@ -24,8 +24,8 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde **Scaling via a multiplier (`em`/percentage) instead of a px delta.** Rejected: multiplying spreads the 12–17px range disproportionately across the ladder (21px h1 would swing ~18–25.5px) and produces fractional line heights; the fixed px shift keeps every step integer and the hierarchy's px gaps intact. -**Scaling every font token (tables, code, small).** Rejected: those variants are secondary/dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. +**Scaling every font token (small, code).** Rejected: those variants are dense by design; at −2 the small ladder would hit 10px and code 9px, below legibility. The table variants instead join the secondary tier, bottoming out at 11px at the 12px setting — the same size think text reaches there. ## Consequences -The 0.875 markdown-ladder rescale (body 16 → 14) ships with this change as the new default rendering; at delta 0 every axis consumer is pixel-identical to that rescaled baseline, and surfaces without the variable fall back to the same 14px. A changed size persists in `$DSH_HOME/settings.yaml`, survives reloads without flashing (the boot script writes the durable value pre-hydration and `ThemeRuntime` seeds its initial snapshot from it), applies live across transcript and composer, and remote browsers keep the process-local-selection rule the theme preference already has. `setFontSize` joins the model-visible cordis client API catalog beside `setTheme`. +The 0.875 markdown-ladder rescale (body 16 → 14) ships with this change as the new default rendering; at the default setting the body-size consumers match that rescaled baseline, and the secondary tier renders at 13px (one step down — a deliberate demotion of the flow-row titles and summaries that previously sat at the body size). Surfaces without the variables fall back to the same defaults. A changed size persists in `$DSH_HOME/settings.yaml`, survives reloads without flashing (the boot script writes the durable value pre-hydration and `ThemeRuntime` seeds its initial snapshot from it), applies live across transcript and composer, and remote browsers keep the process-local-selection rule the theme preference already has. `setFontSize` joins the model-visible cordis client API catalog beside `setTheme`. diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md index 2c2ea714d5..71ff3b5566 100644 --- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md @@ -14,7 +14,7 @@ **呈现走既有快照管线。**服务绝不接触 DOM:ui-layout 的 `ThemePresenter` 依据每份快照在 `body` 上写 `--dsh-content-font-size`(dispose 时收回),Host 引导脚本把持久化值嵌入 index 响应,让首帧就使用所选字号——与暗色属性同一条插件前路径,避免字号闪变。 -**一个 CSS 增量变量平移阶梯。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。表格、small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外的消费方直接读取 `var(--dsh-content-font-size, 14px)`(较小档位则用 `calc(<自身默认> + var(--dsh-content-font-delta, 0px))`)与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含引用摘要及其行内字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量),以及围绕它们的流内 chrome——共享的 DisclosureRow 头部(工具调用、think、命令;行高、标题与 leading 盒同步移动),其展开内容以 `22px + delta` 缩进跟随平移后的标题起点保持对齐、ToolRow/bash 行的 summary 与文件链接、think 正文(12px,保持比正文小 2px 的层级)、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板(run/phase 头部与展开的成员行)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。14px 回退让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。 +**一个 CSS 增量变量平移阶梯,另派生一个低一档层级。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。低一档文本——比正文低一档——读 `--dsh-content-font-size-secondary: min(设置值 − 1px, max(13px, 设置值 − 2px))`:设置 ≤14 时为设置值 −1,>14 时为设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15),其行高经 `--dsh-content-font-delta-secondary`(低一档值 − 13px)随动。低一档覆盖:markdown 表格变体、共享 DisclosureRow 标题(工具调用、think、命令)、ToolRow/bash 行的 summary 与文件链接、think 正文、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板的头部/计数/状态、回合状态时钟、引用摘要与笔记触发标签。small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外按正文档读取的消费方直接使用 `var(--dsh-content-font-size, 14px)` 与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含行内引用字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量)、compaction/DisclosureRow 的行几何(行高、leading 盒、展开内容的 `22px + delta` 缩进)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。回退值(正文 14px、低一档 13px)让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。 **步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。标题下方的三级说明行标明作用范围——字号仅影响会话内容,不影响应用外框。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。 @@ -24,8 +24,8 @@ **用倍率(`em`/百分比)而非像素增量缩放。**否决:乘法会让 12–17px 的范围在阶梯上不成比例地放大(21px 的 h1 会摆动到约 18–25.5px),并产生小数行高;固定像素平移让每一档都是整数,层级间的像素差保持不变。 -**缩放全部字体 token(表格、code、small)。**否决:这些变体按设计就是次级/密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。 +**缩放全部字体 token(code、small)。**否决:这些变体按设计就是密集文本;−2 档时 small 阶梯会降到 10px、code 降到 9px,低于可读下限。表格变体改为并入低一档层级,在 12px 设置下触底 11px——与 think 文本在该设置下相同。 ## 后果 -0.875 的 markdown 阶梯重缩放(正文 16 → 14)作为新的默认渲染随本变更一同交付;增量为 0 时所有轴消费方与该重缩放基线逐像素一致,无变量的表面回退到同样的 14px。修改后的字号持久化在 `$DSH_HOME/settings.yaml`,重载不闪变(引导脚本在 hydration 前写入持久化值,`ThemeRuntime` 以它为初始快照种子),在转录与 composer 上实时生效;远程浏览器沿用主题偏好既有的进程内选择规则。`setFontSize` 与 `setTheme` 一同进入模型可见的 cordis 客户端 API 目录。 +0.875 的 markdown 阶梯重缩放(正文 16 → 14)作为新的默认渲染随本变更一同交付;默认设置下正文档消费方与该重缩放基线一致,低一档渲染为 13px(低一步——这是对此前按正文档渲染的流内行标题与摘要的有意降档)。无变量的表面回退到同样的默认值。修改后的字号持久化在 `$DSH_HOME/settings.yaml`,重载不闪变(引导脚本在 hydration 前写入持久化值,`ThemeRuntime` 以它为初始快照种子),在转录与 composer 上实时生效;远程浏览器沿用主题偏好既有的进程内选择规则。`setFontSize` 与 `setTheme` 一同进入模型可见的 cordis 客户端 API 目录。 diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0d054a293d..c89df410bc 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -316,7 +316,6 @@ async function bootPreview(origin: string, browser: Browser): Promise { const exercised = await page.evaluate(async () => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewApi { - host: { createDirectory(payload: { path: string; name: string }): Promise> } skills: { list(payload: { sessionId: string }): Promise> } settings: { describe(payload: object): Promise }>> @@ -349,12 +348,26 @@ async function bootPreview(origin: string, browser: Browser): Promise { const sessionId = sessions.result.value.items[0]?.sessionId if (sessionId === undefined) throw new Error('workspace adoption created no Session') + // Remote namespaces answer over the same unary carrier; the args object + // keys every wire parameter by its name. + const remote = async (endpoint: string, args: object): Promise => { + const answered = await transport.fetch(`/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: `preview-${endpoint.replace('/', '-')}`, + method: endpoint, payload: { args }, + }), + }) + const body = await answered.json() as Result + if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) + return body.result.value + } const api = transport.createApiClient() const skills = await api.skills.list({ sessionId }) if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) const createDirectory = async (path: string, name: string): Promise => { - const created = await api.host.createDirectory({ path, name }) - if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`) + await remote('directoryPicker/createDirectory', { path, name }) await new Promise((resolve) => { setTimeout(resolve, 250) }) const refreshed = await api.skills.list({ sessionId }) if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index b36508aa5e..425ff5d1ee 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -351,7 +351,19 @@ describe('web e2e: settings modal and General preferences', () => { const readFontSize = async (target: Page = page): Promise => await target.evaluate( () => document.body.style.getPropertyValue('--dsh-content-font-size'), ) + // The secondary tier resolved by the real engine: a probe element's + // font-size forces min/max/calc evaluation, which the CSS-text specs + // cannot exercise. Setting −1 at ≤14, setting −2 above. + const readSecondaryFontSize = async (): Promise => await page.evaluate(() => { + const probe = document.createElement('div') + probe.style.fontSize = 'var(--dsh-content-font-size-secondary, 13px)' + document.body.appendChild(probe) + const size = getComputedStyle(probe).fontSize + probe.remove() + return size + }) expect(await readFontSize()).toBe('14px') + expect(await readSecondaryFontSize()).toBe('13px') await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) @@ -360,9 +372,13 @@ describe('web e2e: settings modal and General preferences', () => { const increase = dialog.getByRole('button', { name: '增大字号' }) await increase.click() await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 }) + // 15 is the piecewise boundary: the secondary tier holds at 13px (−2) + // where the ≤14 branch would have given 14px (−1). + await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px') await increase.click() await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 }) await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px') await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/) await page.keyboard.press('Escape') @@ -375,6 +391,7 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px') + expect(await readSecondaryFontSize()).toBe('14px') // Restore the default for the specs that follow (and the dialog golden). await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 683f690e37..9d54d834a9 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: 2ee2d1b989c4eb25b245cfd528b2fa1ba3bb0272 -capability-seams.zh.md: 7ecda568ef4df4d360414ee872d2b0241ebe1dce +capability-seams.md: 7cf93600cbf0d2deef586135f299058629732685 +capability-seams.zh.md: 3d79717d811fc3cd4e51c19c34c02a4e51d5da21 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2ee2d1b989..7cf93600cb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,6 +40,7 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -217,6 +218,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments @@ -348,7 +350,7 @@ flowchart LR svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek - svc_directoryPicker --> pkg_host_apiproxy + svc_directoryPicker --> pkg_api_workspace_controller svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b @@ -459,6 +461,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | +| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -506,7 +509,7 @@ flowchart LR | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | -| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`host-apiproxy`](../packages/host/apiproxy) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | +| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7ecda568ef..3d79717d81 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -42,6 +42,7 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -219,6 +220,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_attachment --> svc_attachments pkg_attachment_local --> svc_attachments @@ -350,7 +352,7 @@ flowchart LR svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek - svc_directoryPicker --> pkg_host_apiproxy + svc_directoryPicker --> pkg_api_workspace_controller svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b @@ -461,6 +463,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | +| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -508,7 +511,7 @@ flowchart LR | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | -| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`host-apiproxy`](../packages/host/apiproxy) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | +| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | | `ctx.clientModules` | `core` | [`client-modules`](../packages/client/modules) | - | [`client-hmr`](../packages/client/hmr) | - | 通过增量 `dsh.client` 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1fdb6fb3aa..dbe28fb585 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 8b11d157f15d58c0a2193b51a76bbfb0b877b2ec +config-catalog.md: 7c5361358bf66d02857fe91bd58bcca428c426a6 config-catalog.zh.md: 60ed9270483f1e207e9af9295d050948fcd85518 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8b11d157f1..7c5361358b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -796,7 +796,7 @@ export interface Config { } ``` -Source: [`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index cd54689e1d..edf714af08 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 3a1aa6d3ba0000cf16ac38587d244c44bb49212a -module-graph.zh.md: e83f239ddae34f30d8385e57bbc36454a5901ad9 +module-graph.md: db76f0f64025535b06440cf1b58d037be8085d70 +module-graph.zh.md: fd8e25d450b6e58bfc6a20c9e160858db0e6151a diff --git a/docs/module-graph.md b/docs/module-graph.md index 3a1aa6d3ba..db76f0f640 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1085,6 +1085,7 @@ flowchart TD pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm @@ -1258,6 +1259,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1390,6 +1392,7 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_session pkg_client_ui_sidebar --> pkg_client_ui_workspace pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller pkg_client_ui_workspace --> pkg_client_connection @@ -1401,6 +1404,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_schedule pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes @@ -1428,7 +1432,7 @@ flowchart TD pkg_client_ui_brand_official --> pkg_client_ui_renderer pkg_client_ui_brand_official --> pkg_client_ui_sidebar pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_api_remotes pkg_client_ui_directory_picker_browse --> pkg_client_locale pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace @@ -1864,7 +1868,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1887,7 +1891,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1904,11 +1908,11 @@ flowchart TD | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e83f239dda..fd8e25d450 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1087,6 +1087,7 @@ flowchart TD pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm @@ -1260,6 +1261,7 @@ flowchart TD pkg_api_session_controller --> pkg_workspace pkg_api_workspace_controller --> pkg_api_gateway pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker pkg_api_workspace_controller --> pkg_invariants pkg_api_workspace_controller --> pkg_session pkg_api_workspace_controller --> pkg_storage_domain @@ -1392,6 +1394,7 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_session pkg_client_ui_sidebar --> pkg_client_ui_workspace pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_workspace --> pkg_api_remotes pkg_client_ui_workspace --> pkg_api_session_controller pkg_client_ui_workspace --> pkg_api_workspace_controller pkg_client_ui_workspace --> pkg_client_connection @@ -1403,6 +1406,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_client_ui_workspace --> pkg_schedule pkg_client_ui_workspace --> pkg_session + pkg_client_ui_workspace --> pkg_typert_protocol pkg_client_ui_workspace --> pkg_util_workspace_path pkg_client_ui_agent_preset --> pkg_agent_presets pkg_client_ui_agent_preset --> pkg_api_remotes @@ -1430,7 +1434,7 @@ flowchart TD pkg_client_ui_brand_official --> pkg_client_ui_renderer pkg_client_ui_brand_official --> pkg_client_ui_sidebar pkg_client_ui_brand_official --> pkg_invariants - pkg_client_ui_directory_picker_browse --> pkg_client_connection + pkg_client_ui_directory_picker_browse --> pkg_api_remotes pkg_client_ui_directory_picker_browse --> pkg_client_locale pkg_client_ui_directory_picker_browse --> pkg_client_ui_renderer pkg_client_ui_directory_picker_browse --> pkg_client_ui_workspace @@ -1866,7 +1870,7 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | @@ -1889,7 +1893,7 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | @@ -1906,11 +1910,11 @@ flowchart TD | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`api-workspace-controller`](../packages/api/workspace-controller), [`client-locale`](../packages/client/locale), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index a41ab20b51..074fcdd610 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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 docs/subsystems/workspace.md -workspace.md: af2a8d4c5abbde04764e26751ba178fa99b2b3ef -workspace.zh.md: f54444f052c8faffc7670468d5423123cd3aeeac +workspace.md: 82bbe310d490653bae5d88f41d1fcbe76833b431 +workspace.zh.md: 9bf63b7a90f9136173fbec16b55a22821fa380e5 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index af2a8d4c5a..82bbe310d4 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.directoryPickerController` — `DirectoryPickerController` + +Host service backing the generated `ctx.remote.directoryPicker` namespace. The seam it exports is abstract and therefore never a Loader entry of its own, so this controller carries the wire verbs: one composed backend serves either the native chooser or the browse primitives, and a verb the composition cannot serve is refused rather than approximated. + +```ts cordis-catalog +/** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ +@Remote('pick') async pick(signal: AbortSignal): Promise + +/** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ +@Remote('list') async list(path: string | undefined, signal: AbortSignal): Promise + +/** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ +@Remote('createDirectory') async createDirectory(path: string, name: string): Promise +``` + +Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index f54444f052..9bf63b7a90 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -149,6 +149,40 @@ abstract capability(): DirectoryPickerCapability Source: [`packages/host/directory-picker/src/index.ts`](../../packages/host/directory-picker/src/index.ts) + + +### `ctx.directoryPickerController` — `DirectoryPickerController` + +Host service backing the generated `ctx.remote.directoryPicker` namespace. The seam it exports is abstract and therefore never a Loader entry of its own, so this controller carries the wire verbs: one composed backend serves either the native chooser or the browse primitives, and a verb the composition cannot serve is refused rather than approximated. + +```ts cordis-catalog +/** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ +@Remote('pick') async pick(signal: AbortSignal): Promise + +/** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ +@Remote('list') async list(path: string | undefined, signal: AbortSignal): Promise + +/** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ +@Remote('createDirectory') async createDirectory(path: string, name: string): Promise +``` + +Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/knip.json b/knip.json index e5e331194b..b08fe22a25 100644 --- a/knip.json +++ b/knip.json @@ -76,11 +76,6 @@ "tests/**/*.ts" ] }, - "packages/api/workspace-controller": { - "ignoreDependencies": [ - "zod" - ] - }, "packages/client/ui-approval": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 14be075879..473abfbe88 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -53,7 +53,7 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' */ export type { ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, + CredentialView, DiscoveredModelView, IApiClient, MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 1625ac65e7..98d7196358 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -167,23 +167,9 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -210,9 +196,6 @@ 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)), - listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index eee1d5bd5a..3f837582cb 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/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/api/workspace-controller/README.md -README.md: d2f89e6c9f0118be9150650c1c6dec859375f990 -README.zh.md: 3411ca0642df7fa7f8652d1863905868f54cf4bd +README.md: 731a6331e2a19991921c022759f6bbd971cef525 +README.zh.md: f46c78b7b300f26983eee94d3dfdb488d236116e diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index d2f89e6c9f..731a6331e2 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. +`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. The package also owns `ctx.directoryPickerController` and the generated `ctx.remote.directoryPicker` namespace, because the directory-picking seam it carries is abstract and never a Loader entry of its own. ## Table of Contents diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 3411ca0642..f46c78b7b3 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。本包同时拥有 `ctx.directoryPickerController` 与生成的 `ctx.remote.directoryPicker` namespace,因为它承载的选目录 seam 是抽象的,自身从不作为 Loader entry。 ## 目录 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 29245fef86..bc045f38c7 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -76,6 +76,7 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", @@ -87,6 +88,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/api/workspace-controller/src/directory-picker.ts b/packages/api/workspace-controller/src/directory-picker.ts new file mode 100644 index 0000000000..ee58ef7b83 --- /dev/null +++ b/packages/api/workspace-controller/src/directory-picker.ts @@ -0,0 +1,171 @@ +/** + * Host directory-picking Remote owner: capability gating, cancellation, and the + * stable wire failure vocabulary over the `ctx.directoryPicker` seam. + */ + +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker' +// The seam owns the listing declaration; the generator requires the reference +// site to name that package rather than this package's re-export of it. +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { DirectoryPickerErrorDetailsMap } from './types.ts' + +const createDirectoryRequestSchema = z.object({ + path: z.string(), + name: z.string(), +}).refine( + request => request.name.trim() !== '' && request.name !== '.' && request.name !== '..' + && !/[/\\]/.test(request.name), + { message: 'host.createDirectory requires a single non-blank path segment name' }, +) + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host directory-picking Remote namespace owner. */ + directoryPickerController: DirectoryPickerController + } +} + +/** + * Host service backing the generated `ctx.remote.directoryPicker` namespace. The + * seam it exports is abstract and therefore never a Loader entry of its own, so + * this controller carries the wire verbs: one composed backend serves either the + * native chooser or the browse primitives, and a verb the composition cannot + * serve is refused rather than approximated. + */ +export class DirectoryPickerController extends TypertRemoteService { + static inject = ['directoryPicker'] + + /** @param ctx - Host context carrying the composed directory-picking backend. */ + constructor(ctx: Context) { + super(ctx, 'directoryPickerController', { namespace: 'directoryPicker' }) + } + + /** + * Open the host's OS chooser for a Remote caller. + * @param signal - caller lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ + @Remote('pick') + async pick(signal: AbortSignal): Promise { + const capability = this.requireCapability('native', 'pick') + try { + return await capability.pick(signal) + } catch (error: unknown) { + throw cancellableFailure(error, signal, 'directory picker was aborted', 'directory picker failed') + } + } + + /** + * List one directory level for a Remote caller's in-app browser. + * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the backend's scan instead of + * letting it outlive a disconnected caller. + * @returns the level's listing with its ancestry. + */ + @Remote('list') + async list(path: string | undefined, signal: AbortSignal): Promise { + const capability = this.requireCapability('browse', 'list') + try { + return await capability.list(path, signal) + } catch (error: unknown) { + throw cancellableFailure(error, signal, 'directory listing was aborted') + } + } + + /** + * Create one child directory for a Remote caller's in-app browser. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + @Remote('createDirectory') + async createDirectory(path: string, name: string): Promise { + const request = createDirectoryRequestSchema.safeParse({ path, name }) + if (!request.success) { + throw pickerFailureOf( + 'bad-request', + 'invalid payload for host.createDirectory', + { issues: request.error.issues }, + ) + } + const capability = this.requireCapability('browse', 'createDirectory') + try { + return await capability.createDirectory(request.data.path, request.data.name) + } catch (error: unknown) { + throw browseFailure(error) + } + } + + /** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */ + private requireCapability( + kind: Kind, + method: string, + ): DirectoryPickerCapabilities[Kind] { + const capability = this.ctx.directoryPicker.capability() + if (capability.kind !== kind) { + throw pickerFailureOf( + 'directory-picker-unavailable', + `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`, + { capability: capability.kind }, + ) + } + return capability as DirectoryPickerCapabilities[Kind] + } +} + +/** + * Raise one entry of the picking wire failure vocabulary. + * @param code - the failure code a caller discriminates on. + * @param message - operator-facing description. + * @param details - the payload this code carries. + * @returns the failure to throw across the Remote boundary. + */ +function pickerFailureOf( + code: Code, + message: string, + details: DirectoryPickerErrorDetailsMap[Code], +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +/** + * Classify a browse-primitive rejection: the seam's own closed codes carry the + * path they are about, and anything else stays an infrastructure failure. + * @param error - the primitive's rejection. + * @returns the failure to throw across the Remote boundary. + */ +function browseFailure(error: unknown): TypertRemoteFailure { + if (error instanceof DirectoryPickerError) { + return pickerFailureOf(error.code, error.message, { path: error.path }) + } + return pickerFailureOf('internal', errorMessage(error), {}) +} + +/** + * Classify a cancellable primitive's rejection. An abort is the caller's own + * timeout or disconnect, not a backend failure, so it answers `cancelled` + * before the business classification runs. + * @param error - the primitive's rejection. + * @param signal - the caller lifetime the primitive ran under. + * @param cancelled - operator-facing text for the abort outcome. + * @param failed - prefix for a non-seam failure, when the verb has no closed codes. + * @returns the failure to throw across the Remote boundary. + */ +function cancellableFailure( + error: unknown, + signal: AbortSignal, + cancelled: string, + failed?: string, +): TypertRemoteFailure { + if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {}) + if (failed === undefined) return browseFailure(error) + return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {}) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0fbd514cd5..40ab0647f2 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' +import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed } from './feed.ts' import type { WorkspaceArchiveSessionRequest, @@ -20,6 +21,7 @@ import type { } from './types.ts' export type * from './types.ts' +export { DirectoryPickerController } from './directory-picker.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -40,6 +42,11 @@ export class WorkspaceController extends TypertRemoteService { super(ctx, 'workspaceController', { namespace: 'workspace' }) this.commands = new WorkspaceCommands(ctx) this.feed = new WorkspaceFeed(ctx) + // This package is the Loader entry for both Remote owners it hosts: the + // directory-picking seam is abstract and never an entry itself. The child + // stays pending until a picking backend is composed, so a host without one + // registers no picking namespace instead of answering an unservable verb. + ctx.plugin(DirectoryPickerController) } /** diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f530e2ef12..552a9e20c3 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -1,9 +1,18 @@ -/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */ +/** + * Browser-safe request, result, and state-stream vocabulary for the Workspace + * and directory-picking Remote namespaces this package owns. The picking seam + * declares its own listing types, so they are re-exported here rather than + * restated: a browser consumer reads the very declaration the backend answers. + */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { z as zCore } from 'zod' + +type ZodIssue = zCore.core.$ZodIssue export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' /** One durable Workspace projected for browser consumers. */ export interface WorkspaceView { @@ -43,6 +52,24 @@ export type WorkspaceError = { } }[keyof WorkspaceErrorDetailsMap] +/** Stable directory-picking failure details returned by the picking wire verbs. */ +export interface DirectoryPickerErrorDetailsMap { + /** The directory creation request violates its semantic input constraints. */ + 'bad-request': { readonly issues: ZodIssue[] } + /** The verb needs an interaction the composed backend does not serve. */ + 'directory-picker-unavailable': { readonly capability: string } + /** The target is not fully qualified, or the backend cannot list it. */ + 'directory-unreadable': { readonly path: string } + /** A child of that name is already there. */ + 'directory-exists': { readonly path: string } + /** The parent is not fully qualified, the name is not one segment, or creation failed. */ + 'directory-create-failed': { readonly path: string } + /** The caller's own timeout or disconnect ended the chooser or the scan. */ + cancelled: Record + /** A backend failure with no seam code of its own. */ + internal: Record +} + /** Existing directory requested for Workspace adoption. */ export interface WorkspaceCreateRequest { readonly path: string diff --git a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts new file mode 100644 index 0000000000..32fd2dd6a1 --- /dev/null +++ b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { DirectoryPickerController } from '../src/directory-picker.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +/** A backend serving exactly the capability one case is about. */ +class StubPicker extends DirectoryPicker { + static capabilityStub: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + + capability(): DirectoryPickerCapability { + return StubPicker.capabilityStub + } +} + +const NATIVE_STUB: DirectoryPickerCapability = { kind: 'native', pick: async () => null } + +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') { + throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + } + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') { + throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + } + if (name === 'unwritable') throw new Error('disk detached') + if (name === 'gone') throw 'the volume vanished' + return `${path}/${name}` + }, +} + +async function harness(capability: DirectoryPickerCapability = NATIVE_STUB) { + StubPicker.capabilityStub = capability + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(StubPicker).await() + return new DirectoryPickerController(ctx) +} + +/** The failure payload a refused wire verb carries. */ +async function refused(call: Promise): Promise<{ code: string; message: string; details: object }> { + try { + await call + } catch (error: unknown) { + if (!(error instanceof TypertRemoteFailure)) throw error + return { ...error.failure } + } + throw new Error('the call was expected to be refused') +} + +describe('directoryPicker pick Remote', () => { + it('answers the selected path or the operator\'s cancellation', async () => { + const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' }) + expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project') + + const cancelled = await harness(NATIVE_STUB) + expect(await cancelled.pick(new AbortController().signal)).toBeNull() + }) + + it('reports an aborted chooser as cancelled and any other failure as internal', async () => { + const picker = await harness({ + kind: 'native', + pick: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) + const abort = new AbortController() + const pending = refused(picker.pick(abort.signal)) + abort.abort() + expect((await pending).code).toBe('cancelled') + + const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) + const failure = await refused(broken.pick(new AbortController().signal)) + expect(failure.code).toBe('internal') + expect(failure.message).toContain('no chooser installed') + }) + + it('refuses the native verb under a browse composition', async () => { + const picker = await harness(BROWSE_STUB) + const failure = await refused(picker.pick(new AbortController().signal)) + expect(failure.code).toBe('directory-picker-unavailable') + expect(failure.message).toContain('needs the native capability') + expect(failure.details).toEqual({ capability: 'browse' }) + }) +}) + +describe('directoryPicker browse Remotes', () => { + it('serves listings and creation, defaulting to the home directory', async () => { + const picker = await harness(BROWSE_STUB) + const signal = new AbortController().signal + expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' }) + expect(await picker.list('/home/user/projects', signal)) + .toMatchObject({ path: '/home/user/projects' }) + expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh') + }) + + it('maps the seam\'s typed failures and folds unknown throws to internal', async () => { + const picker = await harness(BROWSE_STUB) + expect(await refused(picker.list('/denied', new AbortController().signal))) + .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } }) + expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists') + expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal') + + const thrown = await refused(picker.createDirectory('/home/user', 'gone')) + expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' }) + }) + + it('rejects invalid child names before capability dispatch', async () => { + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + const picker = await harness({ + kind: 'browse', + list: (path, signal) => BROWSE_STUB.list(path, signal), + createDirectory, + }) + + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + const failure = await refused(picker.createDirectory('/home/user', name)) + expect(failure).toMatchObject({ + code: 'bad-request', + message: 'invalid payload for host.createDirectory', + }) + expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true) + } + expect(createDirectory).not.toHaveBeenCalled() + }) + + it('reports an aborted listing as cancelled', async () => { + const picker = await harness({ + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = refused(picker.list(undefined, abort.signal)) + abort.abort() + expect((await pending).code).toBe('cancelled') + }) + + it('refuses the browse verbs under a native composition', async () => { + const picker = await harness() + expect(await refused(picker.list(undefined, new AbortController().signal))) + .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + expect(await refused(picker.createDirectory('/x', 'y'))) + .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } }) + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json index 46d392dff3..9979b769f6 100644 --- a/packages/api/workspace-controller/tsconfig.client.json +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -17,6 +17,7 @@ { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../core/session" }, + { "path": "../../host/directory-picker" }, { "path": "../../typert/protocol" }, { "path": "../../workspace/workspace" } ] diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 76c584fd01..4b98892781 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -10,11 +10,13 @@ "src/invariant.ts", "src/types.ts", "src/commands.ts", + "src/directory-picker.ts", "src/feed.ts" ], "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../core/session" }, + { "path": "../../host/directory-picker" }, { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 5c7e53bb70..395e443ea3 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 3a3c78dfa5..113e72212c 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,6 @@ export type { ApiProxy, HostApi, - DirectoryEntry, DirectoryListing, ResponseValue, SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f3a20282ed..c3bb6b503d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -29,6 +29,7 @@ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' +import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { ApiProxy, ClientRequest, @@ -2178,6 +2179,55 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } + /** + * Canonical fixture implementation of the generated Directory Picker Remote + * contract. The pick is deterministic — the keyless lanes drive the full + * pick-then-adopt path without an OS chooser — over the same design-mock + * tree the browse primitives serve. + */ + const directoryPickerRemotes = { + pick(): ConnectionRpcResult { + return { ok: true, value: `${FIXTURE_HOME}/Documents/project` } + }, + list(path?: string): ConnectionRpcResult { + const target = path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return { + ok: false, + error: { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }, + } + } + return { + ok: true, + value: { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, + }, + } + }, + createDirectory(parent: string, name: string): ConnectionRpcResult { + const children = childrenOf(parent) + if (children === undefined) { + return { ok: false, error: { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } } } + } + // Same root special case as list's entry paths: a plain join under '/' + // would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${name}` : `${parent}/${name}` + if (children.includes(name)) { + return { ok: false, error: { code: 'directory-exists', message: `${target} already exists`, details: { path: target } } } + } + directoryTree.set(parent, [...children, name]) + directoryTree.set(target, []) + return { ok: true, value: target } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -3264,42 +3314,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), - // Deterministic native pick: the keyless lanes drive the full - // pick-then-adopt path without an OS chooser (design-mock content, - // same tree the browse primitives serve). - pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), - listDirectory: (request) => { - const target = request.payload.path ?? FIXTURE_HOME - const children = childrenOf(target) - if (children === undefined) { - return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }) - } - return ok(request, { - path: target, - home: FIXTURE_HOME, - crumbs: crumbsOf(target), - entries: [...children].sort((a, b) => a.localeCompare(b)) - .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), - // The fixture tree is tiny; no level ever reaches a backend bound. - truncated: false, - }) - }, - createDirectory: (request) => { - const parent = request.payload.path - const children = childrenOf(parent) - if (children === undefined) { - return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) - } - // Same root special case as listDirectory's entry paths: a plain join - // under '/' would mint '//name' and fork the tree's identity. - const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` - if (children.includes(request.payload.name)) { - return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) - } - directoryTree.set(parent, [...children, request.payload.name]) - directoryTree.set(target, []) - return ok(request, { path: target }) - }, openPath: request => ok(request, { opened: true as const }), }, agentPresets: { @@ -3425,6 +3439,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { agentId: SessionId line?: string query?: string + path?: string + name?: string images?: readonly unknown[] ref?: { id: string; revision: number } agentPreset?: string @@ -3442,6 +3458,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) + case 'directoryPicker/pick': return Promise.resolve(directoryPickerRemotes.pick()) + case 'directoryPicker/list': return Promise.resolve(directoryPickerRemotes.list(args.path)) + case 'directoryPicker/createDirectory': + return Promise.resolve(directoryPickerRemotes.createDirectory(args.path ?? '', args.name ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: (request as { objective?: string } | undefined)?.objective as string, ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined @@ -3596,9 +3616,6 @@ export class FixtureApiClient extends AbstractApiClient { ): Promise> { switch (method) { case 'host.describe': return this.api.host.describe(request) - case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) - case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) - case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 358b2f41cc..8136254f1c 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -31,7 +31,6 @@ declare module '@deepseek-ai/cordis' { // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, HostApi, - DirectoryEntry, DirectoryListing, SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 93ab3af3b0..c4bd0d03ff 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -50,30 +50,13 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onPickDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) - onListDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) - - onCreateDirectory: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly generationConns: StreamConn[] = [] readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), - listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)), - createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index f17af5b978..b69d775510 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -17,8 +17,9 @@ import { type FixtureOptions, } from '../src/client/fixture.ts' import type { - ClientConnectionRpc, + ClientConnectionRpc, ConnectionRpcResult, } from '../src/rpc.ts' +import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -286,6 +287,12 @@ interface FixtureRemoteEventStream extends AsyncIterable['api'] & { + /** The directory-picking Remote namespace as the fixture serves it. */ + readonly directoryPickerRemote: { + pick: () => Promise> + list: (path?: string) => Promise> + createDirectory: (path: string, name: string) => Promise> + } readonly sessions: FixtureSessionApi readonly sessionRemote: FixtureSessionRemote readonly workspace: FixtureWorkspaceApi @@ -298,6 +305,15 @@ type FixtureTestApi = ReturnType['api'] & { function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { const { api, rpc } = createFixtureFaces(options) return Object.assign(api, { + directoryPickerRemote: { + pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as + Promise>, + list: (path?: string) => rpc.call('/api', 'directoryPicker/list', { args: { path } }) as + Promise>, + createDirectory: (path: string, name: string) => + rpc.call('/api', 'directoryPicker/createDirectory', { args: { path, name } }) as + Promise>, + }, sessions: createSessionApi(rpc), sessionRemote: createSessionRemote(rpc), workspace: createWorkspaceApi(rpc), @@ -1049,18 +1065,18 @@ describe('createFixtureApi', () => { it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() - const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) - if (!created.result.ok) throw new Error('create failed') - expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.crumbs).toEqual([ + const created = await api.directoryPickerRemote.createDirectory('/', 'srv') + if (!created.ok) throw new Error('create failed') + expect(created.value).toBe('/srv') + const listed = await api.directoryPickerRemote.list('/srv') + if (!listed.ok) throw new Error('list failed') + expect(listed.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) - if (!root.result.ok) throw new Error('root list failed') - expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + const root = await api.directoryPickerRemote.list('/') + if (!root.ok) throw new Error('root list failed') + expect(root.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 1b45ecc014..2e267ac966 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -35,11 +35,11 @@ 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: {}, + type: 'client-request', rpcId: 'picker-1', method: 'directoryPicker/pick', payload: { args: {} }, }) const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage Object.assign(request, { - url: '/api/host.pickDirectory', + url: '/api/directoryPicker/pick', method: 'POST', headers: { 'content-type': 'application/json' }, }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index e1cb7abe83..1cead76b09 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -174,7 +174,7 @@ describe('connection node half', () => { it('requires the same browser session for every method on every trusted authority', async () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ - 'host.pickDirectory', 'host.openPath', + 'host.openPath', 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', ] @@ -503,7 +503,7 @@ describe('connection node half over a real HTTP server', () => { const methods = [ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', - 'host.pickDirectory', 'host.openPath', + 'host.openPath', 'llm.discoverModels', 'agentPreset.openDocument', 'llm.providers', 'llm.models', diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index c40e2cbadb..cb409edde0 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -36,6 +36,9 @@ { "path": "../../host/apiproxy" }, + { + "path": "../../host/directory-picker" + }, { "path": "../../interaction/commands" }, diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index f2f5a3a908..502ae295be 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -101,8 +101,8 @@ .turnStatusClock { margin-left: 8px; font: var(--dsw-font-xs-13); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); font-weight: 400; font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-caption); @@ -125,14 +125,14 @@ .hint { color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .openError { color: var(--dsw-alias-state-error-primary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .older { diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css index e9173a078d..788322535c 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.module.css @@ -29,7 +29,7 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; @@ -41,7 +41,7 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css index 075029de01..49297685ae 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.module.css @@ -55,7 +55,7 @@ overflow: hidden; flex: 1 1 auto; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 1a81c35285..7c616f4a0e 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -42,8 +42,8 @@ .referenceSummary { color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .contextRow { padding: 2px 0; @@ -120,7 +120,7 @@ .compactionTitle { flex: none; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-primary-dimmed); } @@ -139,7 +139,7 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; @@ -149,14 +149,14 @@ /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); } .retryRow { color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); } .retrySummary { @@ -227,8 +227,8 @@ margin-top: 3px; padding-left: 14px; overflow-wrap: anywhere; - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); } .retryDetailLabel { @@ -241,8 +241,8 @@ gap: 8px; align-items: start; padding: 2px 0; - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); } .turnErrorDot { diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css index 67cfbf737a..2d7cbe8f07 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.module.css @@ -50,15 +50,16 @@ background: var(--dsw-alias-label-caption); } -/* Think text sits one 2px step under the body size and follows the Settings - font-size preference through the same px delta, keeping the step. */ +/* Think text reads the secondary tier — one step under the body size + (setting −1 at ≤14, setting −2 above), shared with every flow-row title + and summary. */ .summary { min-width: 0; overflow: hidden; flex: 1 1 auto; color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; white-space: nowrap; } @@ -71,8 +72,8 @@ /* Indent under the title, which starts at leading (16 + delta) + gap 6. */ padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: pre-wrap; word-break: break-word; } diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.module.css b/packages/client/ui-chat/src/client/chat/StatsLine.module.css index b2d7989cde..29c0bc3aab 100644 --- a/packages/client/ui-chat/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-chat/src/client/chat/StatsLine.module.css @@ -1,5 +1,6 @@ -/* Session stats row: 12/20 tertiary text under the flow, aligned to the - shared message column axis (--dsh-chat-content-width). */ +/* Session stats row: 13/20 tertiary text (the secondary content tier) under + the flow, aligned to the shared message column axis + (--dsh-chat-content-width). */ .root { /* Block, not flex: text-overflow only elides a block's inline content, so @@ -11,8 +12,8 @@ margin: 0 auto; box-sizing: border-box; padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px; - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; diff --git a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css index b0a8dbad02..ba96155504 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css @@ -29,9 +29,9 @@ min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: 14px; + font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums; - line-height: 24px; + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index 6f94a95ab3..d0b8c9cb3a 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -19,25 +19,25 @@ function declarationsFrom(source: string, selector: string): string[] { } describe('chat flow font-size axis', () => { - it('think text keeps its 2px step under the body size and moves with the delta', () => { + it('think text reads the secondary tier (one step under the body size)', () => { const css = read('ReasoningRow.module.css') for (const selector of ['.summary', '.thinkBody']) { expect(declarationsFrom(css, selector)).toEqual(expect.arrayContaining([ - 'font-size: calc(12px + var(--dsh-content-font-delta, 0px))', - 'line-height: calc(20px + var(--dsh-content-font-delta, 0px))', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))', ])) } }) - it('command and context summaries ride the axis at the body size', () => { + it('command and context summaries read the secondary tier on the shared row line', () => { expect(declarationsFrom(read('GenericCommandCard.module.css'), '.summary')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) const context = read('ContextInjectionRow.module.css') for (const selector of ['.source', '.summary']) { expect(declarationsFrom(context, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) } @@ -60,7 +60,7 @@ describe('chat flow font-size axis', () => { const css = read('MessageItem.module.css') for (const selector of ['.compactionTitle', '.compactionSummary', '.compactionBody']) { expect(declarationsFrom(css, selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) } diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 9c134a13ff..2d0003c1e8 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -32,7 +32,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-locale" @@ -49,7 +49,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", @@ -57,7 +57,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index 571ab4f2a9..d96e513c34 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -40,7 +40,7 @@ import { Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import css from './DirectoryBrowser.module.css' diff --git a/packages/client/ui-directory-picker-browse/src/client/flow.ts b/packages/client/ui-directory-picker-browse/src/client/flow.ts index dbe80e9415..e532eaf762 100644 --- a/packages/client/ui-directory-picker-browse/src/client/flow.ts +++ b/packages/client/ui-directory-picker-browse/src/client/flow.ts @@ -5,7 +5,7 @@ */ import { createElement } from 'react' import type { ReactElement } from 'react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' // Type-only: the owner contract of the directory-flow holes. import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' diff --git a/packages/client/ui-directory-picker-browse/src/client/index.ts b/packages/client/ui-directory-picker-browse/src/client/index.ts index 8730d48098..9e7b2833ea 100644 --- a/packages/client/ui-directory-picker-browse/src/client/index.ts +++ b/packages/client/ui-directory-picker-browse/src/client/index.ts @@ -2,10 +2,11 @@ * Browser half of the browse directory-picker backend: fills ui-workspace's * two directory-flow holes with the in-app Select Workspace Directory dialog * (figma `Harness` 813-23126 family), driving the node half's - * `host.listDirectory`/`host.createDirectory` primitives. Mounting this - * package therefore composes both sides of the browse interaction with one - * cordis.yml row; no client code branches on a capability kind. The dialog's - * copy is locale-registered here — the flow package owns its own strings. + * `directoryPicker/list`/`directoryPicker/createDirectory` primitives. + * Mounting this package therefore composes both sides of the browse + * interaction with one cordis.yml row; no client code branches on a + * capability kind. The dialog's copy is locale-registered here — the flow + * package owns its own strings. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. diff --git a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx index 6008e58ddc..f98d959478 100644 --- a/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/client-flow.client.spec.tsx @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx index 69a624eb38..5f386c8723 100644 --- a/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx +++ b/packages/client/ui-directory-picker-browse/tests/directory-browser.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import type { DirectoryListing } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) diff --git a/packages/client/ui-directory-picker-browse/tsconfig.json b/packages/client/ui-directory-picker-browse/tsconfig.json index 24535dac3f..aaa77a76d9 100644 --- a/packages/client/ui-directory-picker-browse/tsconfig.json +++ b/packages/client/ui-directory-picker-browse/tsconfig.json @@ -18,7 +18,7 @@ "path": "../ui-primitives" }, { - "path": "../connection/tsconfig.client.json" + "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../locale" diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index 951efc2356..68589c2982 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,7 +1,7 @@ /** * Browser half of the native directory-picker backend: fills ui-workspace's * two directory-flow holes with a renderless occupant that answers each - * `open` by driving `host.pickDirectory` (the node half's OS chooser) and + * `open` by driving `directoryPicker/pick` (the node half's OS chooser) and * reporting the one outcome — picked path, cancellation, or failure — back * through the owner conversation. Mounting this package therefore composes * both sides of the native interaction with one cordis.yml row; no client diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css index 6948f2eb56..a8c16c9383 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css @@ -46,7 +46,7 @@ border-radius: 14px; background: transparent; color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(28px + var(--dsh-content-font-delta, 0px)); white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/client/ui-primitives/src/DisclosureRow.module.css b/packages/client/ui-primitives/src/DisclosureRow.module.css index 212c11411b..fed453f345 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.module.css +++ b/packages/client/ui-primitives/src/DisclosureRow.module.css @@ -1,8 +1,10 @@ -/* Shared disclosure header: [16px leading] gap 6 [title 14/24] at the default +/* Shared disclosure header: [16px leading] gap 6 [title 13/24] at the default size. The Settings font-size preference moves the row through the - body-published axis: title size follows --dsh-content-font-size, and the row - height, leading box, and glyph edge shift by the same px delta so the icon - keeps its optical share of the line. */ + body-published axis: title size follows the secondary tier + (--dsh-content-font-size-secondary: one step under the body — setting −1 at + ≤14, setting −2 above), and the row height, leading box, and glyph edge + shift by the body px delta so the icon keeps its optical share of the + line. */ .root { display: flex; @@ -76,7 +78,7 @@ button.leading { .title { flex: none; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts b/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts index 4b1ce4a4ef..52bb220b7e 100644 --- a/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/disclosure-row-styles.client.spec.ts @@ -19,9 +19,9 @@ function declarations(selector: string): string[] { } describe('DisclosureRow.module.css font-size axis', () => { - it('sizes the title from the content font axis with the shared px delta', () => { + it('sizes the title from the secondary content tier on the shared row line', () => { expect(declarations('.title')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) }) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 63a4525d4b..376749a4f8 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: 12ea48aa2f321deec82ecea14c42aad7362523d6 -README.zh.md: a720916f5c9eab005a056415455e5017c0d08f0d +README.md: 32ec60edb39b30afdb9b54804e62302cdc58e531 +README.zh.md: ab17910ab9cec4e1ff519af0922ee27034a6fa7c diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 12ea48aa2f..32ec60edb3 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -29,7 +29,7 @@ Users switch the color scheme and content font size from two rows in Settings (G ### Appearance and font size -The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; tables, small text, and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. +The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local. ### Registering a theme @@ -53,7 +53,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou `src/styles/` holds five sheets imported in order by ui-theme's dynamic client entry: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. -`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. Dense table, small, and code variants stay fixed. The user bubble and composer draft consume the same variables outside that ladder. +`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. ### Scrollbar rebinding diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index a720916f5c..ab17910ab9 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 外观与字号 -插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;表格、小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 +插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。 ### 注册主题 @@ -53,7 +53,7 @@ kind: "package-reference" `src/styles/` 下有五张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR 会随 ui-theme 一同移除。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 -`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。紧凑的表格、小号文本与代码变体保持固定字号。用户气泡与 composer 草稿在该阶梯之外消费相同变量。 +`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。阶梯之外,用户气泡与 composer 草稿直接读取正文档变量对,流内行的标题及摘要读取低一档变量对。 ### 滚动条重新绑定 diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index c286c6d2e2..a2d2597d89 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -22,10 +22,16 @@ body[data-ds-dark-theme] { 重新从 Figma 导出时需要保留这一缩放。 */ /* 正文字号调节:--dsh-content-font-size 由主题 bootstrap 与 ThemePresenter 写在 body 上(整数 px,12–17,默认 14)。标题与正文变体按同一像素增量 - (--dsh-content-font-delta)随动,保持层级;表格、small、code 属于密集 - 次级文本,保持固定。 */ + (--dsh-content-font-delta)随动,保持层级。低一档文本(think、工具/bash + 行的标题与摘要、markdown 表格、流内次级说明等)统一读 + --dsh-content-font-size-secondary:设置 ≤14 时为设置值 −1,>14 时为 + 设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15)。式中 + max(13px,·) 的作用是让低设置取 −1 而非 −2,不是结果下限——12 设置下 + 层级触底 11px。small、code 属于密集次级文本,保持固定。 */ body { --dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px); + --dsh-content-font-size-secondary: min(calc(var(--dsh-content-font-size, 14px) - 1px), max(13px, calc(var(--dsh-content-font-size, 14px) - 2px))); + --dsh-content-font-delta-secondary: calc(var(--dsh-content-font-size-secondary) - 13px); --dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h1-font-family: var(--dsw-font-family); @@ -83,18 +89,18 @@ body { --dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px); --dsw-font-markdown-base-strong-italic-font-style: italic; - --dsw-font-markdown-table: 13px/22px var(--dsw-font-family); + --dsw-font-markdown-table: var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family); --dsw-font-markdown-table-font-family: var(--dsw-font-family); --dsw-font-markdown-table-font-weight: 400; - --dsw-font-markdown-table-line-height: 22px; - --dsw-font-markdown-table-font-size: 13px; + --dsw-font-markdown-table-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px)); + --dsw-font-markdown-table-font-size: var(--dsh-content-font-size-secondary, 13px); --dsw-font-markdown-table-font-style: normal; - --dsw-font-markdown-table-head: 500 13px/22px var(--dsw-font-family); + --dsw-font-markdown-table-head: 500 var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family); --dsw-font-markdown-table-head-font-family: var(--dsw-font-family); --dsw-font-markdown-table-head-font-weight: 500; - --dsw-font-markdown-table-head-line-height: 22px; - --dsw-font-markdown-table-head-font-size: 13px; + --dsw-font-markdown-table-head-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px)); + --dsw-font-markdown-table-head-font-size: var(--dsh-content-font-size-secondary, 13px); --dsw-font-markdown-table-head-font-style: normal; --dsw-font-markdown-small: 12px/20px var(--dsw-font-family); diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 7deef20ea8..4a3d5e46ef 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -1,8 +1,10 @@ /* Tool summary row (figma 122:9479): 24px single line at the default size — - [16 leading] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. + [16 leading] gap6 [title 13/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. The Settings font-size preference rides the body-published axis: the shared - DisclosureRow chrome scales the row height, title, and leading glyph; the - summary texts here follow the same size and px delta. */ + DisclosureRow chrome scales the row height and leading glyph by the body px + delta; the title and the summary texts here read the secondary tier + (--dsh-content-font-size-secondary: one step under the body — setting −1 at + ≤14, setting −2 above), matching think text. */ .root { display: flex; @@ -82,7 +84,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } @@ -96,7 +98,7 @@ flex: none; margin-left: 4px; white-space: nowrap; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } @@ -114,7 +116,7 @@ background: none; font: inherit; text-align: left; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); text-decoration: underline; diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index fbbab8cbbc..4c1d7b18b0 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -170,7 +170,7 @@ .title { flex: none; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); } @@ -190,7 +190,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts index 1a48bbdad8..28913a2939 100644 --- a/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts +++ b/packages/client/ui-tool/tests/tool-row-styles.client.spec.ts @@ -43,12 +43,13 @@ describe('ToolRow.module.css summary line', () => { expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis'])) }) - it('sizes the summary texts from the content font axis', () => { + it('sizes the summary texts from the secondary content tier', () => { // The Settings font-size preference must reach tool-call rows, not only - // the narration body: summary, suffix, and file link ride the axis. + // the narration body: summary, suffix, and file link read the secondary + // tier (one step under the body), matching think text. for (const selector of ['.summary', '.summarySuffix', '.fileLink']) { expect(declarations(selector)).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size, 14px)', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index b7678eea03..aa4ff56a3d 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -37,7 +37,7 @@ flex: none; max-width: 42%; color: var(--dsw-alias-label-secondary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); font-weight: 510; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; @@ -49,8 +49,8 @@ flex: 1; min-width: 0; color: var(--dsw-alias-label-tertiary); - font-size: calc(12px + var(--dsh-content-font-delta, 0px)); - line-height: calc(18px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; white-space: nowrap; } @@ -102,7 +102,7 @@ min-width: 0; max-width: 42%; color: var(--dsw-alias-label-secondary); - font-size: var(--dsh-content-font-size, 14px); + font-size: var(--dsh-content-font-size-secondary, 13px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; white-space: nowrap; @@ -113,8 +113,8 @@ flex: 1; min-width: 0; color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-overflow: ellipsis; white-space: nowrap; } @@ -124,10 +124,10 @@ flex: none; /* Column width scales with the text (~132/13 ≈ 10px per font-size px) so larger sizes keep the same status text visible instead of truncating. */ - width: calc(132px + var(--dsh-content-font-delta, 0px) * 10); + width: calc(132px + var(--dsh-content-font-delta-secondary, 0px) * 10); color: var(--dsw-alias-label-secondary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-align: right; text-overflow: ellipsis; white-space: nowrap; @@ -223,6 +223,9 @@ flex: 1; min-width: 0; color: var(--dsw-alias-label-secondary); + /* Body size, not the secondary tier: the member label is the panel's + content (an agent's task), while the run/phase headers, counts, and + statuses around it are chrome one step down. */ font-size: var(--dsh-content-font-size, 14px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); text-overflow: ellipsis; @@ -234,8 +237,8 @@ overflow: hidden; width: 64px; color: var(--dsw-alias-label-secondary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); text-align: right; text-overflow: ellipsis; white-space: nowrap; @@ -243,8 +246,8 @@ .empty { color: var(--dsw-alias-label-tertiary); - font-size: calc(13px + var(--dsh-content-font-delta, 0px)); - line-height: calc(20px + var(--dsh-content-font-delta, 0px)); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); padding: 0; } diff --git a/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts b/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts index 4530bee2f5..0095a47bbf 100644 --- a/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts +++ b/packages/client/ui-workflow-run/tests/panel-styles.client.spec.ts @@ -1,8 +1,10 @@ /** * WorkflowRunPanel's font-size-axis adoption as CSS text. jsdom has no * layout, so these read the declarations that make the run/phase headers and - * the expanded member rows follow the Settings font-size preference through - * --dsh-content-font-size / --dsh-content-font-delta. + * the expanded member rows follow the Settings font-size preference: member + * labels at the body size (--dsh-content-font-size / --dsh-content-font-delta), + * the chrome around them on the secondary tier + * (--dsh-content-font-size-secondary / --dsh-content-font-delta-secondary). */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -31,18 +33,18 @@ describe('WorkflowRunPanel.module.css font-size axis', () => { ])) }) - it('member status and the empty placeholder keep their 1px step under the body', () => { + it('member status and the empty placeholder read the secondary tier', () => { for (const selector of ['.memberStatus', '.empty']) { expect(declarations(selector)).toEqual(expect.arrayContaining([ - 'font-size: calc(13px + var(--dsh-content-font-delta, 0px))', - 'line-height: calc(20px + var(--dsh-content-font-delta, 0px))', + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))', ])) } }) it('the phase status column widens with the text so larger sizes do not truncate', () => { expect(declarations('.phaseStatus')).toEqual(expect.arrayContaining([ - 'width: calc(132px + var(--dsh-content-font-delta, 0px) * 10)', + 'width: calc(132px + var(--dsh-content-font-delta-secondary, 0px) * 10)', ])) }) }) diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 6c686eb440..893d4b5cb0 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-connection", @@ -53,6 +54,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -63,11 +65,13 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-schedule": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -82,6 +86,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-schedule": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index da06e95cc9..7f87202365 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -59,7 +59,9 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = [ + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', +] /** * Register the browser and picker once their slot declarations are on the @@ -72,7 +74,8 @@ export function apply(ctx: Context): void { const sessions = ctx.get('sessions') as ISessions const workspaces = ctx.get('workspaces') as IWorkspaces const hostDescription = connection.hostDescription - const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions) + const uiWorkspace = new UiWorkspaceService( + ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 1135f60b2e..4d0f3e3fa9 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,9 +1,9 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { - DirectoryListing, IApiClient, RpcError, -} from '@deepseek-ai/dsh-client-connection/client' +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { ISessions, SessionListState, @@ -69,7 +69,7 @@ export class DirectoryBrowseError extends Error { override readonly name = 'DirectoryBrowseError' /** @param rpcError - Host directory business failure. */ - constructor(readonly rpcError: RpcError) { + constructor(readonly rpcError: RemoteFailure) { super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) } } @@ -81,12 +81,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { /** * @param ctx - Client root Context. * @param api - shared Host API carrier. + * @param directoryPicker - the directory-picking Remote namespace. * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. */ constructor( ctx: Context, private readonly api: IApiClient, + private readonly directoryPicker: ClientRemote['directoryPicker'], private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, ) { @@ -144,23 +146,21 @@ class UiWorkspaceService extends Service implements UiWorkspace { } 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 + const result = await this.directoryPicker.pick() + if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`) + return result.value } async listDirectory(path?: string, signal?: AbortSignal): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value + const result = await this.directoryPicker.list(path, signal) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async createDirectory(path: string, name: string): Promise { - const response = await this.api.host.createDirectory({ path, name }) - if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) - return response.result.value.path + const result = await this.directoryPicker.createDirectory(path, name) + if (!result.ok) throw new DirectoryBrowseError(result.error) + return result.value } async openPath(path: string): Promise { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 79aef72717..3834358288 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -60,6 +61,10 @@ async function bench() { ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) + const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) + const directoryPicker = { pick: pickDirectory } + Object.assign(new TestRemote(ctx), { directoryPicker }) + ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(ctx) // These specs assert the shipped Chinese copy. There is no jsdom `window` // in this lane, so browser-language detection never runs and the locale @@ -68,7 +73,7 @@ async function bench() { ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, - insertSessionBefore, open, clear, search, renameSession, binding, fork, + insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory, } } @@ -83,7 +88,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'connection', + 'slots', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'remote.directoryPicker', ]) }) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 7db2ebd545..cc698e7391 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -37,6 +37,11 @@ async function createRuntime(): Promise { runtime.ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) + // The rename flow never picks a directory; the namespace only has to be there + // for ui-workspace's inject to settle. + const directoryPicker = {} + Object.assign(new TestRemote(runtime.ctx), { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 875709a409..95d113bbfd 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -8,11 +8,12 @@ import type { } from '@deepseek-ai/dsh-api-workspace-controller/client' import { RpcId, - type DirectoryListing, type IApiClient, type RpcError, type RpcResponse, } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SessionId } from '@deepseek-ai/dsh-session/types' import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts' @@ -180,9 +181,6 @@ class FakeApiClient implements IApiClient { home: '/home/u', canOpenPath: true, })) - onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null })) - onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing)) - onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' })) onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) declare readonly skills: IApiClient['skills'] @@ -193,9 +191,6 @@ class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)), - pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)), - listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)), - createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)), openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)), } @@ -209,6 +204,32 @@ class FakeApiClient implements IApiClient { } } +/** The directory-picking Remote namespace, recorded and scripted per case. */ +class FakeDirectoryPicker { + readonly calls: { method: string; payload: unknown }[] = [] + + onPick: () => Promise> = () => Promise.resolve({ ok: true, value: null }) + onList: () => Promise> = () => Promise.resolve({ ok: true, value: listing }) + onCreateDirectory: () => Promise> = + () => Promise.resolve({ ok: true, value: '/home/u/new' }) + + readonly remote: ClientRemote['directoryPicker'] = { + pick: () => this.record('pick', {}, this.onPick()), + list: (path?: string) => this.record('list', { path }, this.onList()), + createDirectory: (path: string, name: string) => + this.record('createDirectory', { path, name }, this.onCreateDirectory()), + } + + callsOf(method: string): unknown[] { + return this.calls.filter(call => call.method === method).map(call => call.payload) + } + + private record(method: string, payload: unknown, result: Promise): Promise { + this.calls.push({ method, payload }) + return result + } +} + interface BenchOptions { readonly workspaces?: WorkspaceSnapshot readonly sessions?: SessionListState @@ -217,15 +238,17 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() const api = new FakeApiClient() + const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) const uiWorkspace = new UiWorkspaceService( ctx, api, + directoryPicker.remote, workspaces, sessions as unknown as ISessions, ) - return { api, ctx, sessions, uiWorkspace, workspaces } + return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces } } async function flush(): Promise { @@ -451,31 +474,33 @@ describe('UiWorkspaceService', () => { it('passes directory operations to the Host and preserves structured browse failures', async () => { const b = bench() - b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: '/w/alpha' }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha') - b.api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + b.directoryPicker.onPick = () => Promise.resolve({ ok: true, value: null }) await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull() - expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + expect(b.directoryPicker.callsOf('pick')).toEqual([{}, {}]) await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing) await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing) - expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }]) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') - expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) + expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined() expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }]) - b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} })) + b.directoryPicker.onPick = () => Promise.resolve({ + ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, + }) await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser') - b.api.onListDirectory = () => Promise.resolve(failed({ - code: 'directory-unreadable', message: 'denied', details: { path: '/private' }, - })) + b.directoryPicker.onList = () => Promise.resolve({ + ok: false, error: { code: 'directory-unreadable', message: 'denied', details: { path: '/private' } }, + }) const listFailure = b.uiWorkspace.listDirectory('/private') await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) - b.api.onCreateDirectory = () => Promise.resolve(failed({ - code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' }, - })) + b.directoryPicker.onCreateDirectory = () => Promise.resolve({ + ok: false, error: { code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' } }, + }) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' }, }) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 717178f61c..eb0b5d1dcc 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../connection/tsconfig.client.json" }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../store" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3f64fefbc7..db03dfeba0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -768,6 +768,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'directoryPickerController', + summary: 'Host service backing the generated `ctx.remote.directoryPicker` namespace.', + description: 'Host service backing the generated `ctx.remote.directoryPicker` namespace. The seam it exports is abstract and therefore never a Loader entry of its own, so this controller carries the wire verbs: one composed backend serves either the native chooser or the browse primitives, and a verb the composition cannot serve is refused rather than approximated.', + methods: [ + { + signature: '@Remote(\'pick\') async pick(signal: AbortSignal): Promise', + description: 'Open the host\'s OS chooser for a Remote caller.', + parameters: [{ name: 'signal', description: 'caller lifetime; abort terminates the chooser.' }], + returns: 'the chosen absolute path, or null when the operator cancels.', + }, + { + signature: '@Remote(\'list\') async list(path: string | undefined, signal: AbortSignal): Promise', + description: 'List one directory level for a Remote caller\'s in-app browser.', + parameters: [{ name: 'path', description: 'absolute directory to list; absent lists the home directory.' }, { name: 'signal', description: 'caller lifetime; abort stops the backend\'s scan instead of letting it outlive a disconnected caller.' }], + returns: 'the level\'s listing with its ancestry.', + }, + { + signature: '@Remote(\'createDirectory\') async createDirectory(path: string, name: string): Promise', + description: 'Create one child directory for a Remote caller\'s in-app browser.', + parameters: [{ name: 'path', description: 'absolute existing parent directory.' }, { name: 'name', description: 'single non-blank path segment.' }], + returns: 'the created directory\'s absolute path.', + }, + ], + }, { key: 'e2b', summary: 'Creates one lazily consumable E2B SDK handle and deletes the sandbox at timeout or disposal.', @@ -3660,6 +3685,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DirectoryEntry', + declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}', + }, + { + name: 'DirectoryListing', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}', + }, { name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', @@ -4422,7 +4455,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', }, { name: 'RpcId', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 209e601d83..db9c6a286b 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: 3460d0ec021f658fe91e1ed8b617196d437cb937 -README.zh.md: a6cfac365fe728f26b3afd22ef32e661bd0146ff +README.md: 9f96999faf26090ccb58763a9301381a4d21d2de +README.zh.md: 3633880a27e23c2c35706d97b8c905f24a17882d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3460d0ec02..9f96999faf 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,7 +40,7 @@ The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross ### What the gateway exposes -The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, pickDirectory, listDirectory, createDirectory, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. +The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, openPath), `skills`, `agentPresets`, `goals`, `settings`, `credentials`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. ### Sessions and history @@ -140,7 +140,6 @@ These limits define where the gateway is a poor fit; they are current package co - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. 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. - **Search failures include provider diagnostics** — the gateway is a single-user local service; a carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. -- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). - **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index a6cfac365f..3633880a27 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,7 +40,7 @@ HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站 ### 网关暴露什么 -API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、pickDirectory、listDirectory、createDirectory、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 +API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`credentials`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 ### 会话与历史 @@ -140,7 +140,6 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q - **预留 seam 不进入 `RpcMethodMap`**——`prompt.mode: 'inject'`、`job.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**——网关是单用户本地服务;将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 -- **Linux 原生选择器依赖桌面工具**——在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是浏览后端(见 [native 后端 README](../directory-picker-native/README.zh.md))。 - **冷列表提示只向“保持可见、排序偏旧”降级**——projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,除非符合资格的小工件提供精确折叠。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)的范围。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3c3f8210d6..fc94e58035 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -41,7 +41,6 @@ import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@dee import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Read live abort state across awaits without treating it as synchronously immutable. */ @@ -59,14 +58,6 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } -/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ -function directoryError(error: unknown): RpcError { - if (error instanceof DirectoryPickerError) { - return { code: error.code, message: error.message, details: { path: error.path } } - } - return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } -} - /** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { /** Current deployment model selection reported by `host.describe`. */ @@ -290,72 +281,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - async pickDirectory(request, signal) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'native') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - const path = await capability.pick(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: {}, - }) - } - }, - - async listDirectory(request, signal) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'browse') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - // The carrier's signal follows the caller: a disconnect or timeout - // stops the backend's directory scan instead of outliving it. - return ok(request, await capability.list(request.payload.path, signal)) - } catch (error: unknown) { - // An abort is the caller's own timeout/disconnect, not a server failure. - if (signal.aborted) { - return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) - } - return err(request, directoryError(error)) - } - }, - - async createDirectory(request) { - const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'browse') { - return err(request, { - code: 'directory-picker-unavailable', - message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, - details: { capability: capability.kind }, - }) - } - try { - return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) }) - } catch (error: unknown) { - return err(request, directoryError(error)) - } - }, - async openPath(request, signal) { return openPath(request, request.payload.path, signal) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 1a520f1e30..6735e55639 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,7 +3,6 @@ */ import { z } from 'zod' -import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' @@ -21,49 +20,6 @@ export const hostDescribeValueSchema = z.object({ canOpenPath: z.boolean(), }) 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>> - -/** Directory row shared by listing entries and breadcrumb crumbs. */ -export const directoryEntrySchema = z.object({ - name: z.string(), - path: z.string(), - hidden: z.boolean(), -}) satisfies z.ZodType> - -/** host.listDirectory request payload; an absent path lists the home directory. */ -export const hostListDirectoryRequestSchema = z.object({ - path: z.string().optional(), -}) satisfies z.ZodType>> - -/** host.listDirectory response value. */ -export const hostListDirectoryValueSchema = z.object({ - path: z.string(), - home: z.string(), - crumbs: z.array(directoryEntrySchema), - entries: z.array(directoryEntrySchema), - truncated: z.boolean(), -}) satisfies z.ZodType>> - -/** host.createDirectory request payload: name must be one plain path segment. */ -export const hostCreateDirectoryRequestSchema = z.object({ - path: z.string(), - name: z.string(), -}).refine( - payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..' - && !/[/\\]/.test(payload.name), - { message: 'host.createDirectory requires a single non-blank path segment name' }, -) satisfies z.ZodType>> - -/** host.createDirectory response value: the created directory's absolute path. */ -export const hostCreateDirectoryValueSchema = z.object({ - path: z.string(), -}) satisfies z.ZodType>> /** host.openPath request payload. */ export const hostOpenPathRequestSchema = z.object({ path: z.string().min(1), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index e700b86db3..ceb7998d2b 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,33 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' -/** One directory row of a listing: a child entry or a breadcrumb ancestor. */ -export interface DirectoryEntry { - /** Base name shown in a browser row (a root crumb carries its full path). */ - name: string - /** Absolute host path — the client never joins path segments itself. */ - path: string - /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ - hidden: boolean -} - -/** host.listDirectory response value: one directory level plus its ancestry. */ -export interface DirectoryListing { - /** Absolute path of the listed directory. */ - path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ - home: string - /** - * Ancestor chain from the filesystem root to the listed directory - * inclusive; every crumb is a jump target (crumb `hidden` is always false). - */ - crumbs: DirectoryEntry[] - /** Direct child directories, name-sorted; symlinks to directories included. */ - entries: DirectoryEntry[] - /** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */ - truncated: boolean -} - /** Host-level unary methods. */ export interface HostApi { /** @@ -54,37 +27,6 @@ export interface HostApi { canOpenPath: boolean }>> - /** - * Open the operating system's single-directory picker; cancellation returns - * null. Only served under the `native` capability. - */ - pickDirectory( - request: RpcRequest<{}>, - signal: AbortSignal, - ): Promise> - - /** - * List one directory level for the in-app browser; an absent path lists the - * host account's home directory. Only served under the `browse` capability; - * unreadable or missing targets fail with `directory-unreadable`. The - * carrier's request signal follows the caller, stopping the backend's scan - * on disconnect or timeout. - */ - listDirectory( - request: RpcRequest<{ path?: string }>, - signal: AbortSignal, - ): Promise> - - /** - * Create one child directory under an existing parent (the browser's - * "New folder"). Only served under the `browse` capability; an existing - * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. - */ - createDirectory( - request: RpcRequest<{ path: string; name: string }>, - ): Promise> - /** * Open a filesystem path with the operating system's default application * (Finder / Explorer / xdg-open hand-off). The browser carrier's diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 434c362e1a..b151677137 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -29,7 +29,7 @@ export type { ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' -export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' +export type { HostApi } from './host.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { AgentPresetsApi } from './agent-presets.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index d636963e82..2590b4cec7 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -18,9 +18,6 @@ import type { RpcResponse } from './rpc.ts' */ export interface RpcMethodMap { 'host.describe': HostApi['describe'] - 'host.pickDirectory': HostApi['pickDirectory'] - 'host.listDirectory': HostApi['listDirectory'] - 'host.createDirectory': HostApi['createDirectory'] 'host.openPath': HostApi['openPath'] 'skill.list': SkillsApi['list'] 'agentPreset.openDocument': AgentPresetsApi['openDocument'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 498c7c3a1f..57ca87bcf3 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -36,10 +36,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), - z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index b154a5e14a..7448ef669c 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -31,10 +31,6 @@ export interface RpcErrorDetailsMap { 'cancelled': {} 'session-not-found': { sessionId: SessionId } 'invalid-time-zone': { value: string } - 'directory-unreadable': { path: string } - 'directory-exists': { path: string } - 'directory-create-failed': { path: string } - 'directory-picker-unavailable': { capability: string } 'agent-preset-read-only': { agentPreset: string; reason: string } 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index f65d5fafff..4a567f8638 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,8 +13,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { serverResponseSchema } from '../api/rpc.schema.ts' import { - hostCreateDirectoryValueSchema, hostDescribeValueSchema, - hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema, + hostDescribeValueSchema, hostOpenPathValueSchema, } from '../api/host.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' import { @@ -44,9 +43,6 @@ import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSc export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> - pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> - listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise>> - createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> } skills: { @@ -80,9 +76,6 @@ export interface IApiClient { */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { 'host.describe': hostDescribeValueSchema, - 'host.pickDirectory': hostPickDirectoryValueSchema, - 'host.listDirectory': hostListDirectoryValueSchema, - 'host.createDirectory': hostCreateDirectoryValueSchema, 'host.openPath': hostOpenPathValueSchema, 'skill.list': skillListValueSchema, 'agentPreset.openDocument': agentPresetOpenDocumentValueSchema, @@ -102,9 +95,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */ + /** @param timeoutMs - timeout for unary calls. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -178,24 +168,21 @@ export abstract class AbstractApiClient implements IApiClient { /** * Shared POST leg of unary calls: JSON body, - * optional default timeout merged with the caller's external signal, non-2xx → transport throw. + * default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, body: ClientRequest, signal: AbortSignal | undefined, - timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { - const requestSignal = timeoutPolicy === 'default' - ? signal === undefined - ? AbortSignal.timeout(this.timeoutMs) - : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) - : signal + const requestSignal = signal === undefined + ? AbortSignal.timeout(this.timeoutMs) + : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) const response = await this.doFetch(new URL(path, this.resolveBase()), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - ...requestSignal === undefined ? {} : { signal: requestSignal }, + signal: requestSignal, }) if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) return response @@ -210,11 +197,10 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, - timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy) + const response = await this.postJson(`/api/${method}`, message, signal) 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}`) @@ -229,13 +215,6 @@ 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, 'caller-signal-only', - ), - listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), - createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 6b3a7d3b13..d4f3ea58ed 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -15,9 +15,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema } from '../api/rpc.schema.ts' import { - hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, - hostListDirectoryRequestSchema, hostOpenPathRequestSchema, - hostPickDirectoryRequestSchema, + hostDescribeRequestSchema, hostOpenPathRequestSchema, } from '../api/host.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' import { @@ -50,9 +48,6 @@ type UnaryRoutes = { const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, - 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, - 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, - 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index aab8e952d2..98a0e091c8 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-host-directory-picker' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' import { diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts index 74c068150f..681f2d0ed4 100644 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-host.spec.ts @@ -2,8 +2,6 @@ import { homedir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' -import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { createApiProxy } from '../src/api-proxy.ts' @@ -26,7 +24,6 @@ function expectOk(response: { readonly result: { readonly ok: true; readonly } async function harness( - picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise canOpenPath?: () => boolean @@ -35,7 +32,6 @@ async function harness( const ctx = new Context() contexts.push(ctx) await ctx.plugin(AgentRegistry) - ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: '/tmp/dsh-apiproxy-host', @@ -45,135 +41,10 @@ async function harness( return { api } } -describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the native capability', async () => { - const selected = await harness({ kind: 'native', pick: 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({ kind: 'native', pick: 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 capability as a cancelled RPC error', async () => { - const { api } = await harness({ - kind: 'native', - pick: 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' } }) - }) - - it('folds a non-abort native-chooser failure into an internal error', async () => { - const { api } = await harness({ - kind: 'native', - pick: async () => { throw new Error('no chooser installed') }, - }) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('refuses the native RPC under a browse composition', async () => { - const { api } = await harness(BROWSE_STUB) - const response = await api.host.pickDirectory(request({}), new AbortController().signal) - expect(response.result).toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, - }) - }) -}) - -const BROWSE_STUB: DirectoryPickerCapability = { - kind: 'browse', - list: async (path) => { - if (path === '/denied') { - throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') - } - const target = path ?? '/home/user' - return { - path: target, - home: '/home/user', - crumbs: [{ name: '/', path: '/', hidden: false }], - entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], - truncated: false, - } - }, - createDirectory: async (path, name) => { - if (name === 'taken') { - throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') - } - if (name === 'unwritable') throw new Error('disk detached') - return `${path}/${name}` - }, -} - -describe('host.listDirectory / host.createDirectory', () => { - it('serves listings and creation through the browse capability, defaulting to home', async () => { - const { api } = await harness(BROWSE_STUB) - const home = await api.host.listDirectory(request({}), new AbortController().signal) - expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory( - request({ path: '/home/user/projects' }), - new AbortController().signal, - ) - expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) - const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) - expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) - }) - - it('maps typed picker failures onto wire errors and folds unknown throws to internal', async () => { - const { api } = await harness(BROWSE_STUB) - expect((await api.host.listDirectory( - request({ path: '/denied' }), - new AbortController().signal, - )).result).toMatchObject({ - ok: false, - error: { code: 'directory-unreadable', details: { path: '/denied' } }, - }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result) - .toMatchObject({ ok: false, error: { code: 'directory-exists' } }) - expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result) - .toMatchObject({ ok: false, error: { code: 'internal' } }) - }) - - it('reports an aborted listing as cancelled', async () => { - const { api } = await harness({ - kind: 'browse', - list: (_path, signal) => new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) - }), - createDirectory: async () => '/never', - }) - const abort = new AbortController() - const pending = api.host.listDirectory(request({}), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) - - it('refuses the browse RPCs under a native composition', async () => { - const { api } = await harness() - expect((await api.host.listDirectory(request({}), new AbortController().signal)).result) - .toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result) - .toMatchObject({ - ok: false, - error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, - }) - }) -}) - describe('host.openPath', () => { it('describes whether the deployment can reach a native desktop', async () => { - const visible = await harness(undefined, { canOpenPath: () => true }) - const headless = await harness(undefined, { canOpenPath: () => false }) + const visible = await harness({ canOpenPath: () => true }) + const headless = await harness({ canOpenPath: () => false }) expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) @@ -181,7 +52,7 @@ describe('host.openPath', () => { it('opens through the injected native boundary', async () => { const opened: string[] = [] - const { api } = await harness(undefined, { + const { api } = await harness({ openPath: async (path) => { opened.push(path) }, }) expect((await api.host.openPath( @@ -192,7 +63,7 @@ describe('host.openPath', () => { }) it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, { + const { api } = await harness({ openPath: (_path, signal) => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d6be6bf619..45b6d153ce 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -29,9 +29,6 @@ function scriptedApi(overrides: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), - pickDirectory: r => ok(r, { path: null }), - listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), - createDirectory: r => ok(r, { path: '/t/new' }), openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index b56fa89d82..9bbd19640b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -18,15 +18,6 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { }, } }, - async pickDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } - }, - async listDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } } - }, - async createDirectory(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } - }, async openPath(request) { return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } }, @@ -124,29 +115,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { .toEqual({ ok: true, value: { opened: 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 the browse listing and creation calls through the wire form', async () => { - const c = client() - const listed = await c.host.listDirectory({ path: '/w' }) - expect(listed.result).toEqual({ - ok: true, - value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }, - }) - const home = await c.host.listDirectory({}) - expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) - const created = await c.host.createDirectory({ path: '/w', name: 'fresh' }) - expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } }) - }) - it('round-trips host.openPath through the wire form', async () => { const api = fakeApi() let opened: string | undefined @@ -165,41 +133,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) - it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => { - vi.useFakeTimers() - const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { - const controller = new AbortController() - setTimeout(() => { - controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')) - }, milliseconds) - return controller.signal - }) - try { - const api = fakeApi() - api.host.pickDirectory = async (request) => { - await new Promise(resolve => setTimeout(resolve, 30_001)) - return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } } - } - const execution = client(api).host.pickDirectory({}) - const assertion = expect(execution).resolves.toMatchObject({ - result: { ok: true, value: { path: '/tmp/slow' } }, - }) - - await Promise.all([ - vi.advanceTimersByTimeAsync(30_001), - assertion, - ]) - expect(timeoutSpy).not.toHaveBeenCalled() - } finally { - timeoutSpy.mockRestore() - vi.useRealTimers() - } - }) - - it('keeps caller and connection aborts on a deadline-exempt unary', async () => { + it('keeps caller and connection aborts on a signal-taking unary', async () => { const api = fakeApi() const started = Promise.withResolvers() - api.host.pickDirectory = async (request, signal) => { + api.host.openPath = async (request, signal) => { started.resolve(signal) if (!signal.aborted) { await new Promise((resolve) => { @@ -212,7 +149,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } } const controller = new AbortController() - const execution = client(api).host.pickDirectory({}, controller.signal) + const execution = client(api).host.openPath({ path: '/tmp/a.txt' }, controller.signal) const handlerSignal = await started.promise controller.abort(new Error('connection closed')) @@ -221,9 +158,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(handlerSignal.aborted).toBe(true) }) - it('propagates the carrier Request signal into host.pickDirectory', async () => { + it('propagates the carrier Request signal into host.openPath', async () => { const api = fakeApi() - api.host.pickDirectory = async (request, signal) => { + api.host.openPath = async (request, signal) => { if (!signal.aborted) { await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) @@ -236,8 +173,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => { } 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', { + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-opener', method: 'host.openPath', payload: { path: '/tmp/a.txt' } }) + const pending = handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, })) controller.abort() diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 0a803154b8..429e31361f 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -5,11 +5,7 @@ import { rpcResultSchema, serverResponseSchema, } from '../src/api/rpc.schema.ts' import { z } from 'zod' -import { - hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, - hostDescribeRequestSchema, hostDescribeValueSchema, - hostListDirectoryRequestSchema, hostListDirectoryValueSchema, -} from '../src/api/host.schema.ts' +import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts' @@ -36,10 +32,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - expect(rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: { path: '/x' } }).code).toBe('directory-unreadable') - expect(rpcErrorSchema.parse({ code: 'directory-exists', message: 'm', details: { path: '/x' } }).code).toBe('directory-exists') - expect(rpcErrorSchema.parse({ code: 'directory-create-failed', message: 'm', details: { path: '/x' } }).code).toBe('directory-create-failed') - expect(rpcErrorSchema.parse({ code: 'directory-picker-unavailable', message: 'm', details: { capability: 'none' } }).code).toBe('directory-picker-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') @@ -55,7 +47,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) @@ -109,26 +100,6 @@ describe('host domain schemas', () => { version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, })).toThrow() }) - - it('validates the browse listing/creation payloads', () => { - expect(hostListDirectoryRequestSchema.parse({})).toEqual({}) - expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' }) - const listing = hostListDirectoryValueSchema.parse({ - path: '/home/u/p', - home: '/home/u', - crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }], - entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }], - truncated: false, - }) - expect(listing.entries[0]?.hidden).toBe(true) - // The flag is part of the wire value, not an optional decoration. - expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow() - expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' }) - for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { - expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow() - } - expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' }) - }) }) describe('skills domain schemas', () => { diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index a101e5580b..a9184c9313 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 0b161afa4b70c291d20523507b62789c4856d29a -README.zh.md: 4207a1eccdb913b322733f9c1e56f79d298c16b7 +README.md: 986504a48da5b1ea28c174168eec41e40bb77e4e +README.zh.md: 54a722bdad2d88e6cadc6f39c74f479851197351 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 0b161afa4b..986504a48d 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -25,7 +25,7 @@ Users who cannot reach an OS chooser still pick a workspace directory through `d ## Use this package -Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `host.listDirectory` and `host.createDirectory`; both primitives answer from the host filesystem. +Compose this backend when a workspace directory must be chosen without an OS chooser — remote browsers, SSH-forwarded sessions, or unattended hosts. The workspace flow drives `directoryPicker/list` and `directoryPicker/createDirectory`; both primitives answer from the host filesystem. ### Listing a directory diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4207a1eccd..54a722bdad 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `host.listDirectory` 与 `host.createDirectory`;两个原语都从宿主文件系统作答。 +当工作区目录必须在没有 OS 选择器的情况下被选中时——远程浏览器、SSH 转发会话或无人值守宿主——组合此后端。工作区流程驱动 `directoryPicker/list` 与 `directoryPicker/createDirectory`;两个原语都从宿主文件系统作答。 ### 列举目录 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index adbe6d09ad..01fe5b9cd2 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -303,8 +303,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`) } const parent = resolve(path) - // The backend owns segment validation (the wire schema also refuses these, - // but direct service consumers must hit the same fence). + // The backend owns segment validation; the Remote controller also refuses + // invalid wire input, but direct service consumers must hit the same fence. if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`) } diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index ef85adaeeb..aa59d82312 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/directory-picker-native/README.md -README.md: 9bea790348293398b800271cfee2dce75d02072a -README.zh.md: 53c582c33a55397c1e0fe526e5b92ebc270fe8f7 +README.md: 420ec71899366733c9be538ca5900644e81b668d +README.zh.md: a6671d2f93fbc605bfa74d73e80d835cb0c7824e diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 9bea790348..420ec71899 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -33,7 +33,7 @@ Choose this backend for a workstation-local operator on macOS, Windows, or deskt ### What an operator experiences -Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `host.pickDirectory` and reports the one outcome (picked path, cancel, or failure). +Each call opens one native chooser on the host display and waits for the operator; aborting the caller's signal terminates the chooser process instead of leaving it open. On Linux the chooser needs either Zenity or KDialog installed; with neither present, `pick` rejects with an actionable error instead of falling back to a typed-path prompt. The browser half of this package registers a renderless flow occupant into the workspace flow — every `open` request drives `directoryPicker/pick` and reports the one outcome (picked path, cancel, or failure). ### Observable failures diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 53c582c33a..a6671d2f93 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 操作者会看到什么 -每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并上报唯一结果(所选路径、取消或失败)。 +每次调用在宿主屏幕上打开一个原生选择器并等待操作者;中止调用方的信号会终止选择器进程,而不是让它留在屏幕上。Linux 上选择器需要安装 Zenity 或 KDialog 之一;两者都没有时,`pick` 以包含解决建议的错误拒绝,而不会回退为手输路径提示。本包的 browser 半侧向工作区流程注册一个无渲染的流程占用者——每次 `open` 请求驱动 `directoryPicker/pick`,并上报唯一结果(所选路径、取消或失败)。 ### 可观察的失败 diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 7d6d8ef665..d2d9647738 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: 93afde4ec012fe366f0a44e8d9632596876f6b69 -README.zh.md: 1c9942cdc4271b7201d7086868d01da23ccd4d46 +README.md: bcd2c4dbfa2848e669edbc9f0ebba93804df622c +README.zh.md: 8604b54c99e88cedfedf820e3dd0f8800f9c5972 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 93afde4ec0..bcd2c4dbfa 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -29,11 +29,11 @@ Mount exactly one directory-picker backend and let the workspace flow drive it: ### Choosing a backend -The [native backend](../directory-picker-native/README.md) is the right choice when the operator sits at the host's display: `host.pickDirectory` opens one OS chooser and returns the chosen absolute path, or `null` on cancel. The [browse backend](../directory-picker-browse/README.md) works everywhere — it lists one directory level and creates child directories from the browser, so remote clients that cannot reach an OS dialog still pick a workspace. When the host situation varies between boots, compose the [adaptive chooser](../directory-picker-auto/README.md), which resolves the situation once at boot and mounts the matching backend. +The [native backend](../directory-picker-native/README.md) is the right choice when the operator sits at the host's display: `directoryPicker/pick` opens one OS chooser and returns the chosen absolute path, or `null` on cancel. The [browse backend](../directory-picker-browse/README.md) works everywhere — it lists one directory level and creates child directories from the browser, so remote clients that cannot reach an OS dialog still pick a workspace. When the host situation varies between boots, compose the [adaptive chooser](../directory-picker-auto/README.md), which resolves the situation once at boot and mounts the matching backend. ### The capability contract -`capability()` returns a discriminated union describing how an operator selects a directory: `{ kind: 'native', pick(signal) }` for the OS chooser, or `{ kind: 'browse', list(path?), createDirectory(path, name) }` for the in-app browser. Consumers switch on `kind`; a capability kind no composition implements means the UI hides the picking affordance rather than failing. Browse failures throw the typed `DirectoryPickerError` with a closed code set — `directory-unreadable`, `directory-exists`, or `directory-create-failed` — each carrying the subject path, which the consuming gateway maps onto wire error codes. +`capability()` returns a discriminated union describing how an operator selects a directory: `{ kind: 'native', pick(signal) }` for the OS chooser, or `{ kind: 'browse', list(path?), createDirectory(path, name) }` for the in-app browser. Consumers switch on `kind`; a capability kind no composition implements means the UI hides the picking affordance rather than failing. Browse failures throw the typed `DirectoryPickerError` with a closed code set — `directory-unreadable`, `directory-exists`, or `directory-create-failed` — each carrying the subject path, which the picking Remote controller maps onto wire failure codes. ### What rows carry diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 1c9942cdc4..8604b54c99 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -29,11 +29,11 @@ web GUI 宿主通过一份约定让操作者选择工作区目录:一个只提 ### 选择后端 -当操作者坐在宿主屏幕前时,[原生后端](../directory-picker-native/README.zh.md)是正确选择:`host.pickDirectory` 打开一个 OS 选择器,返回所选绝对路径,取消时返回 `null`。[浏览后端](../directory-picker-browse/README.zh.md)处处可用——它在浏览器中列举一个目录层级并创建子目录,因此无法触达 OS 对话框的远程客户端依然能选择工作区。当宿主处境在两次启动之间变化时,组合[自适应选择器](../directory-picker-auto/README.zh.md),它在启动时判定一次处境并挂载匹配的后端。 +当操作者坐在宿主屏幕前时,[原生后端](../directory-picker-native/README.zh.md)是正确选择:`directoryPicker/pick` 打开一个 OS 选择器,返回所选绝对路径,取消时返回 `null`。[浏览后端](../directory-picker-browse/README.zh.md)处处可用——它在浏览器中列举一个目录层级并创建子目录,因此无法触达 OS 对话框的远程客户端依然能选择工作区。当宿主处境在两次启动之间变化时,组合[自适应选择器](../directory-picker-auto/README.zh.md),它在启动时判定一次处境并挂载匹配的后端。 ### 能力约定 -`capability()` 返回一个可辨识联合类型,说明操作者如何选择目录:OS 选择器为 `{ kind: 'native', pick(signal) }`,应用内浏览器为 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。消费方按 `kind` 分支;某个组合没有实现的能力类型意味着界面隐藏选择入口,而不是失败。浏览失败抛出带类型的 `DirectoryPickerError`,其错误码集合是封闭的——`directory-unreadable`、`directory-exists` 或 `directory-create-failed`——每个都携带出错对象的路径,消费网关将其 1:1 映射为协议错误码。 +`capability()` 返回一个可辨识联合类型,说明操作者如何选择目录:OS 选择器为 `{ kind: 'native', pick(signal) }`,应用内浏览器为 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。消费方按 `kind` 分支;某个组合没有实现的能力类型意味着界面隐藏选择入口,而不是失败。浏览失败抛出带类型的 `DirectoryPickerError`,其错误码集合是封闭的——`directory-unreadable`、`directory-exists` 或 `directory-create-failed`——每个都携带出错对象的路径,选目录 Remote controller 将其 1:1 映射为协议错误码。 ### 行携带什么 diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 5be5940ecd..6fb6652bdc 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e8eb64f87..f4eb0674a2 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -12,6 +12,9 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import type { DirectoryListing } from './types.ts' + +export type { DirectoryEntry, DirectoryListing } from './types.ts' /** The native interaction: one OS directory chooser on the host display. */ export interface DirectoryPickerNativeCapability { @@ -24,37 +27,6 @@ export interface DirectoryPickerNativeCapability { pick(signal: AbortSignal): Promise } -/** One directory row: a listing child or a breadcrumb ancestor. */ -export interface DirectoryEntry { - /** Base name shown in a browser row (a root crumb carries its full path). */ - name: string - /** Absolute host path — clients never join path segments themselves. */ - path: string - /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ - hidden: boolean -} - -/** One directory level plus its ancestry, as a browse backend reports it. */ -export interface DirectoryListing { - /** Absolute path of the listed directory. */ - path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ - home: string - /** - * Ancestor chain from the filesystem root to the listed directory - * inclusive; every crumb is a jump target (crumb `hidden` is always false). - */ - crumbs: DirectoryEntry[] - /** Direct child directories, name-sorted; symlinks to directories included. */ - entries: DirectoryEntry[] - /** - * True when the backend cut `entries` at its complete-result bound: the - * level has more child directories than reported, and the missing rows are - * the name-sorted tail (hidden rows count toward the bound). - */ - truncated: boolean -} - /** * The browse interaction: listing/creation primitives an in-app browser * drives one level at a time. Works for remote clients — nothing renders on diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts index abf29856fc..5c44aac9af 100644 --- a/packages/host/directory-picker/src/invariant.ts +++ b/packages/host/directory-picker/src/invariant.ts @@ -12,7 +12,7 @@ export const inject = ['invariants'] /** * No runtime invariant: this stateless Service Definition owns the capability - * vocabulary, while backends and the RPC consumer own observations. + * vocabulary, while backends and the Remote controller own observations. */ const install: InvariantInstaller = () => {} diff --git a/packages/host/directory-picker/src/types.ts b/packages/host/directory-picker/src/types.ts new file mode 100644 index 0000000000..db1dda57db --- /dev/null +++ b/packages/host/directory-picker/src/types.ts @@ -0,0 +1,39 @@ +/** + * Client-safe type surface of the directory-picking seam: what one browse level + * looks like to a caller. Types only — no runtime code, and nothing here reaches + * a Host-only symbol, so a Client compilation face reads exactly the signatures + * the Host emits. + * + * @module @deepseek-ai/dsh-host-directory-picker/types + */ + +/** One directory row: a listing child or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — clients never join path segments themselves. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** One directory level plus its ancestry, as a browse backend reports it. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] + /** + * True when the backend cut `entries` at its complete-result bound: the + * level has more child directories than reported, and the missing rows are + * the name-sorted tail (hidden rows count toward the bound). + */ + truncated: boolean +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ff11a9f4b..83f2ef6335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -838,6 +838,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -1615,6 +1618,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../../host/directory-picker '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2252,9 +2258,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-connection': + '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ - version: link:../connection + version: link:../../api/remotes '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3675,6 +3681,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -3720,6 +3729,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 00359ad450..3d789bb894 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -118,6 +118,7 @@ export const SERVICE_PAGE: Record = { webhookRuntime: 'webhook.md', workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', + directoryPickerController: 'workspace.md', } /** @@ -595,6 +596,7 @@ export const LINK_MAP: Readonly> = { ProjectionSnapshot: 'session-projection.md', ProjectionCheckpoint: 'session-projection.md', DirectoryPickerCapability: 'workspace.md', + DirectoryListing: 'workspace.md', TypertContribution: 'invariants.md', TypertRemoteEventSource: 'typert.md', TypertFace: 'invariants.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 9e0dd0c8bf..22ded5918c 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', }, + { + key: 'directoryPickerController', + pkg: 'api-workspace-controller', + title: 'Host directory-picking Remote controller', + mode: 'core', + note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.', + }, { key: 'invariants', pkg: 'invariants', @@ -561,7 +568,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Workspace-directory picking seam', mode: 'seam', implementations: ['host-directory-picker-native', 'host-directory-picker-browse'], - consumers: ['host-apiproxy'], + consumers: ['api-workspace-controller'], note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).', }, {