diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 67227acc29..c5435129be 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.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-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 1c451e14ea2a7c494454956ce9bb09d97d4e1e1d -2026-07-07-mcp-client-plugin.zh.md: 78a086d6c218a254e850777fe4024973e8848e17 +2026-07-07-mcp-client-plugin.md: 91c1bf428973eae7935e9c2a14dda7028a734313 +2026-07-07-mcp-client-plugin.zh.md: c0c2dbae5b854734240f8035edfc8d0b5a33408f diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 1c451e14ea..91c1bf4289 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -96,12 +96,14 @@ Every MCP tool has two names: This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp____` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`). -1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. +1. On connect: drain uncached `tools/list` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. 2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. 3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name. 4. No `presentCall`/`presentResult` — UI consumers use the provider-neutral generic-card fallback. 5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself. +Each synchronization rejects a repeated non-empty continuation cursor before requesting another page, retaining the previous tool generation. Empty pages cannot establish progress through tool-name uniqueness, so cursor history also detects cycles spanning several pages ([reported failure](https://github.com/deepseek-ai/deepseek-harness/discussions/3660)). Cursor history belongs to one synchronization: a later update may reuse the same cursors. Focused bridge and lifecycle tests cover cycle rejection, retained callable tools, strict startup failure, and notification recovery. This detects repeated cursors; it does not bound a server that continually returns distinct cursors. + ### Public name normalization MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name: diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 78a086d6c2..c0c2dbae5b 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -96,12 +96,14 @@ type Config = StdioConfig | StreamableHttpConfig 这种按服务器限定的形式是多服务器 agent 客户端事实上的标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp____` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。 -1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 +1. 连接时:遍历未缓存的 `tools/list` 分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。 3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。 4. 无 `presentCall`/`presentResult`——UI 消费方使用提供方无关的通用卡片兜底。 5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。 +每次同步在请求下一页前拒绝重复的非空续传游标,并保留上一代工具。空页无法通过工具名唯一性证明分页在前进,因此游标记录还会检测跨越多页的循环([问题报告](https://github.com/deepseek-ai/deepseek-harness/discussions/3660))。游标记录只属于一次同步:后续更新可以复用相同游标。定向桥接与生命周期测试覆盖循环拒绝、保留可调用工具、严格启动失败及通知恢复。此机制检测重复游标;它不限制持续返回不同游标的服务器。 + ### 公开名称规范化 MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名约定允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会坍缩为同一个公开名称: diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index 6c2a754dc0..2a65513f7d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.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-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3f6bcff565598c4d8a24e374d308c388c1f6bb0a -2026-09-04-right-sidebar-docking-infrastructure.zh.md: aea7aacc4cb50b8974ada4a7e7eec27ed41623b9 +2026-09-04-right-sidebar-docking-infrastructure.md: 936f73c9210baf4297eace2deb270bb9594d7e8d +2026-09-04-right-sidebar-docking-infrastructure.zh.md: d234dbcbb2f60ba1dd3b3d9fca2936a16521751d diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3f6bcff565..936f73c921 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -39,7 +39,7 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes; [Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes. -`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the current default page is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. +`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. ### Beyond the surface @@ -69,7 +69,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind **Undo and redo buttons on the panel header.** Shipped first, then removed: the sequence is an architectural fact, and stepping it is not a product action yet. The API stays reachable as `@internal` methods for tests and the future navigation controller. -**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied pane is merged away and an emptied root pane reseeds the current default page. +**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied side pane is merged away. An empty root receives the current default page only while the column is expanded. **Inline the kit through `packages/util` and the `INLINE_SAFE` list.** A build probe showed it works, but the util build chain has no CSS pipeline and the kit ships a stylesheet; the static-linked client package (the `ui-primitives` precedent) was chosen knowing that changing the kit means rebuilding the shell and reloading. @@ -77,7 +77,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split. - Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls. -- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane restores the current default page. +- An expanded surface has no empty panes. Empty side panes merge away; an empty root receives the current default page only while expanded. New sessions and a collapsed surface whose last tab closed remain empty until expansion. - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide. - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement. - The Sidebar panel never moves when the presentation switches, and its slide is the same in both presentations; the conversation is the only thing that animates on a switch. A hidden panel keeps its tabs mounted, so a preview survives a collapse. diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index aea7aacc4c..d234dbcbb2 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -39,7 +39,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 [默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。 -`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上当前默认页——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 +`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 ### 面之外 @@ -69,7 +69,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 **面板头部的 undo 与 redo 按钮。** 先上后撤:序列是架构事实,步进它现在还不是产品动作。API 以 `@internal` 方法保留给测试与将来的导航控制器。 -**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上当前默认页。 +**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的侧 pane 合并掉。空根 pane 只在该列展开时填入当前默认页。 **经 `packages/util` 与 `INLINE_SAFE` 清单内联库。** 构建探针证明可行,但 util 构建链没有 CSS 管线而库带样式表;在知晓改库须重建壳并刷新页面的前提下,选择静态链接的 client 包(`ui-primitives` 先例)。 @@ -77,7 +77,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。 - 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。 -- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会恢复当前默认页。 +- 展开的布局不保留空 pane。空侧 pane 被合并,空根 pane 只在展开时填入当前默认页。新会话以及关闭最后一个 tab 后收起的布局保持为空,直到下次展开。 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。 - 切换呈现模式时 Sidebar 面板一动不动,两种模式的平移一模一样;切换时只有会话区在动。隐藏的面板保持 tab 挂载,预览在折叠后仍在。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index 4aec891a9f..ddf0632164 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.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-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac -2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392 +2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc +2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index 8781990a40..6fa6194514 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. @@ -22,8 +22,8 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl ## Alternatives considered -- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. -- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category. +- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. +- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category. - **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. - **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 7b5fde4c87..0b15a6c72d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -13,7 +13,7 @@ Status: implemented 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 @@ -22,8 +22,8 @@ Status: implemented ## 备选方案 -- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。 -- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 +- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 +- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 - **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 - **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 831ed01531..4e4b039406 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 1a743c35634c5772e8173b770228a7f736a9d710 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 72253286862f1e8729683fb719e83cfcf544d8e8 +2026-09-05-sidebar-text-preview-and-file-tree.md: 9c0b6db832f19ba758ba2d3851db72f9a1fdb785 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: a5173c20b5203942df3c6f86b2bef5961946fa08 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 1a743c3563..9c0b6db832 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -24,7 +24,7 @@ The body is a centred column — a lead line (`侧栏用来放你想一直看着 The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident. -A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). Settling a surface reseeds the guide when the root pane empties, so there is always at least one tab and never an empty pane. +A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). An expanded empty root receives the current default page. Collapsed layouts may remain empty until expansion, when the default-page selection runs. ### The text preview diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index 7225328686..a5173c20b5 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -24,7 +24,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。 -一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。settle 一个 surface 时,根 pane 空了就重新播下引导页,于是永远至少有一个 tab、永远没有空 pane。 +一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,展开时才选择并创建默认页。 ### 文本预览 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 9d992637b1..9d3acb0e07 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb -2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2 +2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b +2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 6a8ebfcf1f..650c16fe5a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -10,13 +10,15 @@ Users need to open and edit the files produced in their workspace, including she ## Decision -The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. +The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. -An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. + +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered @@ -28,12 +30,12 @@ An authenticated POST selects a declaration by viewed Session, event sequence, a **Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. -**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks verify file metadata and path mapping; concurrent swap-and-restore remains outside the path API's guarantees. ## Consequences The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged. -The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. +The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names and descriptions, falling back to file types, without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. -Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, external paths and unavailable Host mappings, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d9361f2281..61bca82c83 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -10,13 +10,15 @@ Status: implemented ## 决策 -[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 -经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 + +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 @@ -28,12 +30,12 @@ Status: implemented **以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 -**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查验证文件元数据和路径映射;并发替换后复原仍不在路径 API 的保证范围内。 ## 影响 Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。 -仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 +仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称与说明,没有说明时回退到文件类型,并且不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml new file mode 100644 index 0000000000..bf23295de5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488 +2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md new file mode 100644 index 0000000000..f8c87cb325 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -0,0 +1,43 @@ +# Agent Note: Shared file-type icons + +Status: implemented + +English | [中文](2026-09-08-shared-file-type-icons.zh.md) + +## Problem + +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. + +## Decision + +`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. + +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. + +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. + +`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. + +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. + +## Alternatives considered + +**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. + +**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. + +**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. + +**Add archive, audio, and data categories for symmetry.** Rejected. The supplied artwork and current consumers require no such glyphs. New `FileType` members require a shipped render site and artwork that remains legible at the 28px seat. + +## Testing + +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. + +## Consequences + +- Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. +- A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. +- Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. +- The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md new file mode 100644 index 0000000000..ea115cd63b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 共享文件类型图标 + +Status: implemented + +[English](2026-09-08-shared-file-type-icons.md) | 中文 + +## 问题 + +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 + +## 决策 + +`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 + +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 + +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 + +`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 + +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 + +## 备选方案 + +**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 + +**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 + +**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 + +**为了对称增加 archive、audio 与 data 类别。** 否决。提供的图稿与当前消费方都不需要这些图形。新增 `FileType` 成员必须有已发布的渲染点,以及在 28px slot 中仍可辨认的图稿。 + +## 测试 + +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 + +## 后果 + +- 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 +- 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 +- 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 +- 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml index 0f2da39358..2846db77ba 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.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-09-08-sidebar-last-tab-close-rules.md -2026-09-08-sidebar-last-tab-close-rules.md: 4fb0e54af1075dd595d18db4d74779a7cfd2656b -2026-09-08-sidebar-last-tab-close-rules.zh.md: 87cadf0f15eecb0cadc2fb2dacc5695771fd66f7 +2026-09-08-sidebar-last-tab-close-rules.md: ed76b0ad0b8bbadcf5eace12a7da3bd38c733d0e +2026-09-08-sidebar-last-tab-close-rules.zh.md: bcc54c0c39cc65434f21ef8f6f81a382b6eecdb7 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md index 4fb0e54af1..ed76b0ad0b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md @@ -10,7 +10,7 @@ The settle planner guarantees the docked surface is never empty: closing the las ## Decision -The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and lets the settle planner prepare the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active. +The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and leaves the layout empty until the next expansion seeds the then-current default page. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active. Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md index 87cadf0f15..bcc54c0c39 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md @@ -10,7 +10,7 @@ settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播 ## 决定 -停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。 +停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。 两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。 diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml new file mode 100644 index 0000000000..5bb1142826 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md +2026-09-09-present-filesystem-access.md: 5c9d5bec47c73ac1c5952b887d56e582148a4cea +2026-09-09-present-filesystem-access.zh.md: 6387f07b17276b3455d19d650b4ae031a7efda4c diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md new file mode 100644 index 0000000000..5c9d5bec47 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md @@ -0,0 +1,27 @@ +# Agent Note: Present follows Session filesystem access + +Status: implemented + +English | [中文](2026-09-09-present-filesystem-access.zh.md) + +## Problem + +Generated files commonly live outside the workspace, especially in `/tmp`. Workspace containment rejects files that the Session filesystem and Sidebar already allow. A provider process path may also name a remote file rather than a file on the serving Host. + +## Decision + +`present` accepts existing regular files accessible through its composed `ctx.fs`, with relative paths resolved against the Session working directory. There is no workspace containment check or special temporary-directory allowlist. Missing files, directories, final symbolic links, and provider failures reject the declaration. A sandbox's private temporary files remain unavailable when the filesystem provider cannot see them. + +Native actions use the viewed Session header returned with the declaration to form the `workspaceFiles.stat` scope: its cwd, or the deployment workspace root when absent. The same composed filesystem serves Sidebar previews and native validation without activating an Agent, including for child Sessions. The resulting canonical process path must map from a Host path back to the same process path through that filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file. + +This replaces the workspace-only access rule in the [source-file delivery decision](2026-09-08-present-workspace-source-files.md). That note continues to own content-free declarations, Session events, and editing current sources. The request still selects only saved Session/event/file coordinates, never an arbitrary browser-supplied path. + +## Alternatives considered + +A `/tmp` allowlist excludes other readable output locations and duplicates filesystem policy. Treating every provider process path as a Host path can open an unrelated local file. Adding a generic inverse path-mapping API or copying remote files expands provider and retention responsibilities beyond source-file delivery. + +## Consequences + +Workspace files, accessible temporary files, Downloads, and files in another project use the same declaration rules. Native opening requires both a serving desktop and a verified Host path. Metadata checks do not make a desktop application's later path lookup atomic. + +Focused tests cover external absolute and relative paths, final symbolic links, missing files, Session lookup failures, absent and mismatched Host mappings, and localized native-unavailable state. Existing recorded Web scenarios retain declaration, preview, native action, and content-free export coverage. diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md new file mode 100644 index 0000000000..6387f07b17 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Present 遵循 Session 文件系统访问规则 + +Status: implemented + +[English](2026-09-09-present-filesystem-access.md) | 中文 + +## 问题 + +生成文件经常位于工作区外,尤其是 `/tmp`。工作区包含检查会拒绝 Session 文件系统和侧栏已经允许访问的文件。提供方的进程路径也可能指向远端文件,而非服务 Host 上的文件。 + +## 决策 + +`present` 接受所组合的 `ctx.fs` 可访问的已有普通文件,相对路径按 Session 工作目录解析。不检查工作区包含关系,也不另设临时目录白名单。文件缺失、为目录、最终路径为符号链接或提供方出错时,声明失败。文件系统提供方不可见的沙箱私有临时文件仍不可用。 + +原生操作使用与声明一起返回的当前 Session header,为 `workspaceFiles.stat` 提供工作目录:优先使用其中的 cwd,未记录时使用部署的工作目录。侧栏预览和原生校验使用同一组合文件系统,无需启动 Agent,子会话也适用。得到的规范化进程路径必须能通过该文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。 + +本决策替代[源文件交付决策](2026-09-08-present-workspace-source-files.zh.md)中只允许工作区文件的访问规则。原说明继续负责不保存内容的声明、Session 事件和编辑当前源文件。请求仍只按已保存的 Session、事件和文件索引选择文件,不接受浏览器提供的任意路径。 + +## 考虑过的替代方案 + +`/tmp` 白名单会排除其他可读的输出位置,并重复文件系统策略。把所有提供方进程路径都视作 Host 路径可能打开不相关的本机文件。增加通用反向路径映射 API 或复制远端文件,会让提供方与文件保留承担源文件交付之外的职责。 + +## 影响 + +工作区文件、可访问的临时文件、Downloads 和其他项目中的文件使用相同的声明规则。原生打开同时要求服务主机有可用桌面和经过验证的 Host 路径。元数据检查不能使桌面应用随后按路径打开文件的操作具有原子性。 + +定向测试覆盖工作区外的绝对和相对路径、最终符号链接、文件缺失、Session 查询失败、Host 映射缺失或不一致,以及本地化的原生操作不可用状态。已有的 Web 录制场景继续覆盖声明、预览、原生操作和不含文件内容的导出。 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml new file mode 100644 index 0000000000..530553e7cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md +2026-09-09-sidebar-and-preview-interaction-polish.md: df0e768aa1aea913344c356d84c2a7d3386ccc6b +2026-09-09-sidebar-and-preview-interaction-polish.zh.md: d3f8b00fd86ec12d867d48a917cd4a65cab18ba9 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md new file mode 100644 index 0000000000..df0e768aa1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md @@ -0,0 +1,35 @@ +# Agent Note: Sidebar and preview interaction polish + +Status: implemented + +English | [中文](2026-09-09-sidebar-and-preview-interaction-polish.zh.md) + +## Problem + +Five small interaction defects around the right Sidebar and the document preview. Every session's surface was born with a seeded default page, so a collapsed column the user never opened already held a page, and the first open into a fresh surface showed the seed beside the content it opened. Page tabs deduplicated surface-wide: opening a page whose tab sat in the other pane pulled focus across panes instead of opening it where asked. Dragging a pane's sole tab onto its own edge did nothing, though the user plainly asked for a split. A dropdown over the HTML preview did not dismiss on a click inside the sandboxed iframe, because that pointerdown never reaches the parent document. And the code preview's copy banner and card background scrolled away under horizontal scrolling, while the code kept the chat card's gray fill instead of sitting on the pane's own background. + +## Decision + +**Default pages seed lazily.** `createSurface` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) mints a collapsed, empty surface; the store's `advance` passes the seed factory to `planSettle` only when the intent leaves the column expanded. The expansion that would first show an empty layout is what seeds the then-current default page, and closing the last closable tab collapses the column and leaves the layout empty until the next expansion. The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) and [default-page selection](2026-09-08-sidebar-default-pages.md) stay as decided; default pages are created on expansion. Splitting an empty pane is a no-op: it mints no tabs, records no history, and reports no new pane. + +**Page uniqueness is pane-scoped.** The guide-only merge generalized to every page kind (`pageKind`/`panePage`): opening a page focuses an existing tab only inside the pane the open targets, and a page dragged, dropped, or docked into a pane already showing that kind's page merges into the pane's own. Resource tabs keep the kit's surface-wide reveal. + +**A sole tab splits its own pane when a factory backfills it.** `planDropTab` in [planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) takes an optional `TabFactory`; with one, the previously refused self-edge release splits, the factory's tab backfills the vacated pane before the move so the dragged tab ends focused. Without a factory the release still changes nothing. + +**Menus close on focus entering an iframe.** [Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) adds a window `blur` listener gated on `document.activeElement instanceof HTMLIFrameElement` — the focus move is the only signal a pointerdown inside a cross-origin iframe leaves, and the gate keeps app or tab switches from closing the list. + +**The code preview pins its banner and drops the card fill.** With wrap off, [CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) sizes the renderer `max-content` so the sticky banner has the full scroll width to ride, and pins the banner `sticky; left: 0; width: 100cqw` against the document scroller (`container-type: inline-size` on the preview body). The shared CodeBlock's fill is routed through a new `--dsl-code-block-background` variable (default unchanged, so chat keeps its gray card) and the shared banner carries an inert `data-code-block-banner` hook; the preview sets the variable to `transparent` so code sits on the pane's own background. + +## Alternatives considered + +**Keep seeding at surface creation.** A collapsed column held a page nobody asked for, and the seed took slot 0 of every fresh surface ahead of the first real open. + +**Keep surface-wide page dedupe.** Focus jumped to the other pane on an explicit "open here", the exact complaint that started the change. + +**A bare window-blur close for menus.** Closes the list on every app or tab switch; the `activeElement` gate scopes the close to the one case the document cannot see. + +**An inner code scroller for the horizontal axis.** Restoring `overflow-x: auto` on the `pre` keeps the banner still, but puts the horizontal scrollbar at the bottom of the whole block — unreachable in a long file — and both axes deliberately live in the document owner's scroller. + +## Consequences + +`planDropTab`'s factory parameter is new kit API any embedder may pass; `planSettle` already accepted an absent factory, which now also names the Sidebar's collapsed-state behavior. The `--dsl-code-block-background` variable and `data-code-block-banner` attribute are the code block's owner-styling seam; no shared stylesheet rule targets the attribute. Kit planner specs cover the backfilled self-split and its focus order; Sidebar store, service, and seat specs cover lazy seeding, pane-scoped page merges, and the empty collapsed layout; a Menu spec covers the gated blur close. The `ui-sidebar-right`, `ui-dockkit`, and `ui-sidebar-documentpreview` READMEs restate the rules. diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md new file mode 100644 index 0000000000..d3f8b00fd8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 侧边栏与文件预览交互打磨 + +Status: implemented + +[English](2026-09-09-sidebar-and-preview-interaction-polish.md) | 中文 + +## Problem + +右侧边栏与文档预览周边的五个小交互缺陷。每个会话的停靠面出生时就播种默认页,于是用户从未打开过的折叠列里已经躺着一个页面,向全新停靠面的第一次打开会让种子页出现在所开内容旁边。页 tab 在整个停靠面范围去重:打开一个页时,若它的 tab 在另一格里,焦点会被拽到那一格,而不是在被要求的格里打开。把一个格的唯一 tab 拖到本格边缘什么都不发生,尽管用户明明要的是分栏。HTML 预览上方的下拉菜单在点击沙箱 iframe 内部时不消失,因为那次 pointerdown 根本到不了父文档。代码预览的复制条和卡片背景会被横向滚动甩开,而且代码保留了会话卡片的灰色填充,而不是坐在分栏自身的背景上。 + +## Decision + +**默认页惰性播种。**[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 的 `createSurface` 铸造一个折叠且为空的停靠面;store 的 `advance` 只在意图让列保持展开时才把种子工厂传给 `planSettle`。首次会展示空布局的那次展开播种彼时的默认页;关闭最后一个可关 tab 时收起整列,布局保持为空直到下次展开。[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)与[默认页选择](2026-09-08-sidebar-default-pages.zh.md)维持原决定;默认页在展开时创建。对空分栏执行 split 不产生变化,不创建 tab,不记录历史,也不返回新分栏。 + +**页唯一性以格为界。**只对引导页的合并规则推广到每种页 kind(`pageKind`/`panePage`):打开一个页只在这次打开的目标格内聚焦既有 tab;把页拖入、放入或收回到已展示该 kind 页的格会并入该格自己的 tab。资源 tab 保留套件的全停靠面聚焦。 + +**有工厂回填时,唯一 tab 可对本格分栏。**[planner.ts](../../../../packages/client/ui-dockkit/src/engine/planner.ts) 的 `planDropTab` 接受可选 `TabFactory`:带工厂时,先前被拒绝的本格边缘释放会分栏,工厂的 tab 先于移动回填腾出的格,因此被拖的 tab 最终保持聚焦。不带工厂时该释放仍不改变任何东西。 + +**焦点进入 iframe 时关闭菜单。**[Menu.tsx](../../../../packages/client/ui-primitives/src/Menu.tsx) 增加 window `blur` 监听,以 `document.activeElement instanceof HTMLIFrameElement` 为门:焦点移动是跨源 iframe 内 pointerdown 留下的唯一信号,这道门也让应用或标签页切换不会误关列表。 + +**代码预览钉住复制条并去掉卡片填充。**关闭折行时,[CodeBody.module.css](../../../../packages/client/ui-sidebar-documentpreview/src/client/code/CodeBody.module.css) 把渲染器设为 `max-content`,让吸附的复制条拥有完整滚动宽度可骑行,并以 `sticky; left: 0; width: 100cqw` 把它钉在文档滚动区上(预览正文设 `container-type: inline-size`)。共享 CodeBlock 的填充改经新变量 `--dsl-code-block-background`(默认值不变,会话保持灰色卡片),共享复制条带上惰性的 `data-code-block-banner` 钩子;预览把变量设为 `transparent`,代码于是坐在分栏自身的背景上。 + +## Alternatives considered + +**保留出生即播种。**折叠列里躺着没人要的页面,且种子占据每个新停靠面的 0 号位,排在第一次真实打开之前。 + +**保留全停靠面的页去重。**显式"在这里打开"时焦点跳到另一格——正是引发这次改动的抱怨。 + +**菜单用裸的 window blur 关闭。**每次应用或标签页切换都会关掉列表;`activeElement` 门把关闭收窄到父文档看不见的那一种情形。 + +**代码横轴用内层滚动。**在 `pre` 上恢复 `overflow-x: auto` 能让复制条不动,但横向滚动条会落在整个代码块底部——长文件里够不着——而且两个轴本就有意放在文档 owner 的滚动区里。 + +## Consequences + +`planDropTab` 的工厂参数是任何嵌入方都可传的新套件 API;`planSettle` 本就接受缺省工厂,如今它同时命名了侧边栏的折叠态行为。`--dsl-code-block-background` 变量与 `data-code-block-banner` 属性是代码块的 owner 定制接缝;共享样式表没有任何规则指向该属性。套件 planner 规格覆盖带回填的本格分栏及其聚焦顺序;侧边栏 store、service 与 seat 规格覆盖惰性播种、格内页合并与折叠后的空布局;一条 Menu 规格覆盖带门的 blur 关闭。`ui-sidebar-right`、`ui-dockkit` 与 `ui-sidebar-documentpreview` 的 README 重述了这些规则。 diff --git a/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt new file mode 100644 index 0000000000..19a6f28bec --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt @@ -0,0 +1 @@ +Error: mcp-client(pagination-cycle): server repeated a tools/list continuation cursor — invalid tool list diff --git a/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts new file mode 100644 index 0000000000..e9e08bb302 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts @@ -0,0 +1,30 @@ +/** Startup diagnostics through the shipped headless profile and real MCP stdio transport. */ + +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url) +const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot)) +const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url)) + +it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'MCP discovery pagination cycle', + tempDirPrefix: 'dsh-mcp-pagination-', + binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), + libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)), + configPath, + binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'], + tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), + expectedExitCode: 1, + env: { + DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)), + DSH_TELEMETRY_DISABLED: '1', + }, + }) + expect(stdout).toBe('') + expect(stderr).toContain('initial connection or tool synchronization failed') + const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):')) + await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/apps/web/tests/expected/file-upload-round/draft.expected.md b/apps/web/tests/expected/file-upload-round/draft.expected.md index aa5ef30e82..59e438946d 100644 --- a/apps/web/tests/expected/file-upload-round/draft.expected.md +++ b/apps/web/tests/expected/file-upload-round/draft.expected.md @@ -2,6 +2,9 @@ - selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png - one attachment group: true +- file icon dimensions: 28 × 28 +- file icon color: rgb(207, 211, 214) +- file icon uses a solid fill: true - all cards share one row: true - every card is 64px high: true - the file card is wider than an image: true diff --git a/apps/web/tests/expected/file-upload-round/history.expected.md b/apps/web/tests/expected/file-upload-round/history.expected.md index 1427ef8004..4db5550793 100644 --- a/apps/web/tests/expected/file-upload-round/history.expected.md +++ b/apps/web/tests/expected/file-upload-round/history.expected.md @@ -2,6 +2,9 @@ - source order: poem.txt > reference-1.png - one attachment group: true +- file icon dimensions: 28 × 28 +- file icon color: rgb(207, 211, 214) +- file icon uses a solid fill: true - file and image share one row: true - both cards are 64px high: true - the image is a 64px tile: true diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index d39babbf16..5df27af9d9 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -38,6 +38,9 @@ const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${ /** Browser-measured relations for the mixed composer attachment rail. */ interface DraftRailGeometry { + readonly fileIconBox: string + readonly fileIconColor: string + readonly fileIconUsesSolidFill: boolean readonly order: readonly string[] readonly oneGroup: boolean readonly oneRow: boolean @@ -54,6 +57,9 @@ function renderDraftRailGeometry(geometry: DraftRailGeometry): string { '', `- selection order: ${geometry.order.join(' > ')}`, `- one attachment group: ${String(geometry.oneGroup)}`, + `- file icon dimensions: ${geometry.fileIconBox}`, + `- file icon color: ${geometry.fileIconColor}`, + `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`, `- all cards share one row: ${String(geometry.oneRow)}`, `- every card is 64px high: ${String(geometry.equalHeight)}`, `- the file card is wider than an image: ${String(geometry.fileWider)}`, @@ -64,6 +70,9 @@ function renderDraftRailGeometry(geometry: DraftRailGeometry): string { /** Browser-measured relations for one durable mixed-attachment message. */ interface HistoryAttachmentGeometry { + readonly fileIconBox: string + readonly fileIconColor: string + readonly fileIconUsesSolidFill: boolean readonly order: readonly string[] readonly oneGroup: boolean readonly oneRow: boolean @@ -81,6 +90,9 @@ function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): s '', `- source order: ${geometry.order.join(' > ')}`, `- one attachment group: ${String(geometry.oneGroup)}`, + `- file icon dimensions: ${geometry.fileIconBox}`, + `- file icon color: ${geometry.fileIconColor}`, + `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`, `- file and image share one row: ${String(geometry.oneRow)}`, `- both cards are 64px high: ${String(geometry.equalHeight)}`, `- the image is a 64px tile: ${String(geometry.imageIsTile)}`, @@ -145,12 +157,18 @@ describe('web e2e: generic file upload through the real assembly', () => { const rail = page.getByRole('group', { name: 'Pending attachments' }) await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }) .toBe(IMAGE_NAMES.length + 1) + await rail.locator('svg[viewBox="0 0 28 28"]').waitFor({ timeout: 15_000 }) const geometry = await rail.evaluate((element): DraftRailGeometry => { const cards = [...element.children] as HTMLElement[] const boxes = cards.map(card => card.getBoundingClientRect()) const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0 - const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0 + const fileCard = cards.find(card => card.querySelector('[title="poem.txt"]') !== null)! + const fileWidth = fileCard.getBoundingClientRect().width + const fileIcon = fileCard.querySelector('svg[viewBox="0 0 28 28"]')! return { + fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`, + fileIconColor: getComputedStyle(fileIcon).color, + fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor', order: cards.map(card => card.querySelector('img')?.getAttribute('alt') ?? card.querySelector('[title]')?.title ?? ''), oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1, @@ -247,7 +265,11 @@ describe('web e2e: generic file upload through the real assembly', () => { const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt') const imageBox = boxes[imageIndex] const fileBox = boxes[fileIndex] + const fileIcon = cards[fileIndex]!.querySelector('svg')! return { + fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`, + fileIconColor: getComputedStyle(fileIcon).color, + fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor', order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''), oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1, oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d51e08bb49..a73c9dd65b 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -403,14 +403,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // the right column either — the expanded terminal card is read in place. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - // Read summaries are file links: one click opens the file as a text-preview - // tab in the right Sidebar, which expands to show it beside the guide tab. + // Opening a file into the empty column creates only its preview tab. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['nav-a.md']) // Put the column back so later cases start from the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 4ff6a5b819..9fc93f131b 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -40,6 +40,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { } scaffold = await launchWebScaffold({ compareReplaySession: true, + extraOverlayPath: fileURLToPath(new URL('./present-svg.overlay.yml', import.meta.url)), ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }), }) browser = await chromium.launch() @@ -101,7 +102,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq && event.data.message.content.some(block => block.type === 'text'))).toBe(true) - const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE }) + const card = page.locator('[data-presented-file]').filter({ hasText: FILE }) await card.waitFor({ state: 'visible' }) expect(await card.count()).toBe(1) expect(await page.getByText('产物', { exact: true }).count()).toBe(0) @@ -114,6 +115,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) + await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present-svg.overlay.yml b/apps/web/tests/present-svg.overlay.yml new file mode 100644 index 0000000000..a575a00026 --- /dev/null +++ b/apps/web/tests/present-svg.overlay.yml @@ -0,0 +1,4 @@ +# This transcript includes the no-desktop state on every test platform. +- id: session-controller + config: + nativeOpen: false diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 52aee329fa..d1b4bc713c 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const events: SessionEvent[] = [] let nativeRoot: string | undefined let openLog: string - const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string | null; action: 'open' | 'reveal' }) const downloads: string[] = [] beforeAll(async () => { @@ -46,11 +46,14 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!${process.execPath} const fs = require('node:fs'); -fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, content: action === 'open' ? fs.readFileSync(path, 'utf8') : null }) + '\\n'); `, { mode: 0o700 }) vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) @@ -118,15 +121,26 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button').count()).toBe(2) + expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2) + expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') + const beforeReveal = (await opened()).length + await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + expect((await revealResponse).status()).toBe(204) + expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) + .evaluate(button => button === document.activeElement)).toBe(true) + await expect.poll(opened).toHaveLength(beforeReveal + 1) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') - await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click() + await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click() + await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } } const count = (await opened()).length @@ -135,7 +149,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect((await openedResponse).status()).toBe(204) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) @@ -175,7 +189,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg const beforeDelete = (await opened()).length await unlink(join(cwd, 'report.txt')) const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) - await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() expect((await missing).status()).toBe(404) await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() expect(await opened()).toHaveLength(beforeDelete) diff --git a/apps/web/tests/present.overlay.yml b/apps/web/tests/present.overlay.yml new file mode 100644 index 0000000000..a39a66dad9 --- /dev/null +++ b/apps/web/tests/present.overlay.yml @@ -0,0 +1,4 @@ +# Native commands are owned fixtures; desktop availability must be identical in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 84096583f9..a9d713b93b 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -420,7 +420,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt']) // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) const path = column.locator('[data-textpreview-path]') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0e6de7e442..ebd6b67f9d 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: 6c8ec4303ed641b653af7c33b0235ce3edfde4dc -config-catalog.zh.md: 6751aeb9b682fcceaf5c6c39bded8c10d59e5e51 +config-catalog.md: 0994c30c9d11f3b505d89df1e7e2bb7709d40144 +config-catalog.zh.md: 449e6b3521ebc189de9e787350d8f5e4dce7a046 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6c8ec4303e..0994c30c9d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts) @@ -3496,7 +3496,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6751aeb9b6..449e6b3521 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3498,7 +3498,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index ad3f023604..90458a7abf 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 3114d32853689ea267a923490a1a667a0a54b1f0 -event-producer-consumer.zh.md: 42915ab51d194d4827bf047135d39a9f3198a353 +event-producer-consumer.md: d129e9d4c2d965364bcf7146820a76ceec56ebc9 +event-producer-consumer.zh.md: 9c293f029dd7c4ebe3eae883c4001c9b5a5aa2f0 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3114d32853..d129e9d4c2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 42915ab51d..9c293f029d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d3294070f9..389c95a40b 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: bf70879f80f959c865eca17efbdbce3ef78fda2e -persistence-catalog.zh.md: 1332dfdc8778dde651eeedefab2f87b09d67c06e +persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68 +persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index bf70879f80..46e91f23a7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -403,7 +403,7 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio #### `deliverables/presented` — log-only ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 1332dfdc87..087915b83d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -405,7 +405,7 @@ export type SessionEvent = { #### `deliverables/presented` — 仅日志 ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 616359889e..c7296b9ca5 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: 1443f6c3e5795d5a02662f25c3b39525084bb085 -session.zh.md: 8e4ca4b4b5c5b113ff6989a92030e318a888f449 +session.md: 2b99ca8267d35978f245e6bdbf020226c976e363 +session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 1443f6c3e5..2b99ca8267 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -680,7 +680,7 @@ The backends that consume this contract are on [persistence.md](persistence.md). `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. -`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. @@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise @@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise Promise + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ readonly canOpenPath?: () => boolean } @@ -105,6 +108,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -132,6 +136,7 @@ export class SessionController extends TypertRemoteService { this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) this.openPath = internals.openPath ?? openNativePath + this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) @@ -268,6 +273,15 @@ export class SessionController extends TypertRemoteService { return this.canOpenPath() } + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } { + const fileManager = nativeFileManager() + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager } + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. @@ -289,7 +303,8 @@ export class SessionController extends TypertRemoteService { } signal.throwIfAborted() try { - await this.openPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(request.path, signal) + else await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 607baed3cf..60f7a93e56 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -358,6 +358,8 @@ export interface SessionCancelValue { /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: 'reveal' /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee199ddcdd..eb789cb374 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' @@ -138,3 +139,34 @@ describe('session/openWorkspacePath', () => { }) }) }) + + +it('reports Host file-manager metadata and dispatches reveal separately from default-app open', async () => { + const ctx = await context() + const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, + }) + try { + expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) + const signal = new AbortController().signal + await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) + expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(openPath).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('uses the native reveal adapter without a test override and respects unsupported desktop metadata', async () => { + const ctx = await context() + const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) + const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) + try { + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + }) + expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) + await controller.openWorkspacePath({ path: '/report.txt', action: 'reveal' }, new AbortController().signal) + expect(reveal).toHaveBeenCalledOnce() + } finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() } +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac992baced..721288f7dc 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -284,6 +285,7 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index e10c91993c..3692302325 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -1,4 +1,4 @@ -import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './FileCard.module.css' /** Localized strings consumed by one pending-file card. */ @@ -18,13 +18,7 @@ export interface FileCardLabels { /** Upload display state resolved by the owner. */ export type FileCardState = 'uploading' | 'ready' | 'error' -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - -/** One pending generic-file card: name, size or upload status, remove, retry. */ +/** One pending file card: type glyph, name, size or upload status, remove, retry. */ export function FileCard({ name, bytes, state, progress, labels, onRemove, onRetry, }: { @@ -36,7 +30,7 @@ export function FileCard({ onRemove: () => void onRetry: () => void }) { - const extension = extensionOf(name) + const extension = fileExtension(name).toUpperCase().slice(0, 8) const meta = state === 'uploading' ? labels.uploading : state === 'error' @@ -51,7 +45,7 @@ export function FileCard({ {state === 'uploading' ? - : } + : } {retryable ? ( diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index 8e6f01474e..c0d0920c52 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -263,4 +263,17 @@ describe('ComposerAttachments file drafts', () => { expect(group.textContent).toContain('文件') expect(group.textContent).toContain('3B') }) + + it('uses the shared leading-dot suffix in ready-file metadata', () => { + const view = render() + expect(view.getByTitle('.env').textContent).toContain('ENV 3B') + }) }) 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 2f44408b33..8507492a70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -317,7 +317,7 @@ .fileIcon { flex: none; - width: 24px; + width: 28px; height: 28px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index fc4f257048..f4e0fffa54 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -16,12 +16,6 @@ type PresentedAttachment = | { readonly type: 'image'; readonly image: MessageImageSource } | { readonly type: 'file'; readonly file: UserFile['attachment'] } -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - function contentParts(content: readonly unknown[]): { text: string attachments: PresentedAttachment[] @@ -206,11 +200,11 @@ function UserStyleBubble({ ) : ( - + {attachment.file.name} - {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)] .filter(Boolean).join(' ')} diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 5991449f44..a349f34576 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -1085,6 +1085,10 @@ describe('user file attachments', () => { expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') expect(view.getByTitle('tiny.txt').textContent).toContain('12B') expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + const icons = ['notes.pdf', 'tiny.txt', 'mid.csv'].map(name => + view.getByTitle(name).querySelector('svg')?.innerHTML, + ) + expect(new Set(icons).size).toBe(icons.length) expect(view.getByText('summarize these')).toBeTruthy() }) }) diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 0dcbdda890..3492d37686 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -5,7 +5,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' @@ -52,7 +52,7 @@ function queueAttachments(content: QueueRow['content']): Array< function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { return ( - + {attachment.name} {fileSizeText(attachment.bytes)} diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index a72595b84a..74d4c89ee7 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f -README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13 +README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4 +README.zh.md: 8e801a164c8a60de4aea8ff6a224e4823e2d3ccc diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 4886a5817e..857b71c5e0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host reads the viewed Session header with the declaration and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -96,7 +96,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. -- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -109,4 +109,4 @@ None. -**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the filesystem owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 43c68d4563..8e801a164c 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的 Session header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -96,7 +96,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 -- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 +- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 @@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 67970786a0..16534d6f8d 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -67,11 +67,20 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 97befd2946..5125edd381 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,14 +1,32 @@ -/** Immutable file deliveries at the end of a turn. */ -.root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } -.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } -.presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } -.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } -.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } -.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } -.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } -.details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } -.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } -.description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } -.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } -.file:disabled { cursor: wait; opacity: 0.65; } +/** Delivery cards use one full-width row or a two-column, four-card summary. */ +.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 16px; } +:global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } +.hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } +.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; min-width: 0; } +.presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); } +.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 72px; padding: 12px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } +.file:hover { background: var(--deliverable-hover); } +.cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } +.cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } +.fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } +.fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } +.details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } +.previewHint { display: none; } +.file:hover .secondaryText { display: none; } +.file:hover .previewHint { display: inline; } +.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 32px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-button-floating-fill); } +.menuAnchor { align-self: stretch; } +.open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); } +.open { padding: 5px 10px; font-size: 14px; line-height: 22px; } +.chevron { padding: 5px 6px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } +.open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } +.chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; } +.menuActionIcon { display: block; width: 16px; height: 16px; } +.toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; } +.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } +.toggle svg { flex: none; width: 14px; height: 14px; } +@container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } } +@media (pointer: coarse) { .split { min-height: 44px; } .open, .chevron { min-width: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 2faf259ad4..dac421a577 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,20 +1,28 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ +import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' -import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import { presentedFileUrl } from '../presented.ts' +import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } +const COLLAPSED_PRESENTED_COUNT = 4 + /** Native-open callbacks and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { - hooks: { presentedOpen: ObservableSnapshot> } + hooks: { + presentedOpen: ObservableSnapshot> + presentedHost: ObservableSnapshot> + } + reloadPresentedHost: PresentedOpenController['loadHost'] openPresented: PresentedOpenController['open'] } @@ -34,31 +42,42 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & InjectFace) { +} & PropsLocale & Pick & Pick & InjectFace) { + const [expanded, setExpanded] = useState(false) + const cwd = useSessions(state => state.byId[sessionId]?.cwd) const states = usePresentedOpen(value => value) + const host = usePresentedHost(value => value) + const collapsible = matched.presented.length > COLLAPSED_PRESENTED_COUNT + const presented = collapsible && !expanded + ? matched.presented.slice(0, COLLAPSED_PRESENTED_COUNT) + : matched.presented + useEffect(() => { + if (matched.presented.length > 0 && host === null) void reloadPresentedHost() + }, [matched.presented.length, host, reloadPresentedHost]) return <> {matched.produced.length > 0 && } {matched.presented.length > 0 &&
- {t('presented.label')} -
- {matched.presented.map((file) => { - const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] - return })} + {host === 'error' &&
+ {t('presented.hostError')} + +
} + {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}} +
+ {presented.map(file => { openFile(file.path) }} + onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
+ {collapsible && }
} } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx new file mode 100644 index 0000000000..ef848827e8 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -0,0 +1,85 @@ +/** File identity and explicit default-app or file-manager actions for one delivery. */ +import { useRef, useState } from 'react' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import { + Menu, FileTypeIcon, fileExtension, IconRightUpOutline16, + IconChevronDownOutline14, IconFolderOpenOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PresentedAction, PresentedHost } from '../presented.ts' +import type { PresentedOpenPhase } from './present-open.ts' +import { basename, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './Deliverables.module.css' + +function cardDescription(description: string | undefined, fallback: string): string { + const trimmed = description?.replace(/\s*(?:\([^()]*\)|([^()]*))\s*$/u, '').trim() + return trimmed === undefined || trimmed === '' ? fallback : trimmed +} + +/** + * Render independent file actions without nesting buttons inside a clickable card. + * @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy. + * @returns the file card and its anchored action menu. + */ +export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, t }: { + file: PresentedPath + cwd: string | undefined + phase: PresentedOpenPhase | undefined + host: PresentedHost | null + onPreview: () => void + onAction: (action: PresentedAction) => void +} & PropsLocale) { + const [menuOpen, setMenuOpen] = useState(false) + const previewRef = useRef(null) + const pending = phase === 'opening' || phase === 'revealing' + const menuDisabled = pending || host === null || !host.available + if (menuDisabled && menuOpen) setMenuOpen(false) + const reveal = host?.fileManager ?? 'directory' + const act = (action: PresentedAction) => { + setMenuOpen(false) + previewRef.current?.focus() + onAction(action) + } + const name = basename(file.path) + const metadata = fileExtension(name).toUpperCase() || t('presented.file') + const status = phase === undefined + ? cardDescription(file.description, metadata) + : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' + : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) + return
+ + { setMenuOpen(false) }} + anchor={} + items={[ + { id: 'open', icon: , + label: t('presented.defaultApp') }, + { id: 'reveal', icon: , + label: t(`presented.${reveal}`) }, + ]} + onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> +
+
+ +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 09156448b4..7ce723aa09 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-connection/client' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -41,6 +42,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + ctx.on('connection/reset', () => { opener.resetHost() }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -50,8 +52,9 @@ export function apply(ctx: ClientContext): void { select: selectDeliverables, locale: NS, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state }, - openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + hooks: { presentedOpen: opener.state, presentedHost: opener.host }, + reloadPresentedHost: () => opener.loadHost(), + openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), }), }, Deliverables), ) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index c206f2d23a..1e4d78f8c1 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,8 +5,29 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.label': '交付文件', + 'presented.nativeUnavailable': '此文件没有可用的主机路径,请在侧边栏预览', + 'presented.revealError': '无法在文件管理器中显示,请重试', + 'presented.directoryError': '无法打开所在文件夹,请重试', + 'presented.directoryOpening': '正在打开所在文件夹…', + 'presented.directoryOpened': '已请求打开所在文件夹', + 'presented.revealed': '已请求在文件管理器中显示', + 'presented.revealing': '正在文件管理器中显示…', + 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', + 'presented.retry': '重试', + 'presented.hostError': '无法读取主机桌面信息', + 'presented.directory': '打开所在文件夹', + 'presented.explorer': '在文件资源管理器中显示', + 'presented.finder': '在 Finder 中显示', + 'presented.defaultApp': '用默认应用打开', + 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', + 'presented.preview': '在侧边栏预览', + 'presented.previewButton': '在侧边栏打开 {name}', + 'presented.previewCard': '在侧边栏预览 {name}', + 'presented.all': '全部 {count} 个文件', + 'presented.expandAria': '展开全部 {count} 个交付文件', + 'presented.collapse': '收起', + 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', 'presented.error': '打开失败,点击重试', @@ -26,8 +47,29 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.label': 'Deliverables', + 'presented.nativeUnavailable': 'This file has no available Host path. Preview it in the sidebar.', + 'presented.revealError': 'Could not show in file manager. Try again.', + 'presented.directoryError': 'Could not open containing folder. Try again.', + 'presented.directoryOpening': 'Opening containing folder…', + 'presented.directoryOpened': 'Requested opening containing folder', + 'presented.revealed': 'Requested display in file manager', + 'presented.revealing': 'Showing in file manager…', + 'presented.unavailable': 'This Host has no desktop available to open files or folders', + 'presented.retry': 'Retry', + 'presented.hostError': 'Could not read the Host desktop information', + 'presented.directory': 'Open containing folder', + 'presented.explorer': 'Show in File Explorer', + 'presented.finder': 'Show in Finder', + 'presented.defaultApp': 'Open in default app', + 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', + 'presented.preview': 'Preview in sidebar', + 'presented.previewButton': 'Open {name} in sidebar', + 'presented.previewCard': 'Preview {name} in sidebar', + 'presented.all': 'All {count} files', + 'presented.expandAria': 'Show all {count} delivered files', + 'presented.collapse': 'Collapse', + 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', 'presented.error': 'Could not open. Click to retry.', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 5b49941a57..8e66b380c0 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -1,31 +1,37 @@ /** Shared native-open status for delivery cards and closing-message file mentions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { presentedFileUrl } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'error' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ readonly state = createSnapshotStore>({}) + /** Native destination metadata, or a retryable read failure. */ + readonly host = createSnapshotStore(null) + private loading: Promise | undefined + private metadata = new AbortController() private readonly lifetime = new AbortController() private readonly pending = new Set>() /** - * Open a declared workspace file once while a request for the same coordinates is pending. + * Open a declared file once while a request for the same coordinates is pending. * Failures remain visible on the card and a later gesture retries them. * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. * @param index - original file index within that event. + * @param action - default application open or file-manager reveal. * @returns after the Host acknowledges opening or the error state is published. */ - async open(sessionId: SessionId, seq: number, index: number): Promise { + async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { const url = presentedFileUrl(sessionId, seq, index) - if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return - this.state.update((state) => { state[url] = 'opening' }) - const task = this.request(url) + const phase = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) + const task = this.request(url, action) this.pending.add(task) try { await task @@ -34,20 +40,63 @@ export class PresentedOpenController { } } + /** + * Read the serving desktop metadata, coalescing concurrent reads; a later call retries failure. + * @returns after metadata or a retryable error is published. + */ + async loadHost(): Promise { + if (this.lifetime.signal.aborted) return + if (this.loading !== undefined) return this.loading + this.host.set(null) + const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) + this.loading = task + this.pending.add(task) + try { await task } + finally { + if (this.loading === task) this.loading = undefined + this.pending.delete(task) + } + } + + /** Invalidate desktop metadata on connection replacement; mounted cards request the new Host. */ + resetHost(): void { + const wasLoading = this.loading !== undefined + this.metadata.abort() + this.metadata = new AbortController() + this.loading = undefined + this.host.set(null) + if (wasLoading) void this.loadHost() + } + + private async readHost(signal: AbortSignal): Promise { + let host: PresentedHost | 'error' = 'error' + try { + const response = await fetch(PRESENT_HOST_PATH, { signal }) + if (response.ok) { + const value: unknown = await response.json() + if (isPresentedHost(value)) host = value + } + } catch { + host = 'error' + } + if (!signal.aborted) this.host.set(host) + } + /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() await Promise.all(this.pending) } - private async request(url: string): Promise { - let phase: PresentedOpenPhase = 'opened' + private async request(url: string, action: PresentedAction): Promise { + const failure = action === 'open' ? 'error' : 'revealError' + let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = 'error' + const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure } catch { // Transport failures share the retryable card state with Host open failures. - phase = 'error' + phase = failure } if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) } diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 4239299e63..04e5e7a8dd 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,7 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that * lets the browser half recognize final-response file references and serves - * authenticated native opens of workspace files. The browser + * authenticated native opens of declared files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -10,8 +10,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' -/** Services required for file-reference guidance and authenticated native opens of workspace files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] +/** Services required for file-reference guidance and authenticated native opens of declared files. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index beac77657a..9589ef0006 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -1,18 +1,25 @@ -/** Open declared source files inside the viewed Session's workspace. */ -import { realpath, stat } from 'node:fs/promises' -import { isAbsolute, relative, resolve, sep } from 'node:path' +/** Open declared source files verified by the viewed Session's filesystem. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-api-workspace-files' +import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register native opening inside Connection's authentication fence. * @param ctx - Session lookup, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { + ctx.connection.fetch.register({ + path: PRESENT_HOST_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost, + { headers: { 'cache-control': 'no-store' } })), + }) const lifetime = new AbortController() const pending = new Set>() ctx.effect(() => async () => { @@ -36,6 +43,8 @@ export function registerPresentOpen(ctx: Context): void { async function handlePresentOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams + const action = query.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const id = query.get('sessionId') const seq = query.get('seq') const index = query.get('index') @@ -45,27 +54,32 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise + return typeof host.name === 'string' && typeof host.available === 'boolean' + && (host.fileManager === null || host.fileManager === 'finder' + || host.fileManager === 'explorer' || host.fileManager === 'directory') +} + /** * Validate a file declaration read from a Session log. * @param value - decoded durable data. diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index b201895445..bf990969ab 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -61,3 +61,103 @@ it('awaits cancellation and prevents late state publication or new requests afte await controller.open(id, 2, 1) expect(fetcher).toHaveBeenCalledOnce() }) + + +it('shares pending state across open and reveal and retries the selected action', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(reply.promise).mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const revealing = controller.open(id, 2, 1, 'reveal') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('revealing') + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`) + reply.resolve(new Response(null, { status: 500 })) + await revealing + expect(controller.state.getSnapshot()[url]).toBe('revealError') + await controller.open(id, 2, 1, 'reveal') + expect(controller.state.getSnapshot()[url]).toBe('revealed') + await controller.dispose() +}) + +it.each([null, {}, { name: 'host', available: 'yes', fileManager: 'finder' }, + { name: 'host', available: true, fileManager: 'unknown' }, 'invalid json', 'http', 'network', +])('makes invalid Host metadata retryable: %j', async (value) => { + const host = { name: 'linux-host', available: true, fileManager: 'directory' } + const fetcher = vi.fn() + if (value === 'network') fetcher.mockRejectedValueOnce(new Error('offline')) + else if (value === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else if (value === 'invalid json') fetcher.mockResolvedValueOnce(new Response('bad JSON')) + else fetcher.mockResolvedValueOnce(Response.json(value)) + fetcher.mockResolvedValueOnce(Response.json(host)) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + expect(controller.host.getSnapshot()).toBe('error') + await controller.loadHost() + expect(controller.host.getSnapshot()).toEqual(host) + await controller.dispose() + await controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) +}) + +it('coalesces metadata reads and suppresses their publication after disposal', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.loadHost() + const second = controller.loadHost() + expect(fetcher).toHaveBeenCalledOnce() + const disposal = controller.dispose() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + reply.resolve(Response.json({ name: 'host', available: false, fileManager: null })) + await Promise.all([first, second, disposal]) + expect(controller.host.getSnapshot()).toBeNull() +}) + + +it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + controller.resetHost() + expect(controller.host.getSnapshot()).toBeNull() + expect(fetcher).toHaveBeenCalledOnce() + fetcher.mockResolvedValue(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await controller.loadHost() + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new', available: true }) + await controller.dispose() +}) + +it('discards a replaced Host response and keeps the new metadata request coalesced', async () => { + const oldReply = Promise.withResolvers() + const newReply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValue(newReply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const oldLoad = controller.loadHost() + controller.resetHost() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + const newLoad = controller.loadHost() + oldReply.resolve(Response.json({ name: 'old', available: false, fileManager: null })) + await oldLoad + expect(controller.host.getSnapshot()).toBeNull() + const coalesced = controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) + newReply.resolve(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await Promise.all([newLoad, coalesced]) + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' }) + await controller.dispose() +}) + + +it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s while retaining the declaration', async (action) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 422 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1, action) + expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b9319587b6..1282a47b85 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -2,6 +2,8 @@ import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files' import { Context } from '@deepseek-ai/cordis' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' @@ -30,22 +32,29 @@ async function fixture() { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) const session: { cwd?: string } = { cwd } + await ctx.plugin(LocalFileSystem, { cwd }) + ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never) + await ctx.plugin({ + inject: ['fs', 'sandboxPolicy'], + apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) }, + }) + const resolveAgent = vi.fn(() => { throw new Error('Agent activation is unavailable') }) const readEvent = vi.fn(async (request: SessionEventReadRequest) => { if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } }) ctx.provide('sessionQuery', { readEvent } as never) - const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) await fiber const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, )) - return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler } + return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } } describe('Presented workspace file native open route', () => { @@ -75,7 +84,8 @@ describe('Presented workspace file native open route', () => { const fork = join(root, 'fork') await mkdir(fork) await writeFile(join(fork, file.path), 'child source') - readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) + session.cwd = fork + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204) expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path))) }) @@ -109,30 +119,29 @@ describe('Presented workspace file native open route', () => { expect(opener).not.toHaveBeenCalled() }) - it('reports removed files, directories, and absent workspaces without launching', async () => { - const { cwd, file, session, open, opener } = await fixture() + it('reports removed files and directories without launching', async () => { + const { cwd, file, open, opener } = await fixture() await unlink(join(cwd, file.path)) expect((await open()).status).toBe(404) file.path = '.' expect((await open()).status).toBe(404) - delete session.cwd - expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() }) - it('refuses traversal and a source replaced by a symlink outside the workspace', async () => { + it('opens external regular files through absolute and relative paths but refuses final symlinks', async () => { const { root, cwd, file, open, opener } = await fixture() const outside = join(root, 'outside.txt') await writeFile(outside, 'outside') const source = join(cwd, file.path) await unlink(source) await symlink(outside, source) - expect((await open()).status).toBe(403) - file.path = '../outside.txt' - expect((await open()).status).toBe(403) - file.path = outside - expect((await open()).status).toBe(403) + expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() + for (const path of ['../outside.txt', outside]) { + file.path = path + expect((await open()).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside)) + } }) it('reports query and launcher failures without leaking Host paths and allows retry', async () => { @@ -176,3 +185,51 @@ describe('Presented workspace file native open route', () => { await Promise.all([request, disposal]) }) }) + + +it('reports the serving desktop and reveals only an authorized declared source', async () => { + const { cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host')) + expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) + expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) + expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) + file.path = '..' + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(404) + expect(opener).toHaveBeenCalledOnce() +}) + +it('refuses native actions when the configured Host desktop is unavailable', async () => { + const { ctx, open, opener, handler } = await fixture() + vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + for (const action of ['open', 'reveal']) { + expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) + } + expect(opener).not.toHaveBeenCalled() +}) + + +it('refuses native opening without a matching Host mapping even when a same-name Host file exists', async () => { + const { ctx, open, opener } = await fixture() + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await open()).status).toBe(422) + mapping.mockReturnValue('/another-filesystem/file') + expect((await open()).status).toBe(422) + expect(opener).not.toHaveBeenCalled() +}) + +it('opens a viewed child Session without activating an Agent', async () => { + const { session, readEvent, open, opener, resolveAgent } = await fixture() + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'child', files: [{ path: '日记模板.docx' }] } } as SessionEvent }) + expect((await open('?sessionId=child&seq=7&index=0')).status).toBe(204) + expect(opener).toHaveBeenCalledOnce() + expect(resolveAgent).not.toHaveBeenCalled() +}) + +it('uses the deployment workspace root when the viewed Session has no cwd', async () => { + const { session, open, cwd, file, opener } = await fixture() + delete session.cwd + expect((await open()).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path))) +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx new file mode 100644 index 0000000000..28207b5a29 --- /dev/null +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +/** Explicit file actions preserve their destination, availability, and independent failure state. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +const props = () => ({ + cwd: undefined, + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onPreview: vi.fn(), + onAction: vi.fn(), + t: makeTranslate(en), +}) + +it.each([ + ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], +] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) + expect(p.onAction).toHaveBeenCalledWith('reveal') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) + expect(p.onAction).toHaveBeenLastCalledWith('open') + expect(p.onAction).toHaveBeenCalledTimes(2) +}) + +it('dismisses the menu with Escape or an outside click without launching anything', () => { + const p = props() + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() + expect(p.onAction).not.toHaveBeenCalled() +}) + +it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => { + const p = props() + const view = render() + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('keeps the native menu disabled until a desktop is available', () => { + const p = props() + const view = render() + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false) + view.rerender() + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) +}) + +it('opens the right sidebar from either the card or its primary button', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('localizes reveal failures and accurately reports a directory-only action', () => { + const p = props() + const view = render() + expect(view.getByText(zh['presented.revealError'])).toBeTruthy() + view.rerender() + expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy() + view.rerender() + expect(view.getByText(en['presented.revealed'])).toBeTruthy() +}) + + +it('supports keyboard selection and returns focus to the trigger on Escape', () => { + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) +}) + + +it('shows the basename while retaining the full location for hover and actions', () => { + const p = props() + const path = '/work/reports/result.pdf' + const view = render() + expect(view.getByTitle(path)).toBeTruthy() + expect(view.getByText('result.pdf')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) + expect(p.onAction).toHaveBeenCalledWith('open') + view.rerender() + expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy() + expect(view.getByText('report.pdf')).toBeTruthy() +}) + +it.each([ + ['Quarterly summary (.pdf)', 'Quarterly summary'], + ['季度总结(PDF)', '季度总结'], +] as const)('omits a trailing parenthesized file suffix from %s', (description, expected) => { + const p = props() + const view = render() + expect(view.getByText(expected)).toBeTruthy() + expect(view.queryByText(description)).toBeNull() +}) + + +it('does not reopen a menu after a shared native request settles', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByRole('menu')).toBeTruthy() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() +}) + +it('keeps focus on the available preview button after selecting a native action', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + const item = view.getByRole('menuitem', { name: 'Show in Finder' }) + item.focus() + fireEvent.click(item) + view.rerender() + const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) + expect(document.activeElement).toBe(preview) + view.rerender() + expect(document.activeElement).toBe(preview) +}) + +it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { + const p = { ...props(), t: makeTranslate(dictionary) } + const view = render() + expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 15c95017f3..6a70186f27 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionLiveEventEntry, SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { ConversationNodeAssembler, UiConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -34,7 +34,13 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController()) { + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined } return { + useSessions: (select: (state: SessionListState) => T): T => select(sessions), + reloadPresentedHost: vi.fn(() => controller.loadHost()), + usePresentedHost: (select: (state: ReturnType) => T): T => + select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), @@ -549,12 +555,24 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() + expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) + ctx.emit('connection/reset') + expect(face.hooks.presentedHost.getSnapshot()).toBeNull() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() + fetcher.mockResolvedValueOnce(Response.json({ name: 'last-host', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() await fiber.dispose() + const reset = vi.fn() + const unsubscribe = face.hooks.presentedHost.subscribe(reset) + ctx.emit('connection/reset') + expect(reset).not.toHaveBeenCalled() + unsubscribe() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. @@ -581,20 +599,33 @@ describe('presented files', () => { expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() }) - it('uses the viewed fork Session in every open action and retains all delivered files', () => { + it('uses the viewed fork Session in every open action and expands all delivered files', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }), ]) - const owner = tailOwner(deliverablesOf(value), 3) + const preview = vi.fn() + const owner = tailOwner(deliverablesOf(value), 3, preview) const matched = selectDeliverables(owner)! const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) + const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) + expect(expand.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(expand) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(8) + expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true') expect(view.queryByRole('link')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) - expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) + fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' })) + expect(preview).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenLastCalledWith('report-0.docx') + fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') + fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -621,15 +652,37 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] }, expect(view.queryByText('Deliverables')).toBeNull() }) -it('shows file metadata and descriptions without hiding extensionless deliveries', () => { +it('shows descriptions and falls back to file metadata without hiding extensionless deliveries', () => { const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText('Quarterly summary')).toBeTruthy() - expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') + expect(view.getByTitle('out/report.txt')).toBeTruthy() + expect(view.getByText('report.txt')).toBeTruthy() +}) + +it('distinguishes PDF, Word, Markdown, and code files with full-size decorative card icons', () => { + const paths = ['report.pdf', 'report.docx', 'README.md', 'index.tsx'] + const view = render( ({ path, seq: 2, index })), + }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + const icons = [...view.container.querySelectorAll('[data-presented-file]')].map((card) => { + const icon = card.querySelector('svg')! + expect(icon.getAttribute('aria-hidden')).toBe('true') + expect(icon.getAttribute('width')).toBe('28') + return icon.innerHTML + }) + expect(new Set(icons).size).toBe(paths.length) +}) + +it('lets one delivered file span the complete row without an expansion control', () => { + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.container.querySelector('[data-presented-files-row]')?.getAttribute('data-single')).toBe('true') + expect(view.queryByRole('button', { name: /delivered files/ })).toBeNull() }) @@ -640,6 +693,34 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) - expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() + expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) + + +it('explains a missing desktop and retries failed Host metadata', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] } + controller.host.set('error') + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + props.reloadPresentedHost.mockResolvedValue(undefined) + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() + controller.host.set({ name: 'server', available: false, fileManager: null }) + view.rerender( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText(en['presented.unavailable'])).toBeTruthy() +}) + + +it('loads desktop information only when delivery cards appear', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + controller.host.set(null) + props.reloadPresentedHost.mockResolvedValue(undefined) + const shared = { ...props, openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) } + const view = render() + expect(props.reloadPresentedHost).not.toHaveBeenCalled() + view.rerender() + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 8f342099fd..b56bfa3b8e 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,9 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) + ctx.provide('workspaceFiles', {} as never) + ctx.provide('fs', {} as never) + ctx.provide('sandboxPolicy', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json index 3b8f95c87c..8206c090cb 100644 --- a/packages/client/ui-deliverables/tsconfig.client.json +++ b/packages/client/ui-deliverables/tsconfig.client.json @@ -52,6 +52,9 @@ }, { "path": "../../fs/tool-present" + }, + { + "path": "../../util/workspace-path" } ] } diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index 0123290583..289e7df535 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -34,6 +34,18 @@ }, { "path": "../../api/session-controller/tsconfig.host.json" + }, + { + "path": "../../api/workspace-files/tsconfig.host.json" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../sandbox/sandbox-policy" } ] } diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index f2fbcc914d..d7e58f30ee 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/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-dockkit/README.md -README.md: 396253d771196380d04da13cfc0843584b37fbc2 -README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507 +README.md: 41100530b77f9776c768010b53ecbc6ad67b5902 +README.zh.md: 237ce5f0aedf1124e4dc3a214bf10529dea1703f diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 396253d771..41100530b7 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -34,7 +34,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `applyOp(state, op)` returns the next state **and the operations that undo it**. Inverses are captured when an operation runs, because by undo time the pre-operation state is gone. - Every operation carries the ids it creates, so `replay(initial, ops)` reproduces the same tree. The engine reads no clock and no random source. - `Sequencer` keeps a linear history with one entry per intent: the operations one gesture or command produced step back and forward together, a run of consecutive focus-only entries steps as one, and a new entry after stepping back drops the forward branch. -- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. +- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). **The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index ec2d5077ef..237ce5f0ae 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" - `applyOp(state, op)` 返回下一状态**以及撤销它的操作**。逆操作在操作执行时捕获,因为到撤销时操作前的状态已经不存在了。 - 每个操作都携带它创建的 id,因此 `replay(initial, ops)` 能复现同一棵树。引擎不读时钟,也不读随机源。 - `Sequencer` 维护一条线性历史,每个意图一条记录:一次手势或命令产生的操作一起后退、一起前进,连续的纯焦点记录作为一步,后退后的新记录会丢弃前进分支。 -- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 +- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 **组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 diff --git a/packages/client/ui-dockkit/src/engine/planner.ts b/packages/client/ui-dockkit/src/engine/planner.ts index 1c61ab94c2..1657537144 100644 --- a/packages/client/ui-dockkit/src/engine/planner.ts +++ b/packages/client/ui-dockkit/src/engine/planner.ts @@ -255,13 +255,16 @@ export function planPlaceTab( /** * Resolve a tab release on a pane body: the centre moves the tab in, an edge * splits the pane and seats the tab in the new half. A pane's only tab released - * on that pane changes nothing in either zone: the split would empty the pane - * and seat the tab beside where it already was. + * on that pane's centre changes nothing; released on its edge it splits, and + * the factory's tab backfills the pane the drag would otherwise empty — without + * a factory that release also changes nothing, since the split would empty the + * pane and seat the tab beside where it already was. * @param state - current layout. * @param mint - id source for a pane an edge release creates. * @param tabId - the dragged tab. * @param targetPaneId - pane under the pointer. * @param zone - dock region the pointer released in. + * @param makeTab - builds the tab that backfills a pane its only tab splits away from. * @returns the operations, or none when the release changes nothing. */ export function planDropTab( @@ -270,6 +273,7 @@ export function planDropTab( tabId: TabId, targetPaneId: PaneId, zone: DockZone, + makeTab?: TabFactory, ): readonly LayoutOp[] { const source = findTabPane(state, tabId) const target = getPane(state, targetPaneId) @@ -281,20 +285,25 @@ export function planDropTab( return [tabInto(source, tabId, targetPaneId, target.tabs.length)] } - if (source.id === targetPaneId && source.tabs.length === 1) return NOTHING + const vacates = source.id === targetPaneId && source.tabs.length === 1 + if (vacates && makeTab === undefined) return NOTHING if (!canSplit(state)) return NOTHING const newPaneId = mint('pane') - return [ - { - type: 'split', - paneId: targetPaneId, - axis: split.axis, - direction: split.direction, - newPaneId, - newSplitId: mint('split'), - }, - tabInto(source, tabId, newPaneId, 0), - ] + const ops: LayoutOp[] = [{ + type: 'split', + paneId: targetPaneId, + axis: split.axis, + direction: split.direction, + newPaneId, + newSplitId: mint('split'), + }] + // The backfill seats before the move so the moved tab ends focused, as any + // other drop leaves it. + if (vacates && makeTab !== undefined) { + ops.push({ type: 'openTab', paneId: targetPaneId, tab: makeTab(mint('tab')), index: source.tabs.length }) + } + ops.push(tabInto(source, tabId, newPaneId, 0)) + return ops } /** diff --git a/packages/client/ui-dockkit/tests/planner.client.spec.ts b/packages/client/ui-dockkit/tests/planner.client.spec.ts index 16d7fcb339..fa483964dc 100644 --- a/packages/client/ui-dockkit/tests/planner.client.spec.ts +++ b/packages/client/ui-dockkit/tests/planner.client.spec.ts @@ -133,7 +133,7 @@ describe('planPlaceTab', () => { }) describe('planDropTab on the tab\'s own pane', () => { - it('plans nothing for a pane\'s only tab released on any of its own edges', () => { + it('plans nothing for a pane\'s only tab released on any of its own edges without a factory', () => { const { state, minter } = seededState() const mint = minter.next const tabId = getPane(state, state.rootId).tabs[0] @@ -143,12 +143,33 @@ describe('planDropTab on the tab\'s own pane', () => { } }) - it('splits the pane when it keeps another tab', () => { + it('splits on a sole tab\'s own edge when a factory backfills the pane it vacates', () => { + const { state, minter } = seededState() + const mint = minter.next + const paneId = getPane(state, state.rootId).id + const tabId = getPane(state, paneId).tabs[0] + if (tabId === undefined) throw new Error('fixture: seeded tab missing') + const ops = planDropTab(state, mint, tabId, paneId, 'right', seedTab) + expect(ops.map(op => op.type)).toEqual(['split', 'openTab', 'moveTab']) + const split = applyAll(state, ops) + const [home, destination] = dockPaneIds(split) + if (home === undefined || destination === undefined) throw new Error('expected two panes') + expect(getPane(split, home).tabs).toHaveLength(1) + expect(getPane(split, home).tabs[0]).not.toBe(tabId) + expect(getPane(split, destination).tabs).toEqual([tabId]) + // The backfill seats first, so the moved tab ends focused. + expect(split.activePaneId).toBe(destination) + }) + + it('splits without a backfill when the pane keeps another tab, factory or not', () => { const { state, minter } = seededState() const mint = minter.next const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const two = applyAll(state, opened.ops) - const ops = planDropTab(two, mint, opened.tabId, getPane(two, two.rootId).id, 'right') + const target = getPane(two, two.rootId).id + expect(planDropTab(two, mint, opened.tabId, target, 'right') + .map(op => op.type)).toEqual(['split', 'moveTab']) + const ops = planDropTab(two, mint, opened.tabId, target, 'right', seedTab) expect(ops.map(op => op.type)).toEqual(['split', 'moveTab']) const split = applyAll(two, ops) expect(dockPaneIds(split)).toHaveLength(2) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..5672f45bb3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: aebb0be860ebaba4543a8fd262599507ead64556 +README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..aebb0be860 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. Callers must supply localized labels, and the components rely only on React and `--dsw-*` design tokens. +Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. The components import no Cordis runtime; callers supply localized labels, and theme-facing colors use `--dsw-*` design tokens. ## Table of Contents @@ -54,8 +54,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | -| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. | +| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. | Three pairs are easy to confuse: @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the full-color square technology artwork for recognized code and configuration files. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Office mappings include XLSM/Numbers as Excel, KEY as slides, and RTF/ODT/Pages as documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..a545aff983 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。调用方必须提供本地化 label;这些组件仅依赖 React 和 `--dsw-*` 设计 token。 +使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。组件不 import Cordis 运行时;调用方提供本地化 label,主题相关颜色使用 `--dsw-*` 设计 token。 ## 目录 @@ -54,8 +54,8 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | -| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 | +| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 | 有三组容易混淆: @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为已识别的代码和配置文件使用全彩方形技术图形。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。办公文件映射包含 XLSM/Numbers 的表格图标、KEY 的幻灯片图标,以及 RTF/ODT/Pages 的文档图标。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/CodeFileIcon.tsx b/packages/client/ui-primitives/src/CodeFileIcon.tsx new file mode 100644 index 0000000000..a6f5608153 --- /dev/null +++ b/packages/client/ui-primitives/src/CodeFileIcon.tsx @@ -0,0 +1,491 @@ +import { useId } from 'react' +import type { ReactNode } from 'react' +import type { IconProps } from './icons/props.ts' +import type { CodeFileType } from './code-file-types.ts' + +/** Non-localizable letterforms that are part of the source-format artwork. */ +const CODE_MARKS = { + c: 'C', + cpp: 'C', + csharp: 'C', + css: 'CSS', + env: 'ENV', + erlang: 'E', + ini: 'INI', + javascript: 'JS', + objectiveC: 'OC', + perl: 'PL', + php: 'PHP', + r: 'R', + toml: 'TOML', + typescript: 'TS', + yaml: 'YAML', + zig: 'ZIG', +} as const + +/** + * Render one full-color square code-file glyph from the embedded icon set. + * @param props - Detailed code type, optional size, and optional CSS class. + * @returns The selected decorative SVG with its identifying palette intact. + */ +export function CodeFileIcon({ type, size = 20, className }: IconProps & { readonly type: CodeFileType }): ReactNode { + const gradientId = useId() + return ( + + {codeFileArtwork(type, gradientId)} + + ) +} + +function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { + switch (type) { + case 'angular': + return ( + <> + + + + + ) + case 'c': + return ( + <> + + + + {CODE_MARKS.c} + + + ) + case 'clojure': + return ( + <> + + + + + + + ) + case 'cmake': + return ( + <> + + + + + + + ) + case 'cpp': + return ( + <> + + + + {CODE_MARKS.cpp} + + + + ) + case 'csharp': + return ( + <> + + + + {CODE_MARKS.csharp} + + + + ) + case 'css': + return ( + <> + + + {CODE_MARKS.css} + + + ) + case 'dart': + return ( + <> + + + + + ) + case 'docker': + return ( + <> + + + + + + + ) + case 'elixir': + return ( + <> + + + + + + + + + + + ) + case 'env': + return ( + <> + + + {CODE_MARKS.env} + + + ) + case 'erlang': + return ( + <> + + + + {CODE_MARKS.erlang} + + + ) + case 'flutter': + return ( + <> + + + + + ) + case 'git': + return ( + <> + + + + + + + + ) + case 'go': + return ( + <> + + + + ) + case 'graphql': + return ( + <> + + + + + + + + + + + + ) + case 'haskell': + return ( + <> + + + + ) + case 'ini': + return ( + <> + + + {CODE_MARKS.ini} + + + ) + case 'java': + return ( + <> + + + + + ) + case 'javascript': + return ( + <> + + + {CODE_MARKS.javascript} + + + ) + case 'json': + return ( + <> + + + {'{ }'} + + + ) + case 'kotlin': + return ( + <> + + + + + + + + + + + ) + case 'lua': + return ( + <> + + + + + + + ) + case 'makefile': + return ( + <> + + + + ) + case 'node': + return ( + <> + + + + + ) + case 'objective-c': + return ( + <> + + + {CODE_MARKS.objectiveC} + + + ) + case 'perl': + return ( + <> + + + {CODE_MARKS.perl} + + + ) + case 'php': + return ( + <> + + + + {CODE_MARKS.php} + + + ) + case 'powershell': + return ( + <> + + + + + ) + case 'protobuf': + return ( + <> + + + + + ) + case 'python': + return ( + <> + + + + + + + ) + case 'r': + return ( + <> + + + + + + ) + case 'react': + return ( + <> + + + + + + + + + ) + case 'ruby': + return ( + <> + + + + + ) + case 'rust': + return ( + <> + + + + + {CODE_MARKS.r} + + + ) + case 'scala': + return ( + <> + + + + ) + case 'shell': + return ( + <> + + + + ) + case 'solidity': + return ( + <> + + + + ) + case 'sql': + return ( + <> + + + + + + ) + case 'svelte': + return ( + <> + + + + + ) + case 'swift': + return ( + <> + + + + ) + case 'toml': + return ( + <> + + + {CODE_MARKS.toml} + + + ) + case 'typescript': + return ( + <> + + + {CODE_MARKS.typescript} + + + ) + case 'vue': + return ( + <> + + + + + ) + case 'wasm': + return ( + <> + + + + ) + case 'xml': + return ( + <> + + + + ) + case 'yaml': + return ( + <> + + + {CODE_MARKS.yaml} + + + ) + case 'zig': + return ( + <> + + + + {CODE_MARKS.zig} + + + ) + /* v8 ignore next -- closed-union backstop; only reached if a type is forged */ + default: return assertNever(type) + } +} + +/** Closed-union exhaustiveness guard for the embedded artwork table. */ +/* v8 ignore next 3 -- only reachable when an untyped caller forges a code file type */ +function assertNever(value: never): never { + throw new Error(`unreachable code file type: ${String(value)}`) +} diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx deleted file mode 100644 index 743a01a3ff..0000000000 --- a/packages/client/ui-primitives/src/DocumentFileIcon.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useId } from 'react' - -/** - * Render the DeepSeek Web document glyph used by generic-file cards. - * @param props - optional CSS class for sizing and placement. - * @returns a decorative document SVG with an instance-safe gradient id. - */ -export function DocumentFileIcon({ className }: { readonly className?: string | undefined }) { - const gradientId = useId() - return ( - - - - - - - - - - - - ) -} diff --git a/packages/client/ui-primitives/src/FileTypeIcon.module.css b/packages/client/ui-primitives/src/FileTypeIcon.module.css new file mode 100644 index 0000000000..4de4d946e0 --- /dev/null +++ b/packages/client/ui-primitives/src/FileTypeIcon.module.css @@ -0,0 +1,50 @@ +.icon { + /* The design platform has no violet token matching the supplied image/video artwork. */ + --dsh-file-type-violet: rgb(139, 118, 246); + + color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color)); +} + +.code { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.excel { + --dsh-file-type-default-color: var(--dsw-static-green-500); +} + +.folder { + --dsh-file-type-default-color: var(--dsw-static-amber-400); +} + +.html { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.image { + --dsh-file-type-default-color: var(--dsh-file-type-violet); +} + +.markdown { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.other { + --dsh-file-type-default-color: var(--dsw-static-neutral-bluish-300); +} + +.pdf { + --dsh-file-type-default-color: var(--dsw-static-red-600); +} + +.ppt { + --dsh-file-type-default-color: var(--dsw-static-amber-500); +} + +.video { + --dsh-file-type-default-color: var(--dsh-file-type-violet); +} + +.word { + --dsh-file-type-default-color: var(--dsw-static-deepseek-450); +} diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index f03c01d4a7..0a88317401 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -1,167 +1,268 @@ -/** - * The coloured file-type sheet: a 28×28 paper with a folded corner, filled in - * the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*` - * glyphs it does not ride `currentColor` — the colour is the type's identity — - * so a consumer that wants it muted applies a CSS filter. Design sources: - * DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel, - * iconPPT, iconWord, iconOthers. - * - * TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar - * only; the product-wide file-type standard that will replace them is described - * in `file-extensions.ts`. - */ import type { ReactNode } from 'react' +import clsx from 'clsx' +import { CodeFileIcon } from './CodeFileIcon.tsx' import { - CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, - SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension, -} from './file-extensions.ts' + classifyCodeFileType, isCodeFileType, type CodeFileType, type FileTypeProjectContext, +} from './code-file-types.ts' import type { IconProps } from './icons/props.ts' +import css from './FileTypeIcon.module.css' -/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */ -export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other' +/** File categories with distinct 28px glyphs. */ +export type FileType = + | CodeFileType + | 'code' + | 'excel' + | 'folder' + | 'html' + | 'image' + | 'markdown' + | 'other' + | 'pdf' + | 'ppt' + | 'video' + | 'word' -/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */ -export interface FileTypeIconProps extends IconProps { - kind: FileTypeKind +/** Compatibility name for consumers that pass an already resolved category. */ +export type FileTypeKind = FileType + +type ClassifiedFileType = Exclude +type TraditionalFileType = Exclude + +export type { CodeFileType, FileTypeProjectContext } from './code-file-types.ts' + +/** Props for {@link FileTypeIcon}: either a path to classify or an already resolved kind. */ +export type FileTypeIconProps = IconProps & ( + | { + /** File path or name to classify. */ + readonly path: string + /** Optional project-file snapshot for context-sensitive code icons such as Flutter. */ + readonly context?: FileTypeProjectContext | undefined + } + | { + /** Explicit category for callers that already resolved the file type. */ + readonly kind: FileTypeKind + } +) + +const EXTENSION_TYPES: Readonly> = { + scss: 'code', + sass: 'code', + less: 'code', + vue: 'code', + svelte: 'code', + astro: 'code', + bat: 'code', + cmd: 'code', + csv: 'code', + tsv: 'code', + html: 'html', + htm: 'html', + png: 'image', + jpg: 'image', + jpeg: 'image', + gif: 'image', + svg: 'image', + webp: 'image', + avif: 'image', + bmp: 'image', + ico: 'image', + tif: 'image', + tiff: 'image', + heic: 'image', + heif: 'image', + md: 'markdown', + mdx: 'markdown', + markdown: 'markdown', + pdf: 'pdf', + ppt: 'ppt', + pptx: 'ppt', + key: 'ppt', + mp4: 'video', + mov: 'video', + m4v: 'video', + webm: 'video', + mkv: 'video', + avi: 'video', + mpg: 'video', + mpeg: 'video', + doc: 'word', + docx: 'word', + rtf: 'word', + odt: 'word', + pages: 'word', + xls: 'excel', + xlsx: 'excel', + xlsm: 'excel', + numbers: 'excel', } -/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */ -const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet])[] = [ - ['markdown', MARKDOWN_EXTENSIONS], - ['html', HTML_EXTENSIONS], - ['pdf', PDF_EXTENSIONS], - ['sheet', SHEET_EXTENSIONS], - ['slides', SLIDES_EXTENSIONS], - ['document', WORD_EXTENSIONS], - ['image', IMAGE_EXTENSIONS], - ['code', CODE_EXTENSIONS], -] - -/** - * Derive a file path's type glyph from its extension. Unknown and missing - * extensions fall to `other`. - * @param path - File path or bare name, with either separator. - * @returns The file's type. - */ -export function classifyFileType(path: string): FileTypeKind { - const extension = fileExtension(path) - return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other' +const NAME_TYPES: Readonly> = { + changelog: 'markdown', + contributing: 'markdown', + readme: 'markdown', } -const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z' - -const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z' - -/** One type's sheet colour and its white mark. */ -interface Face { - readonly fill: string - readonly mark: ReactNode -} - -const FACES: Readonly> = { - code: { - fill: '#4176E6', - mark: ( - <> - - - - ), - }, - html: { - fill: '#4176E6', - mark: ( - - ), - }, - image: { - fill: '#8B76F6', - mark: ( - <> - - - - - ), - }, - markdown: { - fill: '#4176E6', - mark: ( - - ), - }, - pdf: { - fill: '#EC1313', - mark: ( - - ), - }, - sheet: { - fill: '#22C55E', - mark: ( - - ), - }, - slides: { - fill: '#F59E0B', - mark: ( - - ), - }, - document: { - fill: '#5686FE', - mark: ( - - ), - }, - other: { - fill: '#CFD3D6', - mark: ( - <> - - - - - ), - }, +function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) } /** - * Render one file type's coloured sheet. - * @param props - The type, optional size (default 28px, the drawn size), and - * optional CSS class. - * @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning. + * Extract the final suffix from a file path without changing its case. + * A leading dot starts a suffix, while a missing or trailing dot returns an empty string. + * @param path - File path or basename using either path separator. + * @returns The characters after the basename's final dot. */ -export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode { - const face = FACES[kind] +export function fileExtension(path: string): string { + const name = basename(path) + const dot = name.lastIndexOf('.') + return dot < 0 ? '' : name.slice(dot + 1) +} + +/** + * Classify a file path or name for file-card presentation. + * Matching is case-insensitive and applies code filename rules before extension rules; + * unknown names fall back to `other`. + * @param path - File path or basename using either path separator. + * @param context - Optional project files used by context-sensitive code mappings. + * @returns The file's closed presentation category. + */ +export function classifyFileType( + path: string, + context?: FileTypeProjectContext, +): ClassifiedFileType { + const name = basename(path).toLowerCase() + const extension = fileExtension(name).toLowerCase() + return classifyCodeFileType(name, extension, context) + ?? NAME_TYPES[name] + ?? EXTENSION_TYPES[extension] + ?? 'other' +} + +const FILE_BODY = 'M8.48924 28H19.5108C21.6479 28 22.7165 28 23.5594 27.6509C24.6833 27.1853 25.5762 26.2924 26.0417 25.1685C26.3909 24.3256 26.3909 23.257 26.3909 21.1199V8.79443C26.3909 8.32877 26.3909 8.09593 26.3471 7.87507C26.2887 7.58058 26.173 7.30042 26.0067 7.05048C25.882 6.86303 25.7177 6.69799 25.3893 6.36792L20.0611 1.01354C19.7304 0.681235 19.5651 0.515081 19.3769 0.38885C19.126 0.220541 18.8443 0.103463 18.5481 0.0443412C18.3259 0 18.0915 0 17.6226 0H8.48924C6.35209 0 5.28351 0 4.4406 0.349145C3.31672 0.814671 2.4238 1.70759 1.95828 2.83147C1.60913 3.67438 1.60913 4.74296 1.60913 6.88011V21.1199C1.60913 23.257 1.60913 24.3256 1.95828 25.1685C2.4238 26.2924 3.31672 27.1853 4.4406 27.6509C5.28351 28 6.35209 28 8.48924 28Z' +const FILE_FOLD = 'M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z' +const FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.12) translate(-14 -16)' +const LARGE_FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.22) translate(-14 -16)' +const FOLDER_MARK_TRANSFORM = 'translate(14 13.0693) scale(1.12) translate(-14 -13.0693)' + +function FileGlyph({ + size, className, children, markTransform = FILE_MARK_TRANSFORM, muted = false, +}: IconProps & { children?: ReactNode; markTransform?: string; muted?: boolean }): ReactNode { return ( - - {face.mark} - {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */} - {kind === 'other' - ? - : } + + {muted + ? + : } + {children !== undefined && ( + {children} + )} ) } + +function FolderGlyph({ size, className }: IconProps): ReactNode { + return ( + + + + + + + ) +} + +function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode { + switch (type) { + case 'code': + return ( + + + + + ) + case 'excel': + return ( + + + + ) + case 'folder': return + case 'html': + return ( + + + + ) + case 'image': + return ( + + + + + + ) + case 'markdown': + return ( + + + + ) + case 'other': return + case 'pdf': + return ( + + + + ) + case 'ppt': + return ( + + + + ) + case 'video': + return ( + + + + ) + case 'word': + return ( + + + + ) + /* v8 ignore next -- closed-union backstop; only reached if a type is forged */ + default: return assertNever(type) + } +} + +/** Closed-union exhaustiveness guard for traditional file artwork. */ +/* v8 ignore next 3 -- only reachable when an untyped caller forges a traditional file type */ +function assertNever(value: never): never { + throw new Error(`unreachable traditional file type: ${String(value)}`) +} + +/** + * Render a decorative file-type glyph for a path or an explicitly resolved kind. + * @param props - Path or kind selection, optional project context, size, and CSS class. + * @returns The category-colored SVG; the caller owns the accessible name and may override + * the color through `--dsh-file-type-icon-color`. + */ +export function FileTypeIcon(props: FileTypeIconProps): ReactNode { + const { size = 28, className } = props + const resolvedType = 'path' in props + ? classifyFileType(props.path, props.context) + : props.kind + return isCodeFileType(resolvedType) + ? + : glyph(resolvedType, size, clsx(css.icon, css[resolvedType], className)) +} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 37dade0529..d46f11c24d 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -9,10 +9,8 @@ * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. */ import type { ReactNode } from 'react' -import { - CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS, - fileExtension, -} from './file-extensions.ts' +import { classifyFileType, fileExtension } from './FileTypeIcon.tsx' +import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts' import type { IconProps } from './icons/props.ts' /** @@ -28,22 +26,31 @@ export interface LinkIconProps extends IconProps { kind: LinkIconKind } -/** Office-style documents share one paper-doc glyph on a link. */ -const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS] - /** - * Derive a file path's link-icon category from its extension. Code, web, and - * data files share the code glyph; unknown and missing extensions fall to - * `other` (the plain-paper glyph). + * Derive a file path's link-icon category from its extension. Unknown and + * missing extensions fall to `other` (the plain-paper glyph). * @param path - File path as the producing tool spelled it (either separator). * @returns The file's glyph category; never `url` or `folder`. */ export function classifyLinkPath(path: string): LinkIconKind { + const type = classifyFileType(path) const extension = fileExtension(path) + if (isCodeFileType(type)) return isLinkCodeExtension(extension) ? 'code' : 'other' if (extension === '') return 'other' - if (CODE_EXTENSIONS.has(extension)) return 'code' - if (IMAGE_EXTENSIONS.has(extension)) return 'image' - return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other' + switch (type) { + case 'code': + case 'html': return 'code' + case 'image': return 'image' + case 'excel': + case 'pdf': + case 'ppt': + case 'word': return 'document' + case 'markdown': + case 'other': + case 'video': return 'other' + /* v8 ignore next -- classifyFileType returns a closed union exhausted above */ + default: return assertNever(type) + } } const GlobeGlyph = ({ size, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 3130086777..db8b5588fe 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,13 +48,16 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape focuses the anchor's first button. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). - * @param props.onClose - invoked on outside click or Escape. + * @param props.onClose - invoked on outside click, Escape, or a window blur + * that moved focus into an iframe (the only signal a pointerdown inside a + * cross-origin iframe leaves). * @param props.align - list alignment against the anchor (default 'start'). * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @param props.portal - render the list into document.body, fixed-positioned @@ -81,8 +84,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * crowds the cell). * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, selection = 'check', getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: { open: boolean + autoFocus?: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] @@ -159,6 +163,10 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o } }, [open, portal, align, side, getAnchorRect]) + useEffect(() => { + if (open && autoFocus) listRef.current?.querySelector('button:not(:disabled)')?.focus() + }, [open, autoFocus]) + useEffect(() => { if (!open) { setOpenSubmenuId(null) @@ -172,15 +180,35 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o onClose() } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + onClose() + if (autoFocus) rootRef.current?.querySelector('button')?.focus() + } + if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return + const buttons = Array.from(listRef.current?.querySelectorAll('button:not(:disabled)') ?? []) + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + e.preventDefault() + const next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1 + : (index + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length + buttons[next]?.focus() + } + // A pointerdown inside a cross-origin iframe (a sandboxed HTML preview) + // never reaches this document; the focus move it causes blurs the window + // instead. Only that case closes: an app or tab switch leaves the + // document's focus where it was, so activeElement is not an iframe. + const onWindowBlur = () => { + if (document.activeElement instanceof HTMLIFrameElement) onClose() } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', onWindowBlur) return () => { document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', onWindowBlur) } - }, [open, onClose]) + }, [open, onClose, autoFocus]) // A close from selection/Escape/outside click outruns a pending grace close; // left armed it would shut a list reopened inside the grace window. Its own diff --git a/packages/client/ui-primitives/src/code-file-types.ts b/packages/client/ui-primitives/src/code-file-types.ts new file mode 100644 index 0000000000..92cba6cd4a --- /dev/null +++ b/packages/client/ui-primitives/src/code-file-types.ts @@ -0,0 +1,340 @@ +/** Code and configuration categories with embedded full-color glyphs. */ +export const CODE_FILE_TYPES = [ + 'angular', + 'c', + 'clojure', + 'cmake', + 'cpp', + 'csharp', + 'css', + 'dart', + 'docker', + 'elixir', + 'env', + 'erlang', + 'flutter', + 'git', + 'go', + 'graphql', + 'haskell', + 'ini', + 'java', + 'javascript', + 'json', + 'kotlin', + 'lua', + 'makefile', + 'node', + 'objective-c', + 'perl', + 'php', + 'powershell', + 'protobuf', + 'python', + 'r', + 'react', + 'ruby', + 'rust', + 'scala', + 'shell', + 'solidity', + 'sql', + 'svelte', + 'swift', + 'toml', + 'typescript', + 'vue', + 'wasm', + 'xml', + 'yaml', + 'zig', +] as const + +/** A code or configuration file category with its own full-color square glyph. */ +export type CodeFileType = typeof CODE_FILE_TYPES[number] + +/** Optional project files used by context-sensitive code-icon rules. */ +export interface FileTypeProjectContext { + /** Text content keyed by project-relative or basename path. */ + readonly files: Readonly> +} + +const CODE_FILE_TYPE_SET = new Set(CODE_FILE_TYPES) + +const FILE_NAME_TYPES: Readonly> = { + '.bash_profile': 'shell', + '.bashrc': 'shell', + '.env': 'env', + '.gitattributes': 'git', + '.gitconfig': 'git', + '.gitignore': 'git', + '.gitmodules': 'git', + '.mailmap': 'git', + '.profile': 'shell', + '.zprofile': 'shell', + '.zshrc': 'shell', + 'bsdmakefile': 'makefile', + 'cmakelists.txt': 'cmake', + 'commit_editmsg': 'git', + 'compose.yaml': 'docker', + 'compose.yml': 'docker', + 'docker-compose.yaml': 'docker', + 'docker-compose.yml': 'docker', + 'dockerfile': 'docker', + 'gemfile': 'ruby', + 'gnumakefile': 'makefile', + 'guardfile': 'ruby', + 'makefile': 'makefile', + 'npm-shrinkwrap.json': 'node', + 'package-lock.json': 'node', + 'package.json': 'node', + 'podfile': 'ruby', + 'rakefile': 'ruby', +} + +const FILE_NAME_PREFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ + ['dockerfile.', 'docker'], + ['.env.', 'env'], +] + +const FILE_NAME_SUFFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ + ['.component.ts', 'angular'], + ['.component.html', 'angular'], + ['.directive.ts', 'angular'], + ['.service.ts', 'angular'], + ['.module.ts', 'angular'], + ['.pipe.ts', 'angular'], + ['.guard.ts', 'angular'], + ['.interceptor.ts', 'angular'], + ['.dockerfile', 'docker'], +] + +const EXTENSION_TYPES: Readonly> = { + 'bash': 'shell', + 'c': 'c', + 'c++': 'cpp', + 'cc': 'cpp', + 'cfg': 'ini', + 'cjs': 'javascript', + 'clj': 'clojure', + 'cljc': 'clojure', + 'cljs': 'clojure', + 'cmake': 'cmake', + 'cpp': 'cpp', + 'cs': 'csharp', + 'csh': 'shell', + 'css': 'css', + 'csx': 'csharp', + 'cts': 'typescript', + 'cxx': 'cpp', + 'dart': 'dart', + 'dtd': 'xml', + 'edn': 'clojure', + 'env': 'env', + 'erl': 'erlang', + 'es6': 'javascript', + 'escript': 'erlang', + 'ex': 'elixir', + 'exs': 'elixir', + 'fish': 'shell', + 'gemspec': 'ruby', + 'go': 'go', + 'gql': 'graphql', + 'graphql': 'graphql', + 'h': 'c', + 'h++': 'cpp', + 'hh': 'cpp', + 'hpp': 'cpp', + 'hrl': 'erlang', + 'hs': 'haskell', + 'hxx': 'cpp', + 'ini': 'ini', + 'ipp': 'cpp', + 'java': 'java', + 'js': 'javascript', + 'json': 'json', + 'json5': 'json', + 'jsonc': 'json', + 'jsx': 'react', + 'ksh': 'shell', + 'kt': 'kotlin', + 'kts': 'kotlin', + 'lhs': 'haskell', + 'lua': 'lua', + 'm': 'objective-c', + 'mak': 'makefile', + 'mjs': 'javascript', + 'mk': 'makefile', + 'mm': 'objective-c', + 'mts': 'typescript', + 'node': 'node', + 'pch': 'objective-c', + 'php': 'php', + 'php3': 'php', + 'php4': 'php', + 'php5': 'php', + 'phps': 'php', + 'phtml': 'php', + 'pl': 'perl', + 'plist': 'xml', + 'pm': 'perl', + 'pod': 'perl', + 'proto': 'protobuf', + 'ps1': 'powershell', + 'psd1': 'powershell', + 'psm1': 'powershell', + 'py': 'python', + 'pyi': 'python', + 'pyw': 'python', + 'pyx': 'python', + 'r': 'r', + 'rake': 'ruby', + 'rb': 'ruby', + 'rmd': 'r', + 'rs': 'rust', + 'sc': 'scala', + 'scala': 'scala', + 'sh': 'shell', + 'sol': 'solidity', + 'sql': 'sql', + 'svelte': 'svelte', + 'swift': 'swift', + 't': 'perl', + 'tcsh': 'shell', + 'toml': 'toml', + 'tpp': 'cpp', + 'ts': 'typescript', + 'tsx': 'react', + 'vue': 'vue', + 'wasm': 'wasm', + 'wast': 'wasm', + 'wat': 'wasm', + 'xml': 'xml', + 'xsd': 'xml', + 'xsl': 'xml', + 'xslt': 'xml', + 'yaml': 'yaml', + 'yml': 'yaml', + 'zig': 'zig', + 'zsh': 'shell', +} + +const LINK_CODE_EXTENSIONS = new Set([ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'cts', + 'mts', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'vue', + 'svelte', + 'astro', + 'json', + 'jsonc', + 'json5', + 'yaml', + 'yml', + 'toml', + 'xml', + 'ini', + 'env', + 'sh', + 'bash', + 'zsh', + 'fish', + 'ps1', + 'bat', + 'cmd', + 'py', + 'pyi', + 'rb', + 'rs', + 'go', + 'java', + 'kt', + 'kts', + 'c', + 'cc', + 'cpp', + 'cxx', + 'h', + 'hh', + 'hpp', + 'cs', + 'php', + 'swift', + 'sql', + 'csv', + 'tsv', + 'proto', + 'graphql', + 'gql', + 'lua', + 'r', + 'pl', + 'scala', + 'clj', + 'cljs', + 'ex', + 'exs', + 'erl', + 'hs', + 'dart', +]) + +function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) +} + +/** + * Test whether a resolved file type uses the full-color code-icon set. + * @param type - Resolved file-type string. + * @returns Whether the value is a detailed code-file type. + */ +export function isCodeFileType(type: string): type is CodeFileType { + return CODE_FILE_TYPE_SET.has(type) +} + +/** + * Test whether an extension belonged to the established coarse LinkIcon code category. + * @param extension - Extension without a leading dot. + * @returns Whether clickable links keep the code glyph for this extension. + */ +export function isLinkCodeExtension(extension: string): boolean { + return LINK_CODE_EXTENSIONS.has(extension.toLowerCase()) +} + +/** + * Resolve the most specific code/configuration icon according to the supplied map priority. + * @param name - Lowercase basename. + * @param extension - Lowercase extension without the leading dot. + * @param context - Optional project-file snapshot for context-sensitive matches. + * @returns The detailed code type, or null when the traditional file classifier owns the path. + */ +export function classifyCodeFileType( + name: string, + extension: string, + context?: FileTypeProjectContext, +): CodeFileType | null { + const exact = FILE_NAME_TYPES[name] + if (exact !== undefined) return exact + for (const [prefix, type] of FILE_NAME_PREFIX_TYPES) { + if (name.startsWith(prefix)) return type + } + for (const [suffix, type] of FILE_NAME_SUFFIX_TYPES) { + if (name.endsWith(suffix)) return type + } + if (extension === 'dart' && context !== undefined) { + const pubspec = Object.entries(context.files).find(([path]) => basename(path).toLowerCase() === 'pubspec.yaml')?.[1] + if (pubspec?.includes('flutter:') === true) return 'flutter' + } + return EXTENSION_TYPES[extension] ?? null +} diff --git a/packages/client/ui-primitives/src/file-extensions.ts b/packages/client/ui-primitives/src/file-extensions.ts deleted file mode 100644 index 8adbf67af2..0000000000 --- a/packages/client/ui-primitives/src/file-extensions.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`, - * `classifyFileType`), so a path classifies the same way on a link and on a - * file-type icon. - * - * TODO: this is an interim rule. The product will settle one extension - * standard for every place a file is drawn — an upload in the input bar, a - * sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab - * chips and pane headers — and these sets, the glyph pickers over them, and - * the sheets in `FileTypeIcon` are to be replaced by it. Until then the - * surfaces differ: the Sidebar classifies by these sets, the conversation's - * links fold the four office types into `document` (`LinkIcon`), and the - * attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no - * classification at all. - */ - -/** Code, web, and data extensions. */ -export const CODE_EXTENSIONS: ReadonlySet = new Set([ - 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', - 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', - 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', - 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', - 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', -]) - -/** Raster and vector image extensions. */ -export const IMAGE_EXTENSIONS: ReadonlySet = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', -]) - -/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */ -export const HTML_EXTENSIONS: ReadonlySet = new Set(['html', 'htm']) - -/** Markdown extensions. */ -export const MARKDOWN_EXTENSIONS: ReadonlySet = new Set(['md', 'mdx', 'markdown']) - -/** PDF. */ -export const PDF_EXTENSIONS: ReadonlySet = new Set(['pdf']) - -/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */ -export const SHEET_EXTENSIONS: ReadonlySet = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers']) - -/** Slide-deck extensions. */ -export const SLIDES_EXTENSIONS: ReadonlySet = new Set(['ppt', 'pptx', 'key']) - -/** Word-processor document extensions. */ -export const WORD_EXTENSIONS: ReadonlySet = new Set(['doc', 'docx', 'rtf', 'odt', 'pages']) - -/** - * A path's lowercase extension without the dot, taken from its last segment. - * @param path - File path with either separator. - * @returns The extension, or `''` for a name without a dot. - */ -export function fileExtension(path: string): string { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = name.lastIndexOf('.') - return dot < 0 ? '' : name.slice(dot + 1).toLowerCase() -} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 23ad8cebb9..f5112b64f1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -462,7 +462,7 @@ export const IconRightUpOutline14 = ({ size = 8, className }: IconProps) => ( export const IconRightUpOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d9b12eda0e..3e9ae259e4 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,14 +33,15 @@ export { ReferenceIcon } from './ReferenceIcon.tsx' export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx' export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx' -export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx' -export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx' +export { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.tsx' +export type { + CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext, +} from './FileTypeIcon.tsx' export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' -export { DocumentFileIcon } from './DocumentFileIcon.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' export { rankByName } from './rank-by-name.ts' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 5f726e8474..a6e25ba39f 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -8,11 +8,12 @@ with the markdown 0.875 ladder without shrinking every other xs-13 use. */ --dsl-code-block-banner-font: 11px/18px var(--dsw-font-family); --dsl-code-block-content-font: var(--dsw-font-markdown-code-block); + --dsl-code-block-background: var(--dsw-alias-markdown-code-block); position: relative; margin: 16px 0; color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-markdown-code-block); + background: var(--dsl-code-block-background); border-radius: var(--dsl-code-block-border-radius); } @@ -75,7 +76,7 @@ overflow-x: auto; white-space: pre-wrap; word-break: break-all; - background: var(--dsw-alias-markdown-code-block); + background: var(--dsl-code-block-background); /* Bottom radii live on
: overflow:hidden on .block would kill the
      sticky banner, and this opaque fill otherwise squares off the wrapper. */
   border-bottom-left-radius: var(--dsl-code-block-border-radius);
@@ -84,7 +85,7 @@
 
 /* Shiki inlines its theme background var; route it to the repo token. */
 .block :where(pre.shiki) {
-  background: var(--dsw-alias-markdown-code-block) !important;
+  background: var(--dsl-code-block-background) !important;
 }
 
 .block :where(pre) code {
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index fff6a00a45..02cdbe683a 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -178,7 +178,8 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
       } as CSSProperties}>
       
-
+ {/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */} +
{lang ?? ''}