Selaa lähdekoodia

Merge remote-tracking branch 'origin/master' into worktree/web-sidebar-terminal

# Conflicts:
#	packages/client/ui-sidebar-right/README.i18n.yaml
#	packages/client/ui-sidebar-right/README.md
#	packages/client/ui-sidebar-right/README.zh.md
Yichen Jiang 3 viikkoa sitten
vanhempi
sitoutus
f030b544a9
100 muutettua tiedostoa jossa 2191 lisäystä ja 452 poistoa
  1. 2 2
      .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml
  2. 3 1
      .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
  3. 3 1
      .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  5. 3 3
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  6. 3 3
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
  8. 3 3
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
  9. 3 3
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  11. 1 1
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  12. 1 1
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  14. 7 5
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  15. 6 4
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  16. 6 0
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
  17. 43 0
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
  18. 43 0
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml
  20. 1 1
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md
  21. 1 1
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md
  22. 6 0
      .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml
  23. 27 0
      .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md
  24. 27 0
      .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md
  25. 6 0
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml
  26. 35 0
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md
  27. 35 0
      .agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md
  28. 1 0
      apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt
  29. 30 0
      apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts
  30. 3 0
      apps/web/tests/expected/file-upload-round/draft.expected.md
  31. 3 0
      apps/web/tests/expected/file-upload-round/history.expected.md
  32. 23 1
      apps/web/tests/file-upload-round.e2e.ts
  33. 2 3
      apps/web/tests/navigation-panes.e2e.ts
  34. 3 1
      apps/web/tests/present-svg.e2e.ts
  35. 4 0
      apps/web/tests/present-svg.overlay.yml
  36. 22 7
      apps/web/tests/present.e2e.ts
  37. 4 0
      apps/web/tests/present.overlay.yml
  38. 1 1
      apps/web/tests/seeded-history.e2e.ts
  39. 2 2
      docs/config-catalog.i18n.yaml
  40. 2 2
      docs/config-catalog.md
  41. 1 1
      docs/config-catalog.zh.md
  42. 2 2
      docs/event-producer-consumer.i18n.yaml
  43. 5 5
      docs/event-producer-consumer.md
  44. 5 5
      docs/event-producer-consumer.zh.md
  45. 2 2
      docs/persistence-catalog.i18n.yaml
  46. 1 1
      docs/persistence-catalog.md
  47. 1 1
      docs/persistence-catalog.zh.md
  48. 2 2
      docs/subsystems/session.i18n.yaml
  49. 7 1
      docs/subsystems/session.md
  50. 7 1
      docs/subsystems/session.zh.md
  51. 2 2
      docs/tool-catalog.i18n.yaml
  52. 2 2
      docs/tool-catalog.md
  53. 2 2
      docs/tool-catalog.zh.md
  54. 2 2
      packages/api/session-controller/README.i18n.yaml
  55. 0 0
      packages/api/session-controller/README.md
  56. 0 1
      packages/api/session-controller/README.zh.md
  57. 17 2
      packages/api/session-controller/src/index.ts
  58. 2 0
      packages/api/session-controller/src/types.ts
  59. 32 0
      packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
  60. 2 0
      packages/api/session-controller/tests/test-remote.ts
  61. 4 10
      packages/client/ui-attachment/src/FileCard.tsx
  62. 13 0
      packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx
  63. 1 1
      packages/client/ui-chat/src/client/chat/MessageItem.module.css
  64. 3 9
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  65. 4 0
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  66. 2 2
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  67. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  68. 5 5
      packages/client/ui-deliverables/README.md
  69. 5 5
      packages/client/ui-deliverables/README.zh.md
  70. 11 2
      packages/client/ui-deliverables/package.json
  71. 32 14
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  72. 42 23
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  73. 85 0
      packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
  74. 5 2
      packages/client/ui-deliverables/src/client/index.ts
  75. 44 2
      packages/client/ui-deliverables/src/client/locales.ts
  76. 61 12
      packages/client/ui-deliverables/src/client/present-open.ts
  77. 3 3
      packages/client/ui-deliverables/src/index.ts
  78. 29 15
      packages/client/ui-deliverables/src/present-open.ts
  79. 26 0
      packages/client/ui-deliverables/src/presented.ts
  80. 100 0
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  81. 72 15
      packages/client/ui-deliverables/tests/present-open.host.spec.ts
  82. 166 0
      packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx
  83. 92 11
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  84. 3 0
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  85. 3 0
      packages/client/ui-deliverables/tsconfig.client.json
  86. 12 0
      packages/client/ui-deliverables/tsconfig.host.json
  87. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  88. 1 1
      packages/client/ui-dockkit/README.md
  89. 1 1
      packages/client/ui-dockkit/README.zh.md
  90. 23 14
      packages/client/ui-dockkit/src/engine/planner.ts
  91. 24 3
      packages/client/ui-dockkit/tests/planner.client.spec.ts
  92. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  93. 3 3
      packages/client/ui-primitives/README.md
  94. 3 3
      packages/client/ui-primitives/README.zh.md
  95. 491 0
      packages/client/ui-primitives/src/CodeFileIcon.tsx
  96. 0 41
      packages/client/ui-primitives/src/DocumentFileIcon.tsx
  97. 50 0
      packages/client/ui-primitives/src/FileTypeIcon.module.css
  98. 245 144
      packages/client/ui-primitives/src/FileTypeIcon.tsx
  99. 20 13
      packages/client/ui-primitives/src/LinkIcon.tsx
  100. 32 4
      packages/client/ui-primitives/src/Menu.tsx

+ 2 - 2
.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

+ 3 - 1
.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__<server>__<tool>` 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:

+ 3 - 1
.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__<server>__<tool>` 的拼写方式与 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 标识永远不会坍缩为同一个公开名称:

+ 2 - 2
.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

+ 3 - 3
.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.

+ 3 - 3
.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 挂载,预览在折叠后仍在。

+ 2 - 2
.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

+ 3 - 3
.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.
 

+ 3 - 3
.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 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
 

+ 2 - 2
.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

+ 1 - 1
.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
 

+ 1 - 1
.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 会填入当前默认页。折叠的布局可以保持为空,展开时才选择并创建默认页。
 
 ### 文本预览
 

+ 2 - 2
.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

+ 7 - 5
.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.

+ 6 - 4
.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 导出。

+ 6 - 0
.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

+ 43 - 0
.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.

+ 43 - 0
.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 校验。后缀只是展示提示,不能证明文件内容或可信度。

+ 2 - 2
.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

+ 1 - 1
.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.
 

+ 1 - 1
.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 次键按下什么都不显示,而不是画一个空框。
 

+ 6 - 0
.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

+ 27 - 0
.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.

+ 27 - 0
.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 录制场景继续覆盖声明、预览、原生操作和不含文件内容的导出。

+ 6 - 0
.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

+ 35 - 0
.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.

+ 35 - 0
.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 重述了这些规则。

+ 1 - 0
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

+ 30 - 0
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)

+ 3 - 0
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

+ 3 - 0
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

+ 23 - 1
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<HTMLElement>('[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),

+ 2 - 3
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')

+ 3 - 1
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)

+ 4 - 0
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

+ 22 - 7
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<Array<{ path: string; content: string }>> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string })
+  const opened = async (): Promise<Array<{ path: string; content: string | null; action: 'open' | 'reveal' }>> => (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)

+ 4 - 0
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

+ 1 - 1
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]')

+ 2 - 2
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

+ 2 - 2
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)
 
 <a id="deepseek-aidsh-api-settings-controller"></a>
 
@@ -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))

+ 1 - 1
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))

+ 2 - 2
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

+ 5 - 5
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` |

+ 5 - 5
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` |

+ 2 - 2
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

+ 1 - 1
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[] }
 ```
 

+ 1 - 1
docs/persistence-catalog.zh.md

@@ -405,7 +405,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 #### `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[] }
 ```
 

+ 2 - 2
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

+ 7 - 1
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.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote canOpenWorkspacePath(): boolean
 
+/**
+ * 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 }
+
 /**
  * Open one path prepared by a Session-aware caller on the Host desktop.
  * @param request - path after best-effort Session workspace resolution.

+ 7 - 1
docs/subsystems/session.zh.md

@@ -684,7 +684,7 @@ interface TurnEndReasonMap {
 
 `ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。
 
-`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。
+`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote canOpenWorkspacePath(): boolean
 
+/**
+ * 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 }
+
 /**
  * Open one path prepared by a Session-aware caller on the Host desktop.
  * @param request - path after best-effort Session workspace resolution.

+ 2 - 2
docs/tool-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/tool-catalog.md
-tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7
-tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f
+tool-catalog.md: c6c09aee8ed1cac69558bb02ca3395eaebed86e5
+tool-catalog.zh.md: 2cd6715ff03752885da0bb91e7e9ed9796f9ede4

+ 2 - 2
docs/tool-catalog.md

@@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
 
 ### `present`
 
-Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
+Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
 
 ```json
 {
@@ -239,7 +239,7 @@ Declare existing workspace files as final deliverables. When a file you create o
         "properties": {
           "path": {
             "type": "string",
-            "description": "Path of an existing file inside the workspace."
+            "description": "Path of an existing regular file. Relative paths use the Session working directory."
           },
           "description": {
             "type": "string",

+ 2 - 2
docs/tool-catalog.zh.md

@@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 
 ### `present`
 
-声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
+声明交付 Session 文件系统可访问的已有文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
 
 ```json
 {
@@ -243,7 +243,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
         "properties": {
           "path": {
             "type": "string",
-            "description": "Path of an existing file inside the workspace."
+            "description": "Path of an existing regular file. Relative paths use the Session working directory."
           },
           "description": {
             "type": "string",

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea
-README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8
+README.md: 24cfbba626ddec510452e3b6c8bc4333c03876bd
+README.zh.md: c90357c60eab9427f5656b48c4ea99252d87e2bf

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
packages/api/session-controller/README.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
packages/api/session-controller/README.zh.md


+ 17 - 2
packages/api/session-controller/src/index.ts

@@ -1,10 +1,11 @@
 /** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
 
+import { hostname } from 'node:os'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { errorChain } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-client-file-upload'
-import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
+import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
 import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -76,6 +77,8 @@ export interface Config {
 export interface SessionControllerInternals {
   /** Native default-application handoff. */
   readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
+  /** Native file-manager handoff. */
+  readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
   /** 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<void>
+  private readonly revealPath: (path: string, signal: AbortSignal) => Promise<void>
   private readonly canOpenPath: () => boolean
   private readonly promotions = new Set<Promise<void>>()
 
@@ -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', {})

+ 2 - 0
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
 }

+ 32 - 0
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() }
+})

+ 2 - 0
packages/api/session-controller/tests/test-remote.ts

@@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults {
   readonly nativeOpen?: boolean
   readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
   readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
+  readonly revealPath?: (path: string, signal: AbortSignal) => Promise<void>
   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 },
       },
     )

+ 4 - 10
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({
       <span className={css.icon} aria-hidden>
         {state === 'uploading'
           ? <span className={css.spinner} />
-          : <DocumentFileIcon />}
+          : <FileTypeIcon path={name} />}
       </span>
       {retryable
         ? (

+ 13 - 0
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(<ComposerAttachments {...props({
+      attachments: [fileDraft('env', '.env')],
+      uploads: {
+        env: {
+          status: 'ready', receiptId: 'receipt-env' as never,
+          file: { attachmentId: 'file-env' as never, name: '.env', bytes: 3 },
+        },
+      },
+    })} />)
+    expect(view.getByTitle('.env').textContent).toContain('ENV 3B')
+  })
 })

+ 1 - 1
packages/client/ui-chat/src/client/chat/MessageItem.module.css

@@ -317,7 +317,7 @@
 
 .fileIcon {
   flex: none;
-  width: 24px;
+  width: 28px;
   height: 28px;
 }
 

+ 3 - 9
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({
               )
               : (
                 <span key={`file:${index}`} className={css.fileCard} title={attachment.file.name}>
-                  <DocumentFileIcon className={css.fileIcon} />
+                  <FileTypeIcon path={attachment.file.name} className={css.fileIcon} />
                   <span className={css.fileContent}>
                     <span className={css.fileName}>{attachment.file.name}</span>
                     <span className={css.fileMeta}>
-                      {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)]
+                      {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)]
                         .filter(Boolean).join(' ')}
                     </span>
                   </span>

+ 4 - 0
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()
   })
 })

+ 2 - 2
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 (
     <span className={css.file} aria-label={label} title={attachment.name}>
-      <span className={css.fileIcon} aria-hidden><DocumentFileIcon /></span>
+      <span className={css.fileIcon} aria-hidden><FileTypeIcon path={attachment.name} size={16} /></span>
       <span className={css.fileName}>{attachment.name}</span>
       <span className={css.fileSize}>{fileSizeText(attachment.bytes)}</span>
     </span>

+ 2 - 2
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

+ 5 - 5
packages/client/ui-deliverables/README.md

@@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
 <a id="explicit-deliveries"></a>
 ### 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.
 
 </details>
 
@@ -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.
 
 <a id="dev-note"></a>
@@ -109,4 +109,4 @@ None.
 
 </details>
 
-**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.

+ 5 - 5
packages/client/ui-deliverables/README.zh.md

@@ -30,9 +30,9 @@ kind: "package-reference"
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-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 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 
@@ -96,7 +96,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
 - **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。
-- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。
+- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。
 
 <a id="dev-note"></a>
@@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。
+**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。

+ 11 - 2
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:^"
+  }
 }

+ 32 - 14
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; } }

+ 42 - 23
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<ReturnType<PresentedOpenController['state']['getSnapshot']>> }
+  hooks: {
+    presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
+    presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
+  }
+  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<TurnTailOwnerProps, 'openFile'> & {
+export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
-} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & InjectFace<DeliverablesInjected>) {
+} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
+  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 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
     {matched.presented.length > 0 && <div className={css.root}>
-      <span className={css.label}>{t('presented.label')}</span>
-      <div className={css.presented} data-presented-files-row>
-        {matched.presented.map((file) => {
-          const phase = states[presentedFileUrl(sessionId, file.seq, file.index)]
-          return <button key={file.path} type="button" className={css.file}
-            disabled={phase === 'opening'}
-            onClick={() => { void openPresented(sessionId, file.seq, file.index) }}
-            title={t('presented.open', { name: file.path })} aria-label={t('presented.open', { name: file.path })}>
-            <LinkIcon kind={classifyLinkPath(file.path)} className={css.fileIcon} />
-            <span className={css.details}>
-              <span className={css.fileName}>{basename(file.path)}</span>
-              <span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
-              {file.description && <span className={css.description}>{file.description}</span>}
-              {phase !== undefined && <span className={css.description} role="status">{t(`presented.${phase}`)}</span>}
-            </span>
-            <span className={css.open}><IconRightUpOutline16 /><span>{t('presented.action')}</span></span>
-          </button>})}
+      {host === 'error' && <div className={css.hostStatus}>
+        <span>{t('presented.hostError')}</span>
+        <Button size="sm" onClick={() => { void reloadPresentedHost() }}>{t('presented.retry')}</Button>
+      </div>}
+      {host !== null && host !== 'error' && !host.available && <span className={css.hostStatus}>{t('presented.unavailable')}</span>}
+      <div className={css.presented} data-presented-files-row data-single={matched.presented.length === 1 ? true : undefined}>
+        {presented.map(file => <PresentedFileCard key={`${file.seq}:${file.index}`} file={file} cwd={cwd}
+          phase={states[presentedFileUrl(sessionId, file.seq, file.index)]}
+          host={host === 'error' ? null : host} t={t}
+          onPreview={() => { openFile(file.path) }}
+          onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
       </div>
+      {collapsible && <button type="button" className={css.toggle}
+        aria-expanded={expanded}
+        aria-label={t(expanded ? 'presented.collapseAria' : 'presented.expandAria', { count: matched.presented.length })}
+        onClick={() => { setExpanded(value => !value) }}>
+        <span>{t(expanded ? 'presented.collapse' : 'presented.all', { count: matched.presented.length })}</span>
+        {expanded ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
+      </button>}
     </div>}
   </>
 }

+ 85 - 0
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<typeof NS>) {
+  const [menuOpen, setMenuOpen] = useState(false)
+  const previewRef = useRef<HTMLButtonElement>(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 <div className={css.file} data-presented-file>
+    <button type="button" className={css.cardPreview} title={resolveWorkspacePath(cwd, file.path)}
+      aria-label={t('presented.previewCard', { name: file.path })} onClick={onPreview} />
+    <span className={css.fileIcon}><FileTypeIcon path={file.path} /></span>
+    <div className={css.fileBody}>
+      <div className={css.details}>
+        <span className={css.fileName}>{name}</span>
+        <span className={css.description} role={phase === undefined ? undefined : 'status'}
+          data-error={phase === 'error' || phase === 'revealError' || phase === 'nativeUnavailable' ? true : undefined}>
+          <span className={css.secondaryText}>{status}</span>
+          <span className={css.previewHint}>{t('presented.preview')}</span>
+        </span>
+      </div>
+      <div className={css.split}>
+        <button ref={previewRef} type="button" className={css.open}
+          aria-label={t('presented.previewButton', { name: file.path })}
+          onClick={onPreview}>{t('presented.action')}</button>
+        <Menu className={css.menuAnchor} open={menuOpen && !menuDisabled} autoFocus portal align="end" onClose={() => { setMenuOpen(false) }}
+          anchor={<button type="button" className={css.chevron} disabled={menuDisabled}
+            aria-haspopup="menu" aria-expanded={menuOpen && !menuDisabled}
+            aria-label={t('presented.more', { name: file.path })}
+            onClick={() => { setMenuOpen(value => !value) }}>
+            <IconChevronDownOutline14 size={11} />
+          </button>}
+          items={[
+            { id: 'open', icon: <IconRightUpOutline16 size={16} className={css.menuActionIcon} />,
+              label: t('presented.defaultApp') },
+            { id: 'reveal', icon: <IconFolderOpenOutline16 />,
+              label: t(`presented.${reveal}`) },
+          ]}
+          onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} />
+      </div>
+    </div>
+  </div>
+}

+ 5 - 2
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),
   )

+ 44 - 2
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<DeliverablesKey, string> = {
-  '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.',

+ 61 - 12
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<Record<string, PresentedOpenPhase | undefined>>({})
+  /** Native destination metadata, or a retryable read failure. */
+  readonly host = createSnapshotStore<PresentedHost | 'error' | null>(null)
+  private loading: Promise<void> | undefined
+  private metadata = new AbortController()
   private readonly lifetime = new AbortController()
   private readonly pending = new Set<Promise<void>>()
 
   /**
-   * 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<void> {
+  async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise<void> {
     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<void> {
+    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<void> {
+    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<void> {
     this.lifetime.abort()
     await Promise.all(this.pending)
   }
 
-  private async request(url: string): Promise<void> {
-    let phase: PresentedOpenPhase = 'opened'
+  private async request(url: string, action: PresentedAction): Promise<void> {
+    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 })
   }

+ 3 - 3
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. '

+ 29 - 15
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<Promise<Response>>()
   ctx.effect(() => async () => {
@@ -36,6 +43,8 @@ export function registerPresentOpen(ctx: Context): void {
 
 async function handlePresentOpen(ctx: Context, request: Request): Promise<Response> {
   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<Respon
   }
   try {
     request.signal.throwIfAborted()
-    const { session, target } = await ctx.sessionQuery.readEvent({
+    if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
+    const { target, session } = await ctx.sessionQuery.readEvent({
       sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
     }, request.signal)
     const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined
     if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 })
-    if (session.cwd === undefined) return new Response('Session workspace unavailable.', { status: 404 })
-    const root = await realpath(session.cwd)
-    const path = await realpath(resolve(root, file.path))
-    const within = relative(root, path)
-    if (isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)) {
-      return new Response('Presented file is outside the workspace.', { status: 403 })
+    request.signal.throwIfAborted()
+    const { fs, workspaceFiles } = ctx
+    const { absolutePath: path } = await workspaceFiles.stat({
+      sessionId: id as SessionId,
+      workspaceRoot: session.cwd ?? ctx.sandboxPolicy.workspaceRoot,
+    }, file.path, request.signal)
+    const mapped = fs.processPathFromHostPath(path)
+    if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal: request.signal })) !== path) {
+      return new Response('Presented file has no verified Host path.', { status: 422 })
     }
-    if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 })
     request.signal.throwIfAborted()
-    await ctx.sessionController.openWorkspacePath({ path }, request.signal)
+    await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal)
     return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
   } catch (error: unknown) {
     request.signal.throwIfAborted()
-    const missing = error instanceof Error && 'code' in error
+    const remote = remoteErrorOf(error)
+    const missing = remote?.code === 'session/not-found' || remote?.code === 'workspace-file/not-found'
+      || remote?.code === 'workspace-file/not-regular-file' || error instanceof Error && 'code' in error
       && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND'
         || error.code === 'ENOENT' || error.code === 'ENOTDIR')
-    return new Response('Presented workspace file unavailable.', { status: missing ? 404 : 500 })
+    return new Response('Presented file unavailable.', { status: missing ? 404 : 500 })
   }
 }

+ 26 - 0
packages/client/ui-deliverables/src/presented.ts

@@ -6,6 +6,32 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 /** Authenticated POST route for opening a workspace file on the Host desktop. */
 export const PRESENT_OPEN_PATH = '/api/present.open'
 
+/** Authenticated desktop availability and destination metadata. */
+export const PRESENT_HOST_PATH = '/api/present.host'
+
+/** Native file action selected by an explicit user gesture. */
+export type PresentedAction = 'open' | 'reveal'
+
+/** Serving Host information; file-manager names never derive from the browser's OS. */
+export interface PresentedHost {
+  name: string
+  available: boolean
+  fileManager: 'finder' | 'explorer' | 'directory' | null
+}
+
+/**
+ * Validate desktop metadata received over HTTP.
+ * @param value - decoded response.
+ * @returns whether all displayed and actionable fields are supported.
+ */
+export function isPresentedHost(value: unknown): value is PresentedHost {
+  if (typeof value !== 'object' || value === null) return false
+  const host = value as Record<string, unknown>
+  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.

+ 100 - 0
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<Response>()
+  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<Response>()
+  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<Response>()
+  const newReply = Promise.withResolvers<Response>()
+  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()
+})

+ 72 - 15
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<Context['sessionController']['resolveAgent']>(() => { 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)))
+})

+ 166 - 0
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(<PresentedFileCard {...p} host={{ ...p.host, fileManager }} />)
+  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(<PresentedFileCard {...p} />)
+  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(<PresentedFileCard {...p} phase={phase} />)
+  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(<PresentedFileCard {...p} host={null} />)
+  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(<PresentedFileCard {...p} host={{ ...p.host, available: false, fileManager: null }} />)
+  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(<PresentedFileCard {...p} />)
+  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(<PresentedFileCard {...p} phase="revealError" t={makeTranslate(zh)} />)
+  expect(view.getByText(zh['presented.revealError'])).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="revealed" host={{ ...p.host, fileManager: 'directory' }} />)
+  expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="revealed" />)
+  expect(view.getByText(en['presented.revealed'])).toBeTruthy()
+})
+
+
+it('supports keyboard selection and returns focus to the trigger on Escape', () => {
+  const view = render(<PresentedFileCard {...props()} />)
+  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(<PresentedFileCard {...p} cwd="/work" file={{ ...p.file, path }} />)
+  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(<PresentedFileCard {...p} cwd="/work" />)
+  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(<PresentedFileCard {...p} file={{ ...p.file, description }} />)
+  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(<PresentedFileCard {...p} />)
+  fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' }))
+  expect(view.getByRole('menu')).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="opening" />)
+  expect(view.queryByRole('menu')).toBeNull()
+  view.rerender(<PresentedFileCard {...p} phase="opened" />)
+  expect(view.queryByRole('menu')).toBeNull()
+})
+
+it('keeps focus on the available preview button after selecting a native action', () => {
+  const p = props()
+  const view = render(<PresentedFileCard {...p} />)
+  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(<PresentedFileCard {...p} phase="revealing" />)
+  const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })
+  expect(document.activeElement).toBe(preview)
+  view.rerender(<PresentedFileCard {...p} phase="revealed" />)
+  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(<PresentedFileCard {...p} phase="revealing" />)
+  expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="revealing" host={{ ...p.host, fileManager: 'directory' }} />)
+  expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy()
+  view.rerender(<PresentedFileCard {...p} phase="revealError" host={{ ...p.host, fileManager: 'directory' }} />)
+  expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy()
+})

+ 92 - 11
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: <T,>(select: (state: SessionListState) => T): T => select(sessions),
+    reloadPresentedHost: vi.fn(() => controller.loadHost()),
+    usePresentedHost: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
+      select(controller.host.getSnapshot()),
     openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
     usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => 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(<Deliverables {...props} matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
-    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(<Deliverables {...openProps()} matched={{ produced: [], presented: [
     { path: 'out/report.txt', description: 'Quarterly summary', seq: 2, index: 0 },
     { path: 'LICENSE', seq: 2, index: 1 },
   ] }} openFile={() => {}} 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(<Deliverables {...openProps()} matched={{ produced: [], presented:
+    paths.map((path, index) => ({ 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(<Deliverables {...openProps()} matched={{ produced: [], presented: [
+    { path: 'report.pdf', seq: 2, index: 0 },
+  ] }} openFile={() => {}} 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(<Deliverables {...props} matched={{ produced: [], presented: [
     { path: 'report.txt', seq: 2, index: 0 },
   ] }} openFile={() => {}} 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(<Deliverables {...props} matched={matched} openFile={() => {}} 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(<Deliverables {...props} matched={matched} openFile={() => {}} 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(<Deliverables {...shared} matched={{ produced: ['source.ts'], presented: [] }} />)
+  expect(props.reloadPresentedHost).not.toHaveBeenCalled()
+  view.rerender(<Deliverables {...shared} matched={{ produced: [], presented: [{ path: 'report.txt', seq: 2, index: 0 }] }} />)
+  expect(props.reloadPresentedHost).toHaveBeenCalledOnce()
 })

+ 3 - 0
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()
 

+ 3 - 0
packages/client/ui-deliverables/tsconfig.client.json

@@ -52,6 +52,9 @@
     },
     {
       "path": "../../fs/tool-present"
+    },
+    {
+      "path": "../../util/workspace-path"
     }
   ]
 }

+ 12 - 0
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"
     }
   ]
 }

+ 2 - 2
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

+ 1 - 1
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.

+ 1 - 1
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——而浮出就是把拖动松手在停靠区之外。

+ 23 - 14
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
 }
 
 /**

+ 24 - 3
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)

+ 2 - 2
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

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 3
packages/client/ui-primitives/README.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 3
packages/client/ui-primitives/README.zh.md


+ 491 - 0
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 (
+    <svg width={size} height={size} className={className} viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+      {codeFileArtwork(type, gradientId)}
+    </svg>
+  )
+}
+
+function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode {
+  switch (type) {
+    case 'angular':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#DD0031"/>
+          <path fill="none" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" d="m10 3.4 6 2-1 8-5 3-5-3-1-8z"/>
+          <path fill="#fff" d="m10 5.3-3.7 8.2h1.9l.7-1.8h2.2l.7 1.8h1.9zm0 3.4.5 1.4h-1z"/>
+        </>
+      )
+    case 'c':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#659AD2"/>
+          <path fill="none" stroke="#fff" strokeWidth="1.3" strokeLinejoin="round" d="m10 3.4 6 3.3v6.6l-6 3.3-6-3.3V6.7z"/>
+          <text x="10" y="13.5" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="9.5" fontWeight="800" fill="#fff">
+            {CODE_MARKS.c}
+          </text>
+        </>
+      )
+    case 'clojure':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#344D70"/>
+          <circle cx="10" cy="10" r="6.6" fill="#fff"/>
+          <path fill="#63B132" d="M9.5 3.5a6.5 6.5 0 0 0-4.7 10.8c1-2.6 2.5-4.3 4.3-5.3-1-1.5-.8-3.4.4-5.5z"/>
+          <path fill="#5881D8" d="M10.7 3.5c1.8 1.6 2.5 3.3 2 5.1 1.5 1.1 2.4 2.8 2.7 5.2a6.5 6.5 0 0 0-4.7-10.3z"/>
+          <path fill="#90B4FE" d="M12 9.8c-1.6.3-2.8 2.1-3.8 5.4a6.4 6.4 0 0 0 6.3-.7c-.2-2.1-1-3.7-2.5-4.7z"/>
+        </>
+      )
+    case 'cmake':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#263A55"/>
+          <path fill="#4D9DCC" d="M10 3 4 16h4.6z"/>
+          <path fill="#43A047" d="M10 3 8.6 16H16z"/>
+          <path fill="#E53935" d="m10 3 6 13-6-5z"/>
+          <path fill="#fff" d="m10 6 2.3 5.1-2.3-1.8-.8 2.9z"/>
+        </>
+      )
+    case 'cpp':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#00599C"/>
+          <path fill="none" stroke="#fff" strokeWidth="1.15" strokeLinejoin="round" d="m10 3.4 6 3.3v6.6l-6 3.3-6-3.3V6.7z"/>
+          <text x="7.8" y="13" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.2" fontWeight="800" fill="#fff">
+            {CODE_MARKS.cpp}
+          </text>
+          <path stroke="#fff" strokeWidth="1.1" d="M12 8.8v3.8m-1.9-1.9h3.8m2-1.9v3.8M14 10.7h3.8"/>
+        </>
+      )
+    case 'csharp':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#68217A"/>
+          <path fill="none" stroke="#fff" strokeWidth="1.15" strokeLinejoin="round" d="m10 3.4 6 3.3v6.6l-6 3.3-6-3.3V6.7z"/>
+          <text x="7.6" y="13" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.2" fontWeight="800" fill="#fff">
+            {CODE_MARKS.csharp}
+          </text>
+          <path stroke="#fff" strokeWidth=".95" d="M11.6 8.6h5m-5 3h5m-3.3-5v7m1.8-7v7"/>
+        </>
+      )
+    case 'css':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#1572B6"/>
+          <text x="10" y="12.8" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="6.2" fontWeight="900" fill="#fff">
+            {CODE_MARKS.css}
+          </text>
+        </>
+      )
+    case 'dart':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#0175C2"/>
+          <path fill="#fff" d="M5 4h6.5l4.5 4.5-3.2 7.5H8.5L4 11.5z"/>
+          <path fill="#13B9FD" d="m5 4 4 5.5h7L11.5 4zm4 5.5 3.8 6.5H8.5z"/>
+        </>
+      )
+    case 'docker':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#2496ED"/>
+          <path fill="#fff" d="M3 10h11.7c.2-1.2.9-2 2-2.5.5 1 .4 2-.3 2.7.5.1 1 .1 1.5-.1-.7 3.9-3.6 5.9-8.2 5.9C6 16 3.4 14 3 10z"/>
+          <g fill="#2496ED">
+            <path d="M4.5 7h2v2h-2zm2.6 0h2v2h-2zm2.6 0h2v2h-2zM7.1 4.5h2v2h-2zm2.6 0h2v2h-2zm2.6 2.5h2v2h-2z"/>
+          </g>
+        </>
+      )
+    case 'elixir':
+      return (
+        <>
+          <defs>
+            <linearGradient id={gradientId} x1="1" y1="19" x2="19" y2="1">
+              <stop stopColor="#4B275F"/>
+              <stop offset="1" stopColor="#8E6AAF"/>
+            </linearGradient>
+          </defs>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill={`url(#${gradientId})`}/>
+          <path fill="#fff" d="M10.7 3c.2 3.5-4.3 5.8-4.3 9.4 0 2.9 1.8 4.9 4.4 4.9 2.8 0 4.7-1.9 4.7-4.6 0-4.3-3.4-7-4.8-9.7z"/>
+          <path fill="#8E6AAF" d="M10.7 4.8c-.4 3.1-3 5.4-3 7.8 0 1.5.7 2.6 1.9 3-1-3.6 2.8-5.8 1.1-10.8z"/>
+        </>
+      )
+    case 'env':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#ECD53F"/>
+          <text x="10" y="13.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="6.8" fontWeight="900" fill="#24292F">
+            {CODE_MARKS.env}
+          </text>
+        </>
+      )
+    case 'erlang':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#A90533"/>
+          <circle cx="10" cy="10" r="6.4" fill="none" stroke="#fff" strokeWidth="1"/>
+          <text x="10" y="13.8" textAnchor="middle" fontFamily="Georgia,serif" fontSize="10.5" fontWeight="700" fill="#fff">
+            {CODE_MARKS.erlang}
+          </text>
+        </>
+      )
+    case 'flutter':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#027DFD"/>
+          <path fill="#fff" d="m4 10 6-6h4L8 10zm4 0 2.5-2.5H14L11.5 10l2.5 2.5h-3.5z"/>
+          <path fill="#B9E6FF" d="m8 10 4.5 4.5-2 2L4 10z"/>
+        </>
+      )
+    case 'git':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#F05032"/>
+          <rect x="5" y="5" width="10" height="10" rx="1.4" transform="rotate(45 10 10)" fill="#fff"/>
+          <path d="m7.7 7.4 4.9 4.9M9.9 9.6v3.6" fill="none" stroke="#F05032" strokeWidth="1.15"/>
+          <circle cx="7.7" cy="7.4" r="1.1" fill="#F05032"/>
+          <circle cx="12.6" cy="12.3" r="1.1" fill="#F05032"/>
+          <circle cx="9.9" cy="13.2" r="1.1" fill="#F05032"/>
+        </>
+      )
+    case 'go':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#00ADD8"/>
+          <path fill="#fff" d="M3.5 8h6v1.6H5.2a2 2 0 0 0 0 .8h2.4v-.5H6.8V8.7h3.7c.3 3-1 4.4-3.6 4.4S3 11.7 3 9.7c0-.6.2-1.2.5-1.7Zm9.3 0h1.7c1.6 0 2.5 1 2.5 2.5s-.9 2.6-2.5 2.6h-1.7c-1.6 0-2.5-1-2.5-2.6S11.2 8 12.8 8Zm.3 1.6c-.7 0-1 .3-1 .9s.3 1 1 1h1.1c.7 0 1-.4 1-1s-.3-.9-1-.9z"/>
+        </>
+      )
+    case 'graphql':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#E10098"/>
+          <path d="m10 4 5.2 3v6L10 16l-5.2-3V7zm-5 3.2h10M5 12.8h10M10 4l5 8.8M10 4l-5 8.8M10 16 5 7.2m5 8.8 5-8.8" stroke="#fff" strokeWidth=".8"/>
+          <g fill="#fff">
+            <circle cx="10" cy="4" r="1"/>
+            <circle cx="15.2" cy="7" r="1"/>
+            <circle cx="15.2" cy="13" r="1"/>
+            <circle cx="10" cy="16" r="1"/>
+            <circle cx="4.8" cy="13" r="1"/>
+            <circle cx="4.8" cy="7" r="1"/>
+          </g>
+        </>
+      )
+    case 'haskell':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#5D4F85"/>
+          <path fill="#fff" d="m3.5 4.5 3.5 5.5-3.5 5.5h2.7L9.8 10 6.2 4.5zm4 0L11 10l-3.5 5.5h2.7l3.5-5.5-3.5-5.5zm5 3.1 1 1.6H17V7.6zm1.4 3 1 1.7H17v-1.7z"/>
+        </>
+      )
+    case 'ini':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#6E7781"/>
+          <text x="10" y="13.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="7.2" fontWeight="800" fill="#fff">
+            {CODE_MARKS.ini}
+          </text>
+        </>
+      )
+    case 'java':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#427A9B"/>
+          <path d="M9 3.1c2.3 1.7-2.4 2.5.5 4.5m2-4.9c2.3 1.9-3.2 2.9-.2 5" stroke="#FF6B57" strokeWidth="1.15" strokeLinecap="round"/>
+          <path d="M5.4 8.6h8c-.2 3.7-1.3 4.8-4 4.8-2.6 0-3.8-1.2-4-4.8Zm7.8 1h1c1.6 0 1.6 2.5-.5 2.5M4.7 14.8c2.5.8 7.5.8 10.1 0M5.7 16.5c2 .5 6.3.6 8.3 0" stroke="#fff" strokeWidth="1.05" strokeLinecap="round"/>
+        </>
+      )
+    case 'javascript':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#F7DF1E"/>
+          <text x="10" y="14" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.4" fontWeight="800" fill="#191919">
+            {CODE_MARKS.javascript}
+          </text>
+        </>
+      )
+    case 'json':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#4A4A4A"/>
+          <text x="10" y="13.6" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="9.4" fontWeight="700" fill="#F7DF1E">
+            {'{ }'}
+          </text>
+        </>
+      )
+    case 'kotlin':
+      return (
+        <>
+          <defs>
+            <linearGradient id={gradientId} x1="1" y1="19" x2="19" y2="1">
+              <stop stopColor="#0095D5"/>
+              <stop offset=".5" stopColor="#7F52FF"/>
+              <stop offset="1" stopColor="#F88909"/>
+            </linearGradient>
+          </defs>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill={`url(#${gradientId})`}/>
+          <path fill="#fff" d="M5 5h2.2v4.1L11.1 5h3l-4.6 4.6 5 5.4h-3l-3.6-4-.7.7V15H5z"/>
+        </>
+      )
+    case 'lua':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#000080"/>
+          <circle cx="9" cy="11" r="5.7" fill="none" stroke="#fff" strokeWidth="1"/>
+          <circle cx="13.1" cy="6.6" r="2.3" fill="#000080" stroke="#fff" strokeWidth="1"/>
+          <circle cx="13.8" cy="5.9" r=".65" fill="#fff"/>
+          <circle cx="10.7" cy="9" r=".9" fill="#fff"/>
+        </>
+      )
+    case 'makefile':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#427819"/>
+          <path fill="#fff" d="m5 4.5 2.3 2.3 2.3-2.3L11 5.9 8.7 8.2l2.4 2.4L9.7 12l-2.4-2.4L5 11.9 3.6 10.5l2.3-2.3-2.3-2.3zm7 6.5h4v1.5h-4zm0 2.8h4v1.5h-4z"/>
+        </>
+      )
+    case 'node':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#539E43"/>
+          <path fill="none" stroke="#fff" strokeWidth="1.35" strokeLinejoin="round" d="m10 3.5 6 3.3v6.4l-6 3.3-6-3.3V6.8z"/>
+          <path fill="#fff" d="M6.8 7h1.8l2.8 4.2V7h1.8v6h-1.8L8.6 8.8V13H6.8z"/>
+        </>
+      )
+    case 'objective-c':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#438EFF"/>
+          <text x="10" y="13.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="7.2" fontWeight="800" fill="#fff">
+            {CODE_MARKS.objectiveC}
+          </text>
+        </>
+      )
+    case 'perl':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#39457E"/>
+          <text x="10" y="13.3" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="7.8" fontWeight="800" fill="#fff">
+            {CODE_MARKS.perl}
+          </text>
+        </>
+      )
+    case 'php':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#777BB4"/>
+          <ellipse cx="10" cy="10" rx="7" ry="4.5" fill="none" stroke="#fff" strokeWidth="1"/>
+          <text x="10" y="12.1" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="5.8" fontStyle="italic" fontWeight="800" fill="#fff">
+            {CODE_MARKS.php}
+          </text>
+        </>
+      )
+    case 'powershell':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#2671BE"/>
+          <path fill="#fff" fillOpacity=".16" d="M5.5 4h11l-3.5 12H2z"/>
+          <path d="m6.5 6.5 3 3-4.2 3.2m4 1h4" fill="none" stroke="#fff" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
+        </>
+      )
+    case 'protobuf':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#4285F4"/>
+          <path fill="none" stroke="#fff" strokeWidth="1.2" strokeLinejoin="round" d="m10 3.5 6 3.3v6.4l-6 3.3-6-3.3V6.8z"/>
+          <path fill="#fff" d="M6.5 6.5h4.4c2.3 0 3.6 1.2 3.6 3.1s-1.3 3.1-3.6 3.1H9v2H6.5zM9 8.4v2.4h1.6c.8 0 1.2-.4 1.2-1.2s-.4-1.2-1.2-1.2z"/>
+        </>
+      )
+    case 'python':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#306998"/>
+          <path fill="#fff" d="M9.8 3.2c-3.5 0-3.6 1.5-3.6 1.5v2.5H10V8H5s-2.2-.2-2.2 3.3 2 3.4 2 3.4h1.8v-2.6c0-2 1.8-2.4 3.2-2.4H13c1.8 0 2.4-1.4 2.4-3.2S13.8 3.2 12 3.2z"/>
+          <circle cx="8.1" cy="5.1" r=".65" fill="#306998"/>
+          <path fill="#FFD43B" d="M10.2 16.8c3.5 0 3.6-1.5 3.6-1.5v-2.5H10V12h5s2.2.2 2.2-3.3-2-3.4-2-3.4h-1.8v2.6c0 2-1.8 2.4-3.2 2.4H7c-1.8 0-2.4 1.4-2.4 3.2s1.6 3.3 3.4 3.3z"/>
+          <circle cx="11.9" cy="14.9" r=".65" fill="#306998"/>
+        </>
+      )
+    case 'r':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#276DC3"/>
+          <ellipse cx="9" cy="9.4" rx="6.4" ry="4.5" fill="#fff" fillOpacity=".9"/>
+          <ellipse cx="8.3" cy="8.9" rx="4.5" ry="2.8" fill="#276DC3"/>
+          <path fill="#fff" d="M8 7h4c2.2 0 3.4.9 3.4 2.5 0 1-.6 1.7-1.7 2.1l2.5 4.1h-2.7l-2-3.7h-.9v3.7H8zm2.6 1.9v1.4h1c.7 0 1.1-.2 1.1-.7s-.4-.7-1.1-.7z"/>
+        </>
+      )
+    case 'react':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#20232A"/>
+          <circle cx="10" cy="10" r="1.4" fill="#61DAFB"/>
+          <g stroke="#61DAFB" strokeWidth="1">
+            <ellipse cx="10" cy="10" rx="6.8" ry="2.7"/>
+            <ellipse cx="10" cy="10" rx="6.8" ry="2.7" transform="rotate(60 10 10)"/>
+            <ellipse cx="10" cy="10" rx="6.8" ry="2.7" transform="rotate(120 10 10)"/>
+          </g>
+        </>
+      )
+    case 'ruby':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#B51F27"/>
+          <path fill="#fff" d="m4 7 3-3h6l3 3-6 9-6-6z"/>
+          <path fill="#F15A4A" d="m7 4 1.8 3H4zm1.8 3L13 4l3 3zm0 0L10 16 4 10zm0 0H16l-6 9z"/>
+        </>
+      )
+    case 'rust':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#2B2B2B"/>
+          <path fill="#DEA584" d="m10 2.8.7 1.3 1.4-.4.1 1.5 1.5.2-.6 1.4 1.3.8-.1.1 1.4.7-.9 1.2 1 1.1-1.2.8.6 1.3-1.4.4.3 1.5-1.5.1-.4 1.4-1.3-.5-.9 1.2-.9-1.2-1.3.5-.4-1.4-1.5-.1.3-1.5-1.4-.4.6-1.3-1.2-.8 1-1.1-.9-1.2 1.4-.7-.1-.1 1.3-.8-.6-1.4 1.5-.2.1-1.5 1.4.4z"/>
+          <circle cx="10" cy="10" r="4.6" fill="#2B2B2B"/>
+          <text x="10" y="13.1" textAnchor="middle" fontFamily="Georgia,serif" fontSize="8.2" fontWeight="700" fill="#DEA584">
+            {CODE_MARKS.r}
+          </text>
+        </>
+      )
+    case 'scala':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#DE3423"/>
+          <path fill="#fff" d="M5 4.2c3.3 1.1 6.7-.3 10-.9v3.3c-3.3.5-6.7 2-10 .8zm0 4.7c3.3 1.2 6.7-.3 10-.8v3.3c-3.3.5-6.7 1.9-10 .8zm0 4.7c3.3 1.2 6.7-.3 10-.8v3.3c-3.3.5-6.7 1.9-10 .8z"/>
+        </>
+      )
+    case 'shell':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#303642"/>
+          <path d="m5 6.5 3.5 3.5L5 13.5m5 0h5" fill="none" stroke="#7EE787" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
+        </>
+      )
+    case 'solidity':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#555"/>
+          <path fill="#fff" d="M7.2 3.2h5.6L10 8zm-.5.8 2.8 4.8H4zm6.6 0L16 8.8h-5.5zM4 10.3h5.5l-2.8 4.8zm6.5 0H16l-2.7 4.8zM10 11.2l2.8 4.8H7.2z"/>
+        </>
+      )
+    case 'sql':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#336791"/>
+          <ellipse cx="10" cy="5.5" rx="5.8" ry="2.1" fill="#fff"/>
+          <path fill="#fff" fillOpacity=".88" d="M4.2 5.5v8.9c0 1.2 2.6 2.1 5.8 2.1s5.8-.9 5.8-2.1V5.5c0 1.2-2.6 2.1-5.8 2.1s-5.8-.9-5.8-2.1z"/>
+          <path d="M4.2 9.2c0 1.2 2.6 2.1 5.8 2.1s5.8-.9 5.8-2.1M4.2 12.9c0 1.2 2.6 2.1 5.8 2.1s5.8-.9 5.8-2.1" fill="none" stroke="#336791" strokeOpacity=".7" strokeWidth=".7"/>
+        </>
+      )
+    case 'svelte':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#FF3E00"/>
+          <path d="M13.9 6.1c-.9-1.1-2.6-1.4-3.8-.7L7 7.4c-1.2.8-1.5 2.4-.7 3.6.7 1 2 1.4 3.1 1l3-1.2c.5-.2 1 .1 1.2.5.2.4 0 .9-.4 1.2l-3.1 2c-.5.3-1.1.2-1.5-.2" stroke="#fff" strokeWidth="1.6" strokeLinecap="round"/>
+          <path d="M6.1 13.9c.9 1.1 2.6 1.4 3.8.7l3.1-2c1.2-.8 1.5-2.4.7-3.6-.7-1-2-1.4-3.1-1l-3 1.2c-.5.2-1-.1-1.2-.5-.2-.4 0-.9.4-1.2l3.1-2c.5-.3 1.1-.2 1.5.2" stroke="#fff" strokeWidth="1.6" strokeLinecap="round"/>
+        </>
+      )
+    case 'swift':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#F05138"/>
+          <path fill="#fff" d="M15.9 14.9c.3-3.6-1.2-6.2-3.8-8.4 1.1 1.5 1.7 2.9 1.9 4.1C10.9 8.4 8 6 5.4 3.7c1.6 2.3 3.4 4.4 5.4 6.2-1.7-1-3.5-2.2-5.6-3.7 2.3 3 4.7 5.1 7.3 6.1-2 .9-4.5.7-7.2-.8 2.2 3.2 5.6 4.1 8.3 2.4.8.4 1.6.8 2.3 1z"/>
+        </>
+      )
+    case 'toml':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#9C4121"/>
+          <text x="10" y="12.9" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="5.6" fontWeight="800" fill="#fff">
+            {CODE_MARKS.toml}
+          </text>
+        </>
+      )
+    case 'typescript':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#3178C6"/>
+          <text x="10" y="14" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="8.4" fontWeight="800" fill="#fff">
+            {CODE_MARKS.typescript}
+          </text>
+        </>
+      )
+    case 'vue':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#41B883"/>
+          <path fill="#fff" d="M3.5 5h3.2l3.3 5.7L13.3 5h3.2L10 16z"/>
+          <path fill="#35495E" d="M6.7 5H9l1 1.8L11 5h2.3L10 10.7z"/>
+        </>
+      )
+    case 'wasm':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#654FF0"/>
+          <path fill="#fff" d="M4 6h2l.8 5.1L8 7h2l1.2 4.1L12 6h2l-1.7 8h-2l-1.3-4-1.3 4h-2zm10.5 0H17v8h-2V8h-.5z"/>
+        </>
+      )
+    case 'xml':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#F28C28"/>
+          <path d="m7.5 6-4 4 4 4m5-8 4 4-4 4M11 5 9 15" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
+        </>
+      )
+    case 'yaml':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#CB171E"/>
+          <text x="10" y="12.9" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="5.6" fontWeight="800" fill="#fff">
+            {CODE_MARKS.yaml}
+          </text>
+        </>
+      )
+    case 'zig':
+      return (
+        <>
+          <rect x="1" y="1" width="18" height="18" rx="4" fill="#F7A41D"/>
+          <path fill="#fff" d="M3.5 6h4L6 7.5h5L7.5 12h5L11 14h5.5l-.8-3 1-5h-4.4L14 7.5h-3.7L13 4.2H6L7.3 6z"/>
+          <text x="10" y="12.2" textAnchor="middle" fontFamily="Arial,sans-serif" fontSize="4.6" fontWeight="900" fill="#F7A41D">
+            {CODE_MARKS.zig}
+          </text>
+        </>
+      )
+    /* 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)}`)
+}

+ 0 - 41
packages/client/ui-primitives/src/DocumentFileIcon.tsx

@@ -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 (
-    <svg
-      className={className}
-      xmlns="http://www.w3.org/2000/svg"
-      width="24"
-      height="28"
-      viewBox="0 0 24 28"
-      fill="none"
-      aria-hidden
-    >
-      <path
-        d="M16.5 0l7 7v15.6c0 2.25 0 3.375-.573 4.164a3 3 0 0 1-.663.663C21.475 28 20.349 28 18.1 28H5.9c-2.25 0-3.375 0-4.164-.573a3 3 0 0 1-.663-.663C.5 25.975.5 24.849.5 22.6V5.4c0-2.25 0-3.375.573-4.164a3 3 0 0 1 .663-.663C2.525 0 3.651 0 5.9 0h10.6z"
-        fill={`url(#${gradientId})`}
-      />
-      <path
-        d="M16.5 0l7 7h-3.8c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C16.5 5.48 16.5 4.92 16.5 3.8V0z"
-        fill="#fff"
-        fillOpacity=".55"
-      />
-      <path
-        d="M6 11.784c0-.433.351-.784.784-.784h10.432a.784.784 0 1 1 0 1.568H6.784A.784.784 0 0 1 6 11.784zM6 15.784c0-.433.351-.784.784-.784h10.432a.784.784 0 1 1 0 1.568H6.784A.784.784 0 0 1 6 15.784zM6.114 19.817c0-.433.35-.784.784-.784h6.318a.784.784 0 1 1 0 1.568H6.898a.784.784 0 0 1-.784-.784z"
-        fill="#fff"
-      />
-      <defs>
-        <linearGradient id={gradientId} x1="1.5" y1="-1" x2="23.5" y2="28" gradientUnits="userSpaceOnUse">
-          <stop stopColor="#6D93FF" />
-          <stop offset="1" stopColor="#5A71F0" />
-        </linearGradient>
-      </defs>
-    </svg>
-  )
-}

+ 50 - 0
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);
+}

+ 245 - 144
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 categories with distinct 28px glyphs. */
+export type FileType =
+  | CodeFileType
+  | 'code'
+  | 'excel'
+  | 'folder'
+  | 'html'
+  | 'image'
+  | 'markdown'
+  | 'other'
+  | 'pdf'
+  | 'ppt'
+  | 'video'
+  | 'word'
+
+/** Compatibility name for consumers that pass an already resolved category. */
+export type FileTypeKind = FileType
+
+type ClassifiedFileType = Exclude<FileType, 'folder'>
+type TraditionalFileType = Exclude<FileType, CodeFileType>
 
-/** 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'
+export type { CodeFileType, FileTypeProjectContext } from './code-file-types.ts'
 
-/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */
-export interface FileTypeIconProps extends IconProps {
-  kind: FileTypeKind
+/** 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<Record<string, ClassifiedFileType>> = {
+  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<string>])[] = [
-  ['markdown', MARKDOWN_EXTENSIONS],
-  ['html', HTML_EXTENSIONS],
-  ['pdf', PDF_EXTENSIONS],
-  ['sheet', SHEET_EXTENSIONS],
-  ['slides', SLIDES_EXTENSIONS],
-  ['document', WORD_EXTENSIONS],
-  ['image', IMAGE_EXTENSIONS],
-  ['code', CODE_EXTENSIONS],
-]
+const NAME_TYPES: Readonly<Record<string, ClassifiedFileType>> = {
+  changelog: 'markdown',
+  contributing: 'markdown',
+  readme: 'markdown',
+}
+
+function basename(path: string): string {
+  return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
+}
 
 /**
- * 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.
+ * 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 classifyFileType(path: string): FileTypeKind {
-  const extension = fileExtension(path)
-  return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other'
+export function fileExtension(path: string): string {
+  const name = basename(path)
+  const dot = name.lastIndexOf('.')
+  return dot < 0 ? '' : name.slice(dot + 1)
 }
 
-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'
+/**
+ * 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 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'
+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)'
 
-/** One type's sheet colour and its white mark. */
-interface Face {
-  readonly fill: string
-  readonly mark: ReactNode
+function FileGlyph({
+  size, className, children, markTransform = FILE_MARK_TRANSFORM, muted = false,
+}: IconProps & { children?: ReactNode; markTransform?: string; muted?: boolean }): ReactNode {
+  return (
+    <svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+      <path d={FILE_BODY} fill="currentColor" />
+      {muted
+        ? <path d={FILE_FOLD} fill="var(--dsw-static-neutral-400)" />
+        : <path d={FILE_FOLD} fill="var(--dsw-static-neutral-00)" fillOpacity=".7" />}
+      {children !== undefined && (
+        <g color="var(--dsw-static-neutral-00)" data-file-type-mark transform={markTransform}>{children}</g>
+      )}
+    </svg>
+  )
 }
 
-const FACES: Readonly<Record<FileTypeKind, Face>> = {
-  code: {
-    fill: '#4176E6',
-    mark: (
-      <>
-        <path d="M8.61 16.3601L11.76 18.3901V20.1401L7 17.0601V15.6601L11.76 12.5801V14.3301L8.61 16.3601Z" fill="white" />
-        <path d="M16.1918 14.3301V12.5801L20.9518 15.6601V17.0601L16.1918 20.1401V18.3901L19.3418 16.3601L16.1918 14.3301Z" fill="white" />
-      </>
-    ),
-  },
-  html: {
-    fill: '#4176E6',
-    mark: (
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M13.9983 9.68311C17.2109 9.68311 19.8156 12.2873 19.8157 15.4998C19.8157 18.7124 17.211 21.3172 13.9983 21.3172C10.7858 21.317 8.18164 18.7123 8.18164 15.4998C8.1818 12.2874 10.7859 9.68327 13.9983 9.68311ZM9.26104 16.0248C9.46915 17.9243 10.7925 19.4877 12.5628 20.0464C12.4189 19.7978 12.2941 19.5153 12.1868 19.2117C11.8839 18.3543 11.6919 17.2436 11.6413 16.0248H9.26104ZM16.3554 16.0248C16.3048 17.2435 16.1134 18.3543 15.8105 19.2117C15.7033 19.5153 15.5777 19.7972 15.4339 20.0457C17.2043 19.4871 18.5282 17.9243 18.7363 16.0248H16.3554ZM12.6927 16.0248C12.7428 17.146 12.9201 18.1335 13.1773 18.8617C13.3309 19.2963 13.5019 19.6102 13.6675 19.8051C13.8329 19.9998 13.9462 20.0257 13.9983 20.0259C14.0503 20.0259 14.164 20.0003 14.3299 19.8051C14.4955 19.6101 14.6665 19.2963 14.82 18.8617C15.0773 18.1335 15.2546 17.146 15.3047 16.0248H12.6927ZM13.9983 10.7331C13.9462 10.7332 13.8329 10.7599 13.6675 10.9546C13.5019 11.1495 13.3308 11.4634 13.1773 11.8979C12.9019 12.6778 12.7177 13.7546 12.6838 14.9748H15.3136C15.2797 13.7546 15.0955 12.6778 14.82 11.8979C14.6665 11.4634 14.4954 11.1495 14.3299 10.9546C14.164 10.7594 14.0503 10.7331 13.9983 10.7331ZM15.5877 11.0052C15.669 11.1758 15.7433 11.3577 15.8105 11.5479C16.1332 12.4615 16.3297 13.6621 16.3636 14.9748H18.7363C18.5341 13.1308 17.2806 11.6037 15.5877 11.0052ZM12.409 11.0052C10.7163 11.6038 9.46318 13.1309 9.26104 14.9748H11.6338C11.6677 13.6621 11.8641 12.4615 12.1868 11.5479C12.254 11.3578 12.3277 11.1757 12.409 11.0052Z"
-        fill="white"
-      />
-    ),
-  },
-  image: {
-    fill: '#8B76F6',
-    mark: (
-      <>
-        <path d="M10.4211 15.9204C10.5755 15.6558 10.9578 15.6558 11.1121 15.9204L13.6491 20.2696C13.8047 20.5362 13.6123 20.8711 13.3036 20.8711H8.22962C7.9209 20.8711 7.72855 20.5362 7.88411 20.2696L10.4211 15.9204Z" fill="white" />
-        <path d="M15.498 13.186C15.6504 12.9117 16.0449 12.9117 16.1973 13.186L20.1367 20.2769C20.2848 20.5435 20.092 20.8711 19.787 20.8711H11.9083C11.6033 20.8711 11.4105 20.5435 11.5587 20.2769L15.498 13.186Z" fill="white" />
-        <path d="M11.8599 11.3997C11.8599 12.286 11.1415 13.0045 10.2552 13.0045C9.36887 13.0045 8.65039 12.286 8.65039 11.3997C8.65039 10.5134 9.36887 9.79492 10.2552 9.79492C11.1415 9.79492 11.8599 10.5134 11.8599 11.3997Z" fill="white" />
-      </>
-    ),
-  },
-  markdown: {
-    fill: '#4176E6',
-    mark: (
-      <path
-        d="M8.75904 19.5V14.6H9.90004L11.93 17.932H11.328L13.302 14.6H14.443L14.457 19.5H13.183L13.169 16.539H13.386L11.909 19.017H11.293L9.77404 16.539H10.04V19.5H8.75904ZM15.4378 19.5V14.6H17.7548C18.2961 14.6 18.7721 14.7003 19.1828 14.901C19.5934 15.1017 19.9131 15.384 20.1418 15.748C20.3751 16.112 20.4918 16.546 20.4918 17.05C20.4918 17.5493 20.3751 17.9833 20.1418 18.352C19.9131 18.716 19.5934 18.9983 19.1828 19.199C18.7721 19.3997 18.2961 19.5 17.7548 19.5H15.4378ZM16.8238 18.394H17.6988C17.9788 18.394 18.2214 18.3427 18.4268 18.24C18.6368 18.1327 18.8001 17.9787 18.9168 17.778C19.0334 17.5727 19.0918 17.33 19.0918 17.05C19.0918 16.7653 19.0334 16.5227 18.9168 16.322C18.8001 16.1213 18.6368 15.9697 18.4268 15.867C18.2214 15.7597 17.9788 15.706 17.6988 15.706H16.8238V18.394Z"
-        fill="white"
-      />
-    ),
-  },
-  pdf: {
-    fill: '#EC1313',
-    mark: (
-      <path
-        d="M6.80641 19.5V14.6H9.04641C9.49441 14.6 9.87941 14.6723 10.2014 14.817C10.5281 14.9617 10.7801 15.1717 10.9574 15.447C11.1347 15.7177 11.2234 16.0397 11.2234 16.413C11.2234 16.7817 11.1347 17.1013 10.9574 17.372C10.7801 17.6427 10.5281 17.8527 10.2014 18.002C9.87941 18.1467 9.49441 18.219 9.04641 18.219H7.57641L8.19241 17.617V19.5H6.80641ZM8.19241 17.764L7.57641 17.127H8.96241C9.25174 17.127 9.46641 17.064 9.60641 16.938C9.75107 16.812 9.82341 16.637 9.82341 16.413C9.82341 16.1843 9.75107 16.007 9.60641 15.881C9.46641 15.755 9.25174 15.692 8.96241 15.692H7.57641L8.19241 15.055V17.764ZM11.8992 19.5V14.6H14.2162C14.7575 14.6 15.2335 14.7003 15.6442 14.901C16.0548 15.1017 16.3745 15.384 16.6032 15.748C16.8365 16.112 16.9532 16.546 16.9532 17.05C16.9532 17.5493 16.8365 17.9833 16.6032 18.352C16.3745 18.716 16.0548 18.9983 15.6442 19.199C15.2335 19.3997 14.7575 19.5 14.2162 19.5H11.8992ZM13.2852 18.394H14.1602C14.4402 18.394 14.6828 18.3427 14.8882 18.24C15.0982 18.1327 15.2615 17.9787 15.3782 17.778C15.4948 17.5727 15.5532 17.33 15.5532 17.05C15.5532 16.7653 15.4948 16.5227 15.3782 16.322C15.2615 16.1213 15.0982 15.9697 14.8882 15.867C14.6828 15.7597 14.4402 15.706 14.1602 15.706H13.2852V18.394ZM17.6824 19.5V14.6H21.5254V15.671H19.0684V19.5H17.6824ZM18.9704 17.82V16.749H21.2314V17.82H18.9704Z"
-        fill="white"
-      />
-    ),
-  },
-  sheet: {
-    fill: '#22C55E',
-    mark: (
-      <path
-        d="M10.2935 20.5L13.3535 16.25L13.3435 17.66L10.4035 13.5H12.6335L14.5135 16.21L13.5635 16.22L15.4135 13.5H17.5535L14.6135 17.58V16.18L17.7135 20.5H15.4335L13.5235 17.65H14.4335L12.5535 20.5H10.2935Z"
-        fill="white"
-      />
-    ),
-  },
-  slides: {
-    fill: '#F59E0B',
-    mark: (
-      <path
-        d="M11.0135 20.5V13.5H14.2135C14.8535 13.5 15.4035 13.6033 15.8635 13.81C16.3301 14.0167 16.6901 14.3167 16.9435 14.71C17.1968 15.0967 17.3235 15.5567 17.3235 16.09C17.3235 16.6167 17.1968 17.0733 16.9435 17.46C16.6901 17.8467 16.3301 18.1467 15.8635 18.36C15.4035 18.5667 14.8535 18.67 14.2135 18.67H12.1135L12.9935 17.81V20.5H11.0135ZM12.9935 18.02L12.1135 17.11H14.0935C14.5068 17.11 14.8135 17.02 15.0135 16.84C15.2201 16.66 15.3235 16.41 15.3235 16.09C15.3235 15.7633 15.2201 15.51 15.0135 15.33C14.8135 15.15 14.5068 15.06 14.0935 15.06H12.1135L12.9935 14.15V18.02Z"
-        fill="white"
-      />
-    ),
-  },
-  document: {
-    fill: '#5686FE',
-    mark: (
+function FolderGlyph({ size, className }: IconProps): ReactNode {
+  return (
+    <svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
       <path
-        d="M10.512 20.5L8.24203 13.5H10.282L12.192 19.56H11.162L13.172 13.5H14.992L16.892 19.56H15.902L17.872 13.5H19.762L17.492 20.5H15.372L13.752 15.35H14.322L12.632 20.5H10.512Z"
-        fill="white"
+        d="M2.80078 10.2112C2.80078 9.52802 2.80078 9.18642 2.85314 8.866C2.98648 8.05 3.36942 7.29538 3.94925 6.70595C4.17694 6.4745 4.45264 6.2728 5.00404 5.86939C5.29197 5.65874 5.43594 5.55342 5.58711 5.46632C5.97089 5.24521 6.39638 5.10618 6.83667 5.05803C7.01011 5.03906 7.18849 5.03906 7.54524 5.03906H11.6543C12.4669 5.03906 12.8732 5.03906 13.2599 5.13697C13.3882 5.16945 13.5143 5.20986 13.6375 5.25795C14.0091 5.40294 14.3398 5.63902 15.0012 6.1112L16.2632 7.01224C16.5526 7.21881 16.6972 7.3221 16.8598 7.38553C16.9137 7.40657 16.9689 7.42425 17.025 7.43846C17.1942 7.4813 17.372 7.4813 17.7275 7.4813H19.8008C22.0506 7.4813 23.1755 7.4813 23.9641 8.05425C24.2188 8.23928 24.4428 8.46326 24.6278 8.71794C25.2008 9.50654 25.2008 10.6315 25.2008 12.8813V18.7283C25.2008 20.9781 25.2008 22.1031 24.6278 22.8917C24.4428 23.1463 24.2188 23.3703 23.9641 23.5554C23.1755 24.1283 22.0506 24.1283 19.8008 24.1283H8.20077C5.95094 24.1283 4.82602 24.1283 4.03743 23.5554C3.78274 23.3703 3.55877 23.1463 3.37373 22.8917C2.80078 22.1031 2.80078 20.9781 2.80078 18.7283V10.2112Z"
+        fill="currentColor"
       />
-    ),
-  },
-  other: {
-    fill: '#CFD3D6',
-    mark: (
-      <>
-        <rect x="7.98242" y="10.792" width="12.0368" height="1.46414" fill="#545557" />
-        <rect x="7.98242" y="14.9221" width="12.0368" height="1.46414" fill="#545557" />
-        <rect x="7.98242" y="19.0522" width="7.57257" height="1.46414" fill="#545557" />
-      </>
-    ),
-  },
+      <g color="var(--dsw-static-neutral-00)" data-file-type-mark transform={FOLDER_MARK_TRANSFORM}>
+        <path d="M6.31445 13.0693H21.6893" stroke="currentColor" strokeWidth="2.38" />
+      </g>
+    </svg>
+  )
+}
+
+function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode {
+  switch (type) {
+    case 'code':
+      return (
+        <FileGlyph size={size} className={className}>
+          <path d="M8.61 16.3601L11.76 18.3901V20.1401L7 17.0601V15.6601L11.76 12.5801V14.3301L8.61 16.3601Z" fill="currentColor" />
+          <path d="M16.1918 14.3301V12.5801L20.9518 15.6601V17.0601L16.1918 20.1401V18.3901L19.3418 16.3601L16.1918 14.3301Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'excel':
+      return (
+        <FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
+          <path d="M10.2932 20.5L13.3532 16.25L13.3432 17.66L10.4032 13.5H12.6332L14.5132 16.21L13.5632 16.22L15.4132 13.5H17.5532L14.6132 17.58V16.18L17.7132 20.5H15.4332L13.5232 17.65H14.4332L12.5532 20.5H10.2932Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'folder': return <FolderGlyph size={size} className={className} />
+    case 'html':
+      return (
+        <FileGlyph size={size} className={className}>
+          <path
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d="M13.9994 9.68298C17.212 9.68298 19.8167 12.2872 19.8168 15.4997C19.8168 18.7123 17.2121 21.3171 13.9994 21.3171C10.7869 21.3169 8.18274 18.7122 8.18274 15.4997C8.1829 12.2873 10.787 9.68315 13.9994 9.68298ZM9.26213 16.0247C9.47025 17.9241 10.7936 19.4876 12.5639 20.0463C12.42 19.7977 12.2952 19.5152 12.1879 19.2116C11.885 18.3541 11.693 17.2434 11.6424 16.0247H9.26213ZM16.3565 16.0247C16.3059 17.2434 16.1145 18.3542 15.8116 19.2116C15.7044 19.5151 15.5788 19.7971 15.435 20.0456C17.2054 19.487 18.5293 17.9242 18.7374 16.0247H16.3565ZM12.6938 16.0247C12.7439 17.1459 12.9212 18.1334 13.1784 18.8616C13.332 19.2962 13.503 19.61 13.6686 19.805C13.834 19.9996 13.9473 20.0256 13.9994 20.0258C14.0514 20.0258 14.1651 20.0002 14.331 19.805C14.4966 19.61 14.6676 19.2962 14.8211 18.8616C15.0784 18.1334 15.2557 17.1459 15.3058 16.0247H12.6938ZM13.9994 10.733C13.9473 10.7331 13.834 10.7598 13.6686 10.9545C13.503 11.1494 13.3319 11.4633 13.1784 11.8978C12.903 12.6777 12.7188 13.7545 12.6849 14.9747H15.3147C15.2808 13.7545 15.0966 12.6777 14.8211 11.8978C14.6676 11.4633 14.4965 11.1494 14.331 10.9545C14.1651 10.7593 14.0514 10.733 13.9994 10.733ZM15.5888 11.0051C15.6701 11.1756 15.7444 11.3576 15.8116 11.5478C16.1343 12.4613 16.3308 13.6619 16.3647 14.9747H18.7374C18.5352 13.1307 17.2817 11.6036 15.5888 11.0051ZM12.4101 11.0051C10.7174 11.6037 9.46428 13.1308 9.26213 14.9747H11.6349C11.6688 13.6619 11.8652 12.4613 12.1879 11.5478C12.2551 11.3577 12.3288 11.1756 12.4101 11.0051Z"
+            fill="currentColor"
+          />
+        </FileGlyph>
+      )
+    case 'image':
+      return (
+        <FileGlyph size={size} className={className}>
+          <path d="M10.4212 15.9204C10.5756 15.6558 10.9579 15.6558 11.1123 15.9204L13.6493 20.2696C13.8048 20.5362 13.6125 20.8711 13.3037 20.8711H8.22974C7.92102 20.8711 7.72868 20.5362 7.88423 20.2696L10.4212 15.9204Z" fill="currentColor" />
+          <path d="M15.4981 13.186C15.6505 12.9117 16.0451 12.9117 16.1975 13.186L20.1368 20.2769C20.2849 20.5435 20.0922 20.8711 19.7872 20.8711H11.9084C11.6034 20.8711 11.4107 20.5435 11.5588 20.2769L15.4981 13.186Z" fill="currentColor" />
+          <path d="M11.8603 11.3997C11.8603 12.286 11.1418 13.0045 10.2555 13.0045C9.36924 13.0045 8.65076 12.286 8.65076 11.3997C8.65076 10.5134 9.36924 9.79492 10.2555 9.79492C11.1418 9.79492 11.8603 10.5134 11.8603 11.3997Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'markdown':
+      return (
+        <FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
+          <path d="M8.7588 19.5V14.6H9.8998L11.9298 17.932H11.3278L13.3018 14.6H14.4428L14.4568 19.5H13.1828L13.1688 16.539H13.3858L11.9088 19.017H11.2928L9.7738 16.539H10.0398V19.5H8.7588ZM15.4375 19.5V14.6H17.7545C18.2958 14.6 18.7718 14.7003 19.1825 14.901C19.5932 15.1017 19.9128 15.384 20.1415 15.748C20.3748 16.112 20.4915 16.546 20.4915 17.05C20.4915 17.5493 20.3748 17.9833 20.1415 18.352C19.9128 18.716 19.5932 18.9983 19.1825 19.199C18.7718 19.3997 18.2958 19.5 17.7545 19.5H15.4375ZM16.8235 18.394H17.6985C17.9785 18.394 18.2212 18.3427 18.4265 18.24C18.6365 18.1327 18.7998 17.9787 18.9165 17.778C19.0332 17.5727 19.0915 17.33 19.0915 17.05C19.0915 16.7653 19.0332 16.5227 18.9165 16.322C18.7998 16.1213 18.6365 15.9697 18.4265 15.867C18.2212 15.7597 17.9785 15.706 17.6985 15.706H16.8235V18.394Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'other': return <FileGlyph size={size} className={className} muted />
+    case 'pdf':
+      return (
+        <FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
+          <path d="M6.80616 19.5V14.6H9.04616C9.49416 14.6 9.87916 14.6723 10.2012 14.817C10.5278 14.9617 10.7798 15.1717 10.9572 15.447C11.1345 15.7177 11.2232 16.0397 11.2232 16.413C11.2232 16.7817 11.1345 17.1013 10.9572 17.372C10.7798 17.6427 10.5278 17.8527 10.2012 18.002C9.87916 18.1467 9.49416 18.219 9.04616 18.219H7.57616L8.19216 17.617V19.5H6.80616ZM8.19216 17.764L7.57616 17.127H8.96216C9.2515 17.127 9.46616 17.064 9.60616 16.938C9.75083 16.812 9.82316 16.637 9.82316 16.413C9.82316 16.1843 9.75083 16.007 9.60616 15.881C9.46616 15.755 9.2515 15.692 8.96216 15.692H7.57616L8.19216 15.055V17.764ZM11.8989 19.5V14.6H14.2159C14.7573 14.6 15.2333 14.7003 15.6439 14.901C16.0546 15.1017 16.3743 15.384 16.6029 15.748C16.8363 16.112 16.9529 16.546 16.9529 17.05C16.9529 17.5493 16.8363 17.9833 16.6029 18.352C16.3743 18.716 16.0546 18.9983 15.6439 19.199C15.2333 19.3997 14.7573 19.5 14.2159 19.5H11.8989ZM13.2849 18.394H14.1599C14.4399 18.394 14.6826 18.3427 14.8879 18.24C15.0979 18.1327 15.2613 17.9787 15.3779 17.778C15.4946 17.5727 15.5529 17.33 15.5529 17.05C15.5529 16.7653 15.4946 16.5227 15.3779 16.322C15.2613 16.1213 15.0979 15.9697 14.8879 15.867C14.6826 15.7597 14.4399 15.706 14.1599 15.706H13.2849V18.394ZM17.6821 19.5V14.6H21.5251V15.671H19.0681V19.5H17.6821ZM18.9701 17.82V16.749H21.2311V17.82H18.9701Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'ppt':
+      return (
+        <FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
+          <path d="M11.0132 20.5V13.5H14.2132C14.8532 13.5 15.4032 13.6033 15.8632 13.81C16.3299 14.0167 16.6899 14.3167 16.9432 14.71C17.1966 15.0967 17.3232 15.5567 17.3232 16.09C17.3232 16.6167 17.1966 17.0733 16.9432 17.46C16.6899 17.8467 16.3299 18.1467 15.8632 18.36C15.4032 18.5667 14.8532 18.67 14.2132 18.67H12.1132L12.9932 17.81V20.5H11.0132ZM12.9932 18.02L12.1132 17.11H14.0932C14.5066 17.11 14.8132 17.02 15.0132 16.84C15.2199 16.66 15.3232 16.41 15.3232 16.09C15.3232 15.7633 15.2199 15.51 15.0132 15.33C14.8132 15.15 14.5066 15.06 14.0932 15.06H12.1132L12.9932 14.15V18.02Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'video':
+      return (
+        <FileGlyph size={size} className={className}>
+          <path d="M17.5 14.634C18.1667 15.0189 18.1667 15.9811 17.5 16.366L11.5 19.8301C10.8333 20.215 10 19.7339 10 18.9641L10 12.0359C10 11.2661 10.8333 10.785 11.5 11.1699L17.5 14.634Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    case 'word':
+      return (
+        <FileGlyph size={size} className={className} markTransform={LARGE_FILE_MARK_TRANSFORM}>
+          <path d="M10.5118 20.5L8.24179 13.5H10.2818L12.1918 19.56H11.1618L13.1718 13.5H14.9918L16.8918 19.56H15.9018L17.8718 13.5H19.7618L17.4918 20.5H15.3718L13.7518 15.35H14.3218L12.6318 20.5H10.5118Z" fill="currentColor" />
+        </FileGlyph>
+      )
+    /* 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 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.
+ * 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({ kind, size = 28, className }: FileTypeIconProps): ReactNode {
-  const face = FACES[kind]
-  return (
-    <svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
-      <path d={SHEET_PATH} fill={face.fill} />
-      {face.mark}
-      {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */}
-      {kind === 'other'
-        ? <path d={CORNER_PATH} fill="#A2A4A6" />
-        : <path d={CORNER_PATH} fill="white" fillOpacity="0.7" />}
-    </svg>
-  )
+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)
+    ? <CodeFileIcon type={resolvedType} size={size} className={className} />
+    : glyph(resolvedType, size, clsx(css.icon, css[resolvedType], className))
 }

+ 20 - 13
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) => (

+ 32 - 4
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<HTMLButtonElement>('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<HTMLButtonElement>('button')?.focus()
+      }
+      if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
+      const buttons = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>('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

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä