Browse Source

Merge remote-tracking branch 'origin/master' into HEAD

Yichen Jiang 2 months ago
parent
commit
07fbaa9b30
100 changed files with 2400 additions and 231 deletions
  1. 3 0
      .agents/notes/archived/manifest.json
  2. 2 2
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml
  3. 1 0
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md
  4. 1 0
      .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md
  5. 2 2
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
  6. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md
  7. 1 1
      .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
  8. 6 0
      .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml
  9. 43 0
      .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md
  10. 43 0
      .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md
  11. 2 2
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml
  12. 2 1
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md
  13. 2 1
      .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md
  14. 6 0
      .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml
  15. 33 0
      .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md
  16. 33 0
      .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md
  17. 2 2
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
  18. 4 4
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
  19. 4 4
      .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md
  20. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml
  21. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
  22. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md
  23. 6 0
      .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml
  24. 37 0
      .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md
  25. 37 0
      .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md
  26. 6 0
      .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml
  27. 34 0
      .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md
  28. 34 0
      .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md
  29. 45 0
      apps/web/tests/home-path-tilde.snapshot.ts
  30. 336 0
      apps/web/tests/message-feedback-layout.e2e.ts
  31. 12 3
      apps/web/tests/navigation-panes.e2e.ts
  32. 1 1
      apps/web/tests/onboarding-deepseek-config.e2e.ts
  33. 50 4
      apps/web/tests/seeded-history.e2e.ts
  34. 2 0
      apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt
  35. 10 0
      apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md
  36. 7 0
      apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md
  37. 1 0
      apps/web/tsconfig.json
  38. 2 2
      docs/config-catalog.i18n.yaml
  39. 6 2
      docs/config-catalog.md
  40. 6 2
      docs/config-catalog.zh.md
  41. 2 2
      docs/module-graph.i18n.yaml
  42. 6 3
      docs/module-graph.md
  43. 6 3
      docs/module-graph.zh.md
  44. 4 2
      examples/acp-agent/code-mode-image.cordis.snapshot.yml
  45. 7 5
      examples/acp-agent/image.cordis.snapshot.yml
  46. 2 1
      examples/acp-agent/image.cordis.yml
  47. 80 20
      examples/acp-agent/tests/acp.snapshot.ts
  48. 14 19
      examples/acp-agent/tests/fixtures/image-offload.cordis.yml
  49. 4 4
      examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl
  50. 1 1
      examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md
  51. 3 3
      examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl
  52. 4 4
      examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl
  53. 4 4
      examples/acp-agent/tests/snapshots/read-image/session.jsonl
  54. 24 0
      examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md
  55. 9 2
      packages/client/connection/src/client/fixture.ts
  56. 1 1
      packages/client/connection/src/client/index.ts
  57. 5 5
      packages/client/connection/tests/connection.client.spec.ts
  58. 2 1
      packages/client/connection/tests/fake-api.client.ts
  59. 13 5
      packages/client/connection/tests/fixture.client.spec.ts
  60. 2 2
      packages/client/runtime/README.i18n.yaml
  61. 2 0
      packages/client/runtime/README.md
  62. 2 0
      packages/client/runtime/README.zh.md
  63. 1 1
      packages/client/runtime/src/client/index.ts
  64. 24 1
      packages/client/runtime/src/client/workspaces/path.ts
  65. 2 2
      packages/client/runtime/tests/client-apply.client.spec.ts
  66. 2 1
      packages/client/runtime/tests/fake-api.client.ts
  67. 41 0
      packages/client/runtime/tests/path.client.spec.ts
  68. 1 1
      packages/client/runtime/tests/wire-events.client.spec.ts
  69. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  70. 1 1
      packages/client/ui-conversation/README.md
  71. 1 1
      packages/client/ui-conversation/README.zh.md
  72. 1 4
      packages/client/ui-conversation/src/client/apply.ts
  73. 5 0
      packages/client/ui-conversation/src/client/chat/ChatView.module.css
  74. 87 3
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  75. 4 2
      packages/client/ui-conversation/src/client/contract/slots.ts
  76. 8 0
      packages/client/ui-conversation/src/client/locales.ts
  77. 9 1
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  78. 107 4
      packages/client/ui-conversation/tests/chat-view.client.spec.tsx
  79. 1 1
      packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx
  80. 1 1
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  81. 2 2
      packages/client/ui-message-feedback/README.i18n.yaml
  82. 1 1
      packages/client/ui-message-feedback/README.md
  83. 1 1
      packages/client/ui-message-feedback/README.zh.md
  84. 1 0
      packages/client/ui-message-feedback/package.json
  85. 49 13
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
  86. 215 44
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx
  87. 2 0
      packages/client/ui-message-feedback/src/client/locales.ts
  88. 491 1
      packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx
  89. 93 0
      packages/client/ui-message-feedback/tests/styles.client.spec.ts
  90. 17 8
      packages/client/ui-model-selection/src/client/ModelSelect.module.css
  91. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  92. 1 1
      packages/client/ui-primitives/README.md
  93. 1 1
      packages/client/ui-primitives/README.zh.md
  94. 2 0
      packages/client/ui-primitives/src/index.ts
  95. 81 0
      packages/client/ui-primitives/src/useAnchoredPosition.ts
  96. 111 0
      packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx
  97. 2 2
      packages/client/ui-tool/README.i18n.yaml
  98. 1 1
      packages/client/ui-tool/README.md
  99. 1 1
      packages/client/ui-tool/README.zh.md
  100. 2 0
      packages/client/ui-tool/package.json

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -334,6 +334,9 @@
     "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee",
     "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec",
     "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1",
+    "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a",
+    "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b",
+    "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976",
     "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db",
     "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67",
     "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c",

+ 2 - 2
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.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/simplification/2026-07-04-drop-image-content-block.md
-2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0
-2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9
+2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6
+2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c

+ 1 - 0
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md

@@ -1,6 +1,7 @@
 # Agent Note: Drop the `image` content block until a path can honor it
 
 Status: implemented
+Archived: 2026-08-19
 
 English | [中文](2026-07-04-drop-image-content-block.zh.md)
 

+ 1 - 0
.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md → .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它
 
 Status: implemented
+Archived: 2026-08-19
 
 [English](2026-07-04-drop-image-content-block.md) | 中文
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.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/architecture/2026-06-11-content-block-vocabulary.md
-2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022
-2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600
+2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b
+2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md

@@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall
 ## Consequences
 
 - Reasoning has a core home without provider-specific shapes.
-- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md).
+- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md).
 - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes.
 - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests.
 - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost.

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md

@@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
 ## 后果
 
 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。
-- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。
+- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。
 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。
 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。
 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.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/bug-fix/2026-08-13-feedback-note-editor-popover.md
+2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800
+2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md

@@ -0,0 +1,43 @@
+# Agent Note: The feedback note editor floats above the transcript in a popover
+
+Status: implemented
+
+English | [中文](2026-08-13-feedback-note-editor-popover.zh.md)
+
+## Problem
+
+The Web surface for message feedback ([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262)) contributes its controls to `conversation.chat.assistant-actions`, which renders inside the finalized assistant message's shared IconActions row. That row was one fixed-height `flex` line with `flex-wrap` at its initial `nowrap` and `height: 28px`, sized for 28px icons and a clock. The note editor mounted into it as an inline group holding a `width: 260px` textarea plus Save and Cancel.
+
+A 260px input and two buttons do not fit that line at any window size. Measured against the shipped bundle, the row's scrollable overflow with the editor open was 168px at a 1680px viewport and 444px at 600px — the defect was never a narrow-window edge case, it was present at full-screen desktop. Flex overflow spills past the end of the line, so the items after the editor in flex order were the ones pushed out of the conversation column: the branch action left the column at 600px, and the clock and its run/TTFT/throughput readings left it at 900px. Those controls stay hit-testable while invisible, so no behavioral assertion noticed; the shipped e2e covered rate, note, reload, and retract, and the 24 UI snapshots are width-independent DOM.
+
+The same stylesheet also named four `--dsw-alias-*` tokens that the theme does not define: `border-secondary`, `bg-primary`, `interactive-bg-primary`, and `label-inverse`. An undefined custom property makes its whole declaration invalid at computed-value time, so the textarea shipped with no border and no surface, and Save with neither fill nor a readable label — the editor read as loose text floating in the transcript rather than as an input.
+
+## Decision
+
+The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out.
+
+**The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward.
+
+**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`).
+
+**Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected.
+
+## Alternatives considered
+
+**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not.
+
+**An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do.
+
+**A new `belowActions` seam on `MessageIconActions`, rendering the editor as a sibling under the row** — rejected: the slot contract documents `assistant-actions` as rendering *inside* the message's IconActions row, and one entry cannot supply two render sites without widening the host contract for a presentation detail that a portaled popover already expresses without touching the host.
+
+## Consequences
+
+With the editor open the actions row stays a single 28px line with zero overflow and nothing outside the column at every viewport from 1680px down to 600px — because the editor is not in the row to begin with. The panel floats above the transcript inside the viewport and stays anchored to its trigger, escaping the column's overflow clip. The editor is legible as an input in both themes.
+
+`apps/web/tests/message-feedback-layout.e2e.ts` sweeps six viewports with the editor open and pins, per stop, that the row reports one line and zero overflow, that the panel is outside the conversation column (proof it escapes the clip), that it lies within the viewport (proof the clamp holds), and that it sits by its trigger. A committed golden records the relations; reverting to inline (or dropping the portal) fails the geometry assertions. `packages/client/ui-message-feedback/tests/styles.client.spec.ts` checks the tokens against the theme's committed source, that the panel is `position: fixed`, and that it carries no flex sizing (so it cannot rejoin the row), plus the brace balance, following the `ui-settings-models` styles-spec precedent. The unit spec covers rate, note, reload, retract, plus the popover's portal-to-body, Escape/outside-click dismissal, and keep-open-on-inside-click.
+
+The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` usage typechecks, mirroring `ui-primitives`.
+
+Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub.
+
+A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change.

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md

@@ -0,0 +1,43 @@
+# Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方
+
+Status: implemented
+
+[English](2026-08-13-feedback-note-editor-popover.md) | 中文
+
+## Problem
+
+消息反馈的 Web 界面([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262))把控件贡献给 `conversation.chat.assistant-actions`,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 `flex` 线,`flex-wrap` 保持初始值 `nowrap` 且 `height: 28px`,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 `width: 260px` 的 textarea 加 Save 与 Cancel。
+
+一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。
+
+同一张样式表还引用了四个主题并未定义的 `--dsw-alias-*` token:`border-secondary`、`bg-primary`、`interactive-bg-primary` 与 `label-inverse`。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。
+
+## Decision
+
+备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。
+
+**操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。
+
+**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu`、`--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted`、`border-radius: 12px`)。
+
+**失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。
+
+## Alternatives considered
+
+**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。
+
+**不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。
+
+**在 `MessageIconActions` 上新增 `belowActions` 接缝,把编辑器作为该行的兄弟节点渲染在下方** — 否决:slot 契约明确记载 `assistant-actions` 渲染在消息 IconActions 行**内部**,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。
+
+## Consequences
+
+编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。
+
+`apps/web/tests/message-feedback-layout.e2e.ts` 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。`packages/client/ui-message-feedback/tests/styles.client.spec.ts` 校验 token 与主题已提交的源一致、面板为 `position: fixed`、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 `ui-settings-models` styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。
+
+`ui-message-feedback` 包新增 `@types/react-dom`,使 `createPortal` 用法能通过类型检查,与 `ui-primitives` 一致。
+
+有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。
+
+520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.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/bug-fix/2026-08-18-request-image-payload-bound.md
-2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1
-2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385
+2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1
+2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md

@@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the
 
 ## Decision
 
-The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route.
+The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route.
 
 ## Offload is conversion, not history
 
@@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit
 ## Related
 
 - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size).
+- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route.
 
 ## Consequences
 

+ 2 - 1
.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md

@@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型
 
 ## Decision
 
-pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。
+pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。
 
 ## offload 是转换而非历史
 
@@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE
 ## Related
 
 - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。
+- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。
 
 ## Consequences
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.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/bug-fix/2026-08-18-tool-row-file-open-failure.md
+2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43
+2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md

@@ -0,0 +1,33 @@
+# Agent Note: Tool-row file-open failures stay visible
+
+Status: implemented
+
+English | [中文](2026-08-18-tool-row-file-open-failure.zh.md)
+
+## Problem
+
+Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try.
+
+The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal.
+
+## Decision
+
+The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog.
+
+The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy.
+
+The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix.
+
+## Alternatives considered
+
+- **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target.
+- **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two.
+- **Chat-store remount persistence.** A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture.
+
+## Consequences
+
+A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe.
+
+## Testing
+
+Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: Tool-row file-open failures stay visible
+
+Status: implemented
+
+[English](2026-08-18-tool-row-file-open-failure.md) | 中文
+
+## 问题
+
+工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。
+
+[用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。
+
+## 决策
+
+inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。
+
+对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。
+
+Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。
+
+## 考虑过的替代方案
+
+- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。
+- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。
+- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。
+
+## 后果
+
+从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。
+
+## 测试
+
+包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-22-web-multimodal-image-input-and-durable-attachments.md
-2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9
-2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7
+2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5
+2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0

+ 4 - 4
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md

@@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments
 
 Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history.
 
-This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
+This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
 
 The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle.
 
@@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations
 
 The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast.
 
-The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
+Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`.
 
 Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
 
@@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc
 | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. |
 | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. |
 | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. |
-| `packages/llm/llm-deepseek` | Reject image content explicitly. |
+| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. |
 | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. |
 | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. |
 | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. |
@@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability
 
 ### Implementation
 
-The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
+The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
 
 No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
 

+ 4 - 4
.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。
 
-这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
+这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。
 
 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。
 
@@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都
 
 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。
 
-Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。
+Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。
 
 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
 
@@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 |
 | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
 | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
-| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
+| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 |
 | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 |
 | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 |
 | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 |
@@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
 
 ### 实现
 
-已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
+已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
 
 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-28-tool-call-file-open-in-os.md
-2026-07-28-tool-call-file-open-in-os.md: d78cf4da2c1861a66a0cefb24dba785c7cb1279d
-2026-07-28-tool-call-file-open-in-os.zh.md: c13f6e60c0c70a3036f50c678c7b348c91f874c3
+2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68
+2026-07-28-tool-call-file-open-in-os.zh.md: 59079533b502d234bafb0b53c123e5895e105833

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md

@@ -23,9 +23,9 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil
 
 ## Consequences
 
-Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`.
+Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)).
 
 ## Risks
 
-- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error.
+- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat view shows that Host error and offers retry.
 - Relative paths without a session cwd are forwarded verbatim and may fail on the host.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md

@@ -23,9 +23,9 @@ Status: implemented
 
 ## 后果
 
-点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。
+点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.md))。
 
 ## 风险
 
-- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。
+- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天视图展示该 Host 错误并提供重试。
 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.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-08-18-web-home-path-tilde.md
+2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977
+2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76

+ 37 - 0
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md

@@ -0,0 +1,37 @@
+# Agent Note: Web UI abbreviates POSIX home paths as `~`
+
+Status: implemented
+
+English | [中文](2026-08-18-web-home-path-tilde.zh.md)
+
+## Problem
+
+Workspace hover cards and Tool call summaries showed full POSIX home paths. Those strings are long, repeat the same prefix on every row, and make the sidebar and transcript harder to scan. Windows paths must stay verbatim because `~` is not a Windows filesystem convention.
+
+## Decision
+
+`host.describe` reports the host account `home` as a required field. Client and Host ship together, so the field is required rather than optional. ApiProxy fills it from `homedir()` at describe time.
+
+`abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path.
+
+`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. The field is required on `ConnectionHandle`; test fakes supply a source whose snapshot may be undefined before connect.
+
+The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged.
+
+## Alternatives considered
+
+**Guess `/Users` or `/home` without the real home.** Rejected because a shared prefix is not an account home, and `/Users/shared` or `/home/src` would abbreviate incorrectly.
+
+**Abbreviate Windows `%USERPROFILE%` as `~` as well.** Rejected because the acceptance rule keeps Windows paths verbatim, and `~` is not how Explorer or `cmd` spell those paths.
+
+**Put the helper in `dsh-home-paths`.** Rejected because that package expands configuration tildes on Node; this helper is a browser display rewrite and must not pull Node `os` into client bundles.
+
+**Thread `home` from ChatView owner props.** Rejected because it enlarges the conversation inject face and every ChatView test harness for a display fact only Tool and Workspace cards consume.
+
+## Consequences
+
+POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Before the first describe, or while reconnecting, the source snapshot is undefined and paths stay unabbreviated.
+
+## Testing
+
+Package tests cover `abbreviateHomePath`, `toolRowModel` / `readCardModel` home abbreviation, Workspace hover display versus copy, and `host.describe` schema plus live `homedir()`. Assembled replay `apps/web/tests/home-path-tilde.snapshot.ts` hovers the fixture home-descendant Workspace. Product-GUI PRs still record a real-browser GIF of the hover card.

+ 37 - 0
.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: Web UI abbreviates POSIX home paths as `~`
+
+Status: implemented
+
+[English](2026-08-18-web-home-path-tilde.md) | 中文
+
+## Problem
+
+Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路径。这些字符串很长,每行重复同一前缀,侧边栏和对话记录更难扫读。Windows 路径必须保持原样,因为 `~` 不是 Windows 文件系统约定。
+
+## Decision
+
+`host.describe` 把宿主账户的 `home` 作为必填字段上报。Client 与 Host 一同发布,因此该字段是必填而不是可选。ApiProxy 在 describe 时用 `homedir()` 填入。
+
+`dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。
+
+`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。该字段在 `ConnectionHandle` 上是必填的;测试假对象提供一个来源,其快照在连接完成前可以为 undefined。
+
+fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。
+
+## Alternatives considered
+
+**在没有真实 home 的情况下猜测 `/Users` 或 `/home`。** 否决,因为共享前缀不是账户家目录,`/Users/shared` 或 `/home/src` 会被错误缩写。
+
+**同样把 Windows `%USERPROFILE%` 缩写成 `~`。** 否决,因为验收规则要求 Windows 路径保持原样,而且 Explorer 与 `cmd` 并不这样拼写这些路径。
+
+**把辅助函数放进 `dsh-home-paths`。** 否决,因为该包在 Node 上展开配置里的波浪号;本辅助函数是浏览器展示改写,不能把 Node `os` 拉进 client 包。
+
+**从 ChatView owner props 向下传递 `home`。** 否决,因为它会扩大 conversation 注入面和每一份 ChatView 测试夹具,而只有 Tool 与 Workspace 卡片消费这个展示事实。
+
+## Consequences
+
+POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。首次 describe 之前或重连期间,来源快照为 undefined,路径保持未缩写。
+
+## Testing
+
+包测试覆盖 `abbreviateHomePath`、`toolRowModel`/`readCardModel` 的家目录缩写、Workspace 悬停展示与复制,以及 `host.describe` schema 与实时 `homedir()`。组装回放 `apps/web/tests/home-path-tilde.snapshot.ts` 悬停 fixture 中位于家目录下的 Workspace。面向产品 GUI 的 PR 仍需录制悬停卡片的真实浏览器 GIF。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.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-08-19-direct-deepseek-vision-input.md
+2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04
+2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc

+ 34 - 0
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md

@@ -0,0 +1,34 @@
+# Agent Note: Direct DeepSeek vision input
+
+Status: implemented
+
+English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md)
+
+## Problem
+
+DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider.
+
+## Decision
+
+The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently.
+
+The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O.
+
+The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`.
+
+Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported.
+
+## Alternatives considered
+
+- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id.
+- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata.
+- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order.
+- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns.
+
+## Verification
+
+Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder.
+
+## Consequences
+
+Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available.

+ 34 - 0
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md

@@ -0,0 +1,34 @@
+# Agent Note: 直接 DeepSeek 视觉输入
+
+Status: implemented
+
+[English](2026-08-19-direct-deepseek-vision-input.md) | 中文
+
+## Problem
+
+DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。
+
+## Decision
+
+直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。
+
+适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。
+
+直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。
+
+规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。
+
+## Alternatives considered
+
+- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。
+- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。
+- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。
+- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。
+
+## Verification
+
+包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。
+
+## Consequences
+
+已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。

+ 45 - 0
apps/web/tests/home-path-tilde.snapshot.ts

@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+// Assembled POSIX home-path display: the fixture Host home is `/home/fixture`
+// and a second Workspace lives under it. The sidebar hover card must show
+// `~/Documents/project` while copy still writes the full path.
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts'
+
+const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt')
+
+installAssembledBootEnv()
+
+describe('assembled POSIX home-path display', () => {
+  it('shows the home-descendant Workspace path as ~ and copies the full path', async () => {
+    mountAssembledApp()
+
+    const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+    const group = (await within(tree).findAllByText('project'))
+      .map(el => el.closest<HTMLElement>('[role="treeitem"]'))
+      .find(el => el?.getAttribute('aria-expanded') !== null)
+    if (group == null) throw new Error('home-descendant Workspace group missing')
+
+    fireEvent.pointerEnter(group.parentElement as HTMLElement)
+    const hoverPath = await waitFor(() => {
+      const found = screen.getByText('~/Documents/project')
+      expect(found).toBeTruthy()
+      return found
+    }, { timeout: 2_000 })
+    expect(screen.queryByText('/home/fixture/Documents/project')).toBeNull()
+    const copy = screen.getByRole('button', { name: 'Copy: /home/fixture/Documents/project' })
+
+    const shape = [
+      `hover=${hoverPath.textContent}`,
+      `copy=${copy.getAttribute('aria-label')}`,
+    ].join('\n') + '\n'
+    if (REFRESHING_GOLDEN) {
+      mkdirSync(dirname(EXPECTED), { recursive: true })
+      writeFileSync(EXPECTED, shape)
+    }
+    await expect(shape).toMatchFileSnapshot(EXPECTED)
+    act(() => { fireEvent.pointerLeave(group.parentElement as HTMLElement) })
+  })
+})

+ 336 - 0
apps/web/tests/message-feedback-layout.e2e.ts

@@ -0,0 +1,336 @@
+// Web e2e scenario: with the feedback note editor open, the assistant IconActions
+// row stays one intact line (no wrapping, nothing pushed out), and the note
+// editor floats above the transcript in a popover that escapes the conversation
+// column's overflow clip and stays inside the viewport.
+//
+// The hazard this pins: a slot-contributed note editor (260px textarea plus
+// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an
+// inline expansion made the row wider than the column — full-screen desktop
+// included — so the branch action and the clock were pushed out of view by later
+// flex items. The fix is to not mount the editor in the row at all: it is a
+// popover portaled to document.body and fixed-positioned from the note trigger's
+// rect, so the row keeps its single 28px line of icons and the trigger, and the
+// panel cannot be cropped by the column's overflow because it lives outside it.
+//
+// The sweep records, per viewport, whether the open editor keeps the actions row
+// on one line with zero overflow, whether the panel is outside the column (proof
+// it escapes the clip), whether the panel stays inside the viewport (proof the
+// clamp works), and whether it sits by its trigger. All relations, no absolute
+// pixels: the column width follows the viewport, the sidebar, and the platform's
+// scrollbar, so a golden carrying pixels would document the platform, not the
+// behavior.
+//
+// Zero model calls: a settled transcript is cold-seeded, so nothing streams.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-layout', import.meta.url))
+/**
+ * Committed golden of the popover relations at every stop. Booleans and counts
+ * only, never absolute coordinates.
+ */
+const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
+const MODE = webSnapshotMode()
+/** Borrowed read-only: this scenario needs any settled assistant message to rate. */
+const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
+const SEED_ID = 'message-feedback-layout-e2e'
+/** Viewport widths from full-screen desktop down to a narrow window. */
+const WIDTHS = [1680, 1280, 1024, 900, 700, 600]
+
+/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */
+export interface PopoverMetrics {
+  /** Viewport width the stop was measured at. */
+  width: number
+  /** The row's scrollable overflow with the note editor closed (natural row width). */
+  rowOverflowClosed: number
+  /** The row's scrollable overflow with the note editor open; must equal the closed value. */
+  rowOverflowOpen: number
+  /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */
+  rowLines: number
+  /** Row items whose right edge escapes the column, editor closed. */
+  itemsOutsideColumnClosed: number
+  /** Row items whose right edge escapes the column, editor open; must equal the closed value. */
+  itemsOutsideColumnOpen: number
+  /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */
+  panelOutsideColumn: boolean
+  /** True when the panel lies fully inside the viewport (the clamp holds). */
+  panelWithinViewport: boolean
+  /** Horizontal separation between the panel's left edge and the note trigger's, in px. */
+  panelToTriggerGap: number
+}
+
+/**
+ * Measure the feedback row (and the open popover, when present) at the current
+ * viewport. The same reader serves the closed and open readings so the two
+ * sides differ only by whether the editor is open.
+ * @param page - the page under test.
+ * @param width - the viewport width already applied, recorded with the reading.
+ * @param editorOpen - true to also read the popover's relations; throws if it is absent.
+ * @returns the stop's relations.
+ */
+function measurePopover(page: Page, width: number, editorOpen: boolean): Promise<PopoverMetrics> {
+  return page.evaluate(({ viewportWidth, open }) => {
+    const rated = document.querySelector<HTMLElement>('button[aria-label="Remove rating"]')
+    if (rated === null) throw new Error('no rated feedback control in the DOM')
+    const row = rated.parentElement?.closest<HTMLElement>('div[class*="actions"]') ?? null
+    if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control')
+    const trigger = row.querySelector<HTMLElement>('button[aria-haspopup="dialog"]')
+    if (trigger === null) throw new Error('the note trigger is not in the row')
+
+    /**
+     * The real flex items of the row. A slot contributor (the feedback strip)
+     * arrives as a `display: contents` wrapper (the `assistant-actions` slot
+     * renders inside a transparent `data-slot` div), which reports an all-zero
+     * rect; a zero box would be miscounted as a phantom flex line. The actual
+     * items are the boxes inside it.
+     * @param element - the row whose items to read.
+     * @returns the real flex-item boxes, in flex/DOM order.
+     */
+    const flexItemBoxes = (element: HTMLElement): DOMRect[] => {
+      const boxes: DOMRect[] = []
+      for (const child of Array.from(element.children)) {
+        const el = child as HTMLElement
+        const rect = el.getBoundingClientRect()
+        if (el.style.display === 'contents') {
+          boxes.push(...flexItemBoxes(el))
+        } else if (rect.height > 0 && rect.width > 0) {
+          boxes.push(rect)
+        }
+      }
+      return boxes
+    }
+    /**
+     * Group items into flex lines by overlapping vertical extent.
+     * @param boxes - the row items' boxes, in DOM order.
+     * @returns the number of distinct lines.
+     */
+    const countFlexLines = (boxes: DOMRect[]): number => {
+      const centres: number[] = []
+      for (const box of boxes) {
+        const centre = box.top + box.height / 2
+        if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre)
+      }
+      return centres.length
+    }
+
+    const column = row.closest<HTMLElement>('[data-conversation-scroll]')
+    const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0)
+    const itemRects = flexItemBoxes(row)
+    // A half-pixel tolerance: subpixel layout puts a contained edge a fraction
+    // over the boundary on some device scale factors.
+    const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length
+    // The editor is a portal, so the row measures identically whether the
+    // editor is open or not; the closed/open fields differ by call so the sweep
+    // can assert a zero delta on them.
+    const overflow = row.scrollWidth - row.clientWidth
+
+    let builder: {
+      panelOutsideColumn: boolean
+      panelWithinViewport: boolean
+      panelToTriggerGap: number
+    }
+    if (!open) {
+      builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 }
+    } else {
+      const panel = document.body.querySelector<HTMLElement>('[role="dialog"]')
+      if (panel === null) throw new Error('the note popover is not open')
+      const panelBox = panel.getBoundingClientRect()
+      const triggerBox = trigger.getBoundingClientRect()
+      const vw = window.innerWidth
+      const vh = window.innerHeight
+      builder = {
+        // The panel portals out of the column, so the clip cannot reach it.
+        panelOutsideColumn: column === null ? true : !column.contains(panel),
+        panelWithinViewport:
+          panelBox.left >= -0.5
+          && panelBox.right <= vw + 0.5
+          && panelBox.top >= -0.5
+          && panelBox.bottom <= vh + 0.5,
+        // The panel is fixed from the trigger's left, so a zero gap says it is
+        // anchored; a clamp can only widen it.
+        panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left),
+      }
+    }
+
+    return {
+      width: viewportWidth,
+      rowOverflowClosed: overflow,
+      rowOverflowOpen: overflow,
+      rowLines: countFlexLines(itemRects),
+      itemsOutsideColumnClosed: itemsOutsideColumn,
+      itemsOutsideColumnOpen: itemsOutsideColumn,
+      ...builder,
+    }
+  }, { viewportWidth: width, open: editorOpen })
+}
+
+/**
+ * Render the golden body: one line per stop, relations and counts only. The
+ * row-overflow and outside-column readings are deltas (open minus closed) so
+ * the golden records that opening the editor leaves the row untouched, not an
+ * absolute count that many unrelated controls could move.
+ * @param stops - the measured stops, in sweep order.
+ * @returns the golden body, without a trailing newline.
+ */
+function renderGeometry(stops: PopoverMetrics[]): string {
+  return [
+    '# Assistant actions row with the feedback note popover open',
+    '',
+    '| viewport | row overflow delta | row lines | items-outside delta '
+      + '| panel outside the column | panel within the viewport | panel-to-trigger gap |',
+    '| --- | --- | --- | --- | --- | --- | --- |',
+    ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px `
+      + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} `
+      + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} `
+      + `| ${String(stop.panelToTriggerGap)}px |`),
+  ].join('\n')
+}
+
+describe('web e2e: the feedback note editor floats above the column', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser, 900)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 180_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  /**
+   * Open the seeded transcript. The first treeitem is the collapsible group
+   * row; the session itself is the row beneath it.
+   * @returns nothing.
+   */
+  async function openSeededSession(): Promise<void> {
+    const groupRow = page.locator('[role="treeitem"]').first()
+    await groupRow.waitFor({ timeout: 15_000 })
+    if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
+    const sessionRow = page.locator('[role="treeitem"]').nth(1)
+    await sessionRow.waitFor({ timeout: 15_000 })
+    await sessionRow.click()
+  }
+
+  /**
+   * Resize to a viewport and read the row once its width stops moving. The
+   * frame eases its column tracks, so reading straight after a resize can
+   * report the previous viewport's relation.
+   * @param width - viewport width to settle at.
+   * @param editorOpen - whether the note editor is currently open; reads the popover relations when so.
+   * @returns the row's (and popover's) readings at that width.
+   */
+  const settleAt = async (width: number, editorOpen: boolean): Promise<PopoverMetrics> => {
+    await page.setViewportSize({ width, height: 900 })
+    let previous = -1
+    await expect.poll(async () => {
+      const current = await page.evaluate(() =>
+        document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1)
+      const settled = current === previous
+      previous = current
+      return settled
+    }, { timeout: 10_000 }).toBe(true)
+    // The popover is JS-positioned from the trigger rect and re-places on
+    // resize/scroll, so once the column width stops moving we nudge it to the
+    // final layout; otherwise the panel can sit at a transient position from
+    // mid-resize and the anchor reading would be off.
+    await page.evaluate(() => window.dispatchEvent(new Event('resize')))
+    return measurePopover(page, width, editorOpen)
+  }
+
+  /**
+   * Rate a message, then for every stop read the row once with the note editor
+   * closed and once with it open, handing the SAME measured readings to both
+   * assertions so the golden and the assertions describe one measurement
+   * rather than two runs that could disagree.
+   * @returns the stops in {@link WIDTHS} order.
+   */
+  let swept: Promise<PopoverMetrics[]> | undefined
+  const sweep = (): Promise<PopoverMetrics[]> => {
+    swept ??= (async () => {
+      await openSeededSession()
+      await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
+      // The controller defers its list read to the first hover or focus, so the
+      // strip has to be touched before it can be rated.
+      const like = page.getByRole('button', { name: 'Good response' }).first()
+      await like.waitFor({ timeout: 30_000 })
+      await like.scrollIntoViewIfNeeded()
+      await like.hover()
+      await like.click()
+      await page.getByRole('button', { name: 'Remove rating' }).first()
+        .waitFor({ timeout: 15_000 })
+      const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first()
+      const stops: PopoverMetrics[] = []
+      for (const width of WIDTHS) {
+        // Reset to the closed baseline at each stop before opening.
+        if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click()
+        const closed = await settleAt(width, false)
+        await page.getByRole('button', { name: 'Add a note' }).first().click()
+        await page.getByRole('dialog').waitFor({ timeout: 10_000 })
+        const open = await settleAt(width, true)
+        stops.push({
+          width,
+          rowOverflowClosed: closed.rowOverflowClosed,
+          rowOverflowOpen: open.rowOverflowOpen,
+          rowLines: open.rowLines,
+          itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed,
+          itemsOutsideColumnOpen: open.itemsOutsideColumnOpen,
+          panelOutsideColumn: open.panelOutsideColumn,
+          panelWithinViewport: open.panelWithinViewport,
+          panelToTriggerGap: open.panelToTriggerGap,
+        })
+      }
+      return stops
+    })()
+    return swept
+  }
+
+  it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout'))
+    const stops = await sweep()
+    for (const stop of stops) {
+      // The popover lives outside the row, so opening it must not change the
+      // row at all. This is the vacuity guard of the whole redesign: an inline
+      // editor would widen or reflow the row, pushing the delta off zero.
+      expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0)
+      expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0)
+      // The row is one 28px line; the editor never forces a reflow.
+      expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1)
+      // The panel escapes the column's overflow clip by living outside it.
+      expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true)
+      // The placement clamps the panel inside the viewport at every width.
+      expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true)
+      // The panel stays anchored to its trigger rather than drifting off.
+      expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4)
+    }
+    expect(tripwire.pageErrors).toEqual([])
+  }, 180_000)
+
+  it('matches the committed geometry golden', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden'))
+    await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
+  }, 180_000)
+
+  it('kept the console clean', () => {
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+})

+ 12 - 3
apps/web/tests/navigation-panes.e2e.ts

@@ -12,7 +12,7 @@ import { join } from 'node:path'
 import type { Browser, Page, Response } from 'playwright'
 import { chromium } from 'playwright'
 import { strFromU8, unzipSync } from 'fflate'
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
 import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
@@ -415,8 +415,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     // Read summaries are host-open file links; they also must not open details.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await fileLink.waitFor({ timeout: 10_000 })
-    await fileLink.click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+    const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath')
+      .mockImplementation(async (request, _signal) => ({
+        rpcId: request.rpcId,
+        result: { ok: true, value: { opened: true as const } },
+      }))
+    try {
+      await fileLink.click()
+      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+    } finally {
+      openPath.mockRestore()
+    }
   }, 60_000)
 
   it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {

+ 1 - 1
apps/web/tests/onboarding-deepseek-config.e2e.ts

@@ -224,7 +224,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
     expect(document).toContain('name: Private Preview')
     expect(document).toContain('contextWindow: 131072')
     expect(document).toContain('maxTokens: 64000')
-    expect(document).not.toContain('id: deepseek-v4-flash')
+    expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m)
 
     await page.keyboard.press('Escape')
     // A connected Workspace is what puts a live composer — and its model

+ 50 - 4
apps/web/tests/seeded-history.e2e.ts

@@ -14,7 +14,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
 import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
@@ -33,6 +33,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expecte
 // Command-row goldens over the same conversation after direct host commands.
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url))
+const FILE_OPEN_FAILURE_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/file-open-failure.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'seeded-history-web-e2e'
 
@@ -396,12 +397,57 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await fileLink.waitFor({ timeout: 10_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
-    await fileLink.click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+    const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath')
+      .mockImplementation(async (request, _signal) => ({
+        rpcId: request.rpcId,
+        result: { ok: true, value: { opened: true as const } },
+      }))
+    try {
+      await fileLink.click()
+      await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
+    } finally {
+      openPath.mockRestore()
+    }
     // Path label survives from the recorded args (a.txt).
     await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
   })
 
+  it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure'))
+    const fileLink = page.locator('[data-variant="read"] button').first()
+    await fileLink.waitFor({ timeout: 10_000 })
+    const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath')
+      .mockImplementation(async (request, _signal) => ({
+        rpcId: request.rpcId,
+        result: {
+          ok: false as const,
+          error: { code: 'internal', message: 'xdg-open is not available', details: {} },
+        },
+      }))
+    try {
+      await fileLink.click()
+      const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' })
+      await dialog.waitFor({ timeout: 5_000 })
+      const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE)
+      await expect.poll(() => dialog.innerText(), { timeout: 5_000 })
+        .toContain('path open failed: xdg-open is not available')
+      await page.getByRole('button', { name: 'Retry' }).click()
+      await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2)
+      expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload)
+      await page.getByRole('button', { name: 'Cancel' }).click()
+      await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), {
+        timeout: 5_000,
+      }).toBe(0)
+    } finally {
+      // Shared page: a leftover mask blocks later cases even when this one fails.
+      if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) {
+        await page.keyboard.press('Escape')
+      }
+      openPath.mockRestore()
+    }
+  })
+
   it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
     const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
@@ -505,6 +551,6 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // stream would have failed the turn loudly. Cleanliness pins the wire.
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'seed.jsonl', 'ui.expected.md'])
   })
 })

+ 2 - 0
apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt

@@ -0,0 +1,2 @@
+hover=~/Documents/project
+copy=Copy: /home/fixture/Documents/project

+ 10 - 0
apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md

@@ -0,0 +1,10 @@
+# Assistant actions row with the feedback note popover open
+
+| viewport | row overflow delta | row lines | items-outside delta | panel outside the column | panel within the viewport | panel-to-trigger gap |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1680px | 0px | 1 | 0 | true | true | 0px |
+| 1280px | 0px | 1 | 0 | true | true | 0px |
+| 1024px | 0px | 1 | 0 | true | true | 0px |
+| 900px | 0px | 1 | 0 | true | true | 0px |
+| 700px | 0px | 1 | 0 | true | true | 0px |
+| 600px | 0px | 1 | 0 | true | true | 0px |

+ 7 - 0
apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md

@@ -0,0 +1,7 @@
+- dialog "Couldn’t open file":
+  - heading "Couldn’t open file" [level=2]
+  - button "Close":
+    - img
+  - paragraph: "path open failed: xdg-open is not available"
+  - button "Cancel"
+  - button "Retry"

+ 1 - 0
apps/web/tsconfig.json

@@ -60,6 +60,7 @@
     "tests/web-search-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/message-feedback.e2e.ts",
+    "tests/message-feedback-layout.e2e.ts",
     "tests/markdown-images.e2e.ts",
     "tests/math-rendering.e2e.ts",
     "tests/markdown-cjk-strong.e2e.ts",

+ 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: f96c369f7129839f10ade05939b0822bf6850e70
-config-catalog.zh.md: b20056d18cf53b415db21daecb957dae975a53cc
+config-catalog.md: 659fe4a89bd36a6b1e7446be07e3e72022fa5d0f
+config-catalog.zh.md: 8b180c96d7d22f13253bc28c580343ccec051a1c

+ 6 - 2
docs/config-catalog.md

@@ -870,6 +870,8 @@ export interface Config {
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
+  /** Maximum accumulated base64 image payload per request (default 20 MiB). */
+  maxRequestImageBytes?: number
   /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
   retryPolicy?: RetryPolicyConfig
 }
@@ -886,12 +888,14 @@ export interface DeepSeekCatalogModel {
   contextWindow?: number
   /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
   maxTokens?: number
+  /** Accepted request modalities; omission is text-only. */
+  inputModalities?: ModelModality[]
 }
 ```
 
-Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
+Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 6 - 2
docs/config-catalog.zh.md

@@ -872,6 +872,8 @@ export interface Config {
   models?: DeepSeekCatalogModel[]
   /** Maximum provider idle time while one stream read is outstanding (default five minutes). */
   streamIdleTimeoutMs?: number
+  /** Maximum accumulated base64 image payload per request (default 20 MiB). */
+  maxRequestImageBytes?: number
   /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */
   retryPolicy?: RetryPolicyConfig
 }
@@ -888,12 +890,14 @@ export interface DeepSeekCatalogModel {
   contextWindow?: number
   /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
   maxTokens?: number
+  /** Accepted request modalities; omission is text-only. */
+  inputModalities?: ModelModality[]
 }
 ```
 
-依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
+依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts)
+来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc
-module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200
+module-graph.md: 38d3acc73175d504dadf22e4a5317e5f84ff5be8
+module-graph.zh.md: 76880c34aef07f877147a90f62dacd2b6877de41

+ 6 - 3
docs/module-graph.md

@@ -407,6 +407,7 @@ flowchart TD
   pkg_settings_file --> pkg_invariants
   pkg_settings_file --> pkg_settings
   pkg_llm_deepseek --> pkg_anonymous_user_id
+  pkg_llm_deepseek --> pkg_attachment
   pkg_llm_deepseek --> pkg_credentials
   pkg_llm_deepseek --> pkg_invariants
   pkg_llm_deepseek --> pkg_launch_environment
@@ -1332,6 +1333,7 @@ flowchart TD
   pkg_client_ui_subagent --> pkg_subagent
   pkg_client_ui_subagent --> pkg_token_meter
   pkg_client_ui_tool --> pkg_api_remotes
+  pkg_client_ui_tool --> pkg_client_connection
   pkg_client_ui_tool --> pkg_client_locale
   pkg_client_ui_tool --> pkg_client_runtime
   pkg_client_ui_tool --> pkg_client_ui_conversation
@@ -1355,6 +1357,7 @@ flowchart TD
   pkg_client_ui_workflow_run --> pkg_session
   pkg_client_ui_workflow_run --> pkg_tool_workflow
   pkg_client_ui_workflow_run --> pkg_workflow
+  pkg_client_ui_workspace --> pkg_client_connection
   pkg_client_ui_workspace --> pkg_client_locale
   pkg_client_ui_workspace --> pkg_client_runtime
   pkg_client_ui_workspace --> pkg_client_ui_conversation
@@ -1463,7 +1466,7 @@ flowchart TD
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
@@ -1624,11 +1627,11 @@ flowchart TD
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
-| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
-| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 6 - 3
docs/module-graph.zh.md

@@ -409,6 +409,7 @@ flowchart TD
   pkg_settings_file --> pkg_invariants
   pkg_settings_file --> pkg_settings
   pkg_llm_deepseek --> pkg_anonymous_user_id
+  pkg_llm_deepseek --> pkg_attachment
   pkg_llm_deepseek --> pkg_credentials
   pkg_llm_deepseek --> pkg_invariants
   pkg_llm_deepseek --> pkg_launch_environment
@@ -1334,6 +1335,7 @@ flowchart TD
   pkg_client_ui_subagent --> pkg_subagent
   pkg_client_ui_subagent --> pkg_token_meter
   pkg_client_ui_tool --> pkg_api_remotes
+  pkg_client_ui_tool --> pkg_client_connection
   pkg_client_ui_tool --> pkg_client_locale
   pkg_client_ui_tool --> pkg_client_runtime
   pkg_client_ui_tool --> pkg_client_ui_conversation
@@ -1357,6 +1359,7 @@ flowchart TD
   pkg_client_ui_workflow_run --> pkg_session
   pkg_client_ui_workflow_run --> pkg_tool_workflow
   pkg_client_ui_workflow_run --> pkg_workflow
+  pkg_client_ui_workspace --> pkg_client_connection
   pkg_client_ui_workspace --> pkg_client_locale
   pkg_client_ui_workspace --> pkg_client_runtime
   pkg_client_ui_workspace --> pkg_client_ui_conversation
@@ -1465,7 +1468,7 @@ flowchart TD
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
@@ -1626,11 +1629,11 @@ flowchart TD
 | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) |
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
-| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
-| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 4 - 2
examples/acp-agent/code-mode-image.cordis.snapshot.yml

@@ -13,7 +13,7 @@
         name: '@deepseek-ai/dsh-acp-demo'
         config:
           provider: deepseek-official
-          model: deepseek-v4-flash
+          model: deepseek-v4-flash-vision-exp
           persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
           persistenceCompression: none
           workspaceContext:
@@ -37,6 +37,8 @@
                   name: DeepSeek
                   models:
                     - id: deepseek-v4-flash
-                      inputModalities: [text, image]
+                      inputModalities: [text]
                     - id: deepseek-v4-pro
                       inputModalities: [text]
+                    - id: deepseek-v4-flash-vision-exp
+                      inputModalities: [text, image]

+ 7 - 5
examples/acp-agent/image.cordis.snapshot.yml

@@ -1,9 +1,9 @@
 # Keyless replay for the read-image success scenario. Include patches cannot
 # target entries behind a nested include, so this restates the replay overlay
 # directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and
-# re-pins the recorded flash model. The replay catalog declares image input on
-# flash, so the strict read_image gate accepts the route and the tool result
-# carries the durable image block; the live DeepSeek route cannot record this.
+# re-pins the recorded vision model. The replay catalog declares image input,
+# so the strict read_image gate accepts the route and the tool result carries
+# the durable image block.
 - id: base
   name: '@deepseek-ai/cordis-plugin-include'
   config:
@@ -16,7 +16,7 @@
         name: '@deepseek-ai/dsh-acp-demo'
         config:
           provider: deepseek-official
-          model: deepseek-v4-flash
+          model: deepseek-v4-flash-vision-exp
           persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
           persistenceCompression: none
           workspaceContext:
@@ -36,6 +36,8 @@
                   name: DeepSeek
                   models:
                     - id: deepseek-v4-flash
-                      inputModalities: [text, image]
+                      inputModalities: [text]
                     - id: deepseek-v4-pro
                       inputModalities: [text]
+                    - id: deepseek-v4-flash-vision-exp
+                      inputModalities: [text, image]

+ 2 - 1
examples/acp-agent/image.cordis.yml

@@ -3,7 +3,8 @@
 # snapshot harness scopes per run, so the overlay itself carries no paths. The
 # app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the
 # authored fixture and the pinned header class are flash), because a config
-# patch replaces the whole app config.
+# patch replaces the whole app config. Keyless replay supplies its own exact
+# image-capable catalog while the production endpoint remains unavailable.
 - id: base
   name: '@deepseek-ai/cordis-plugin-include'
   config:

+ 80 - 20
examples/acp-agent/tests/acp.snapshot.ts

@@ -16,6 +16,7 @@ import {
 } from '@deepseek-ai/dsh-acp-snapshot'
 import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
 import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
+import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm'
 
 /**
  * The acp-agent example's snapshot suite: the scenario table for
@@ -222,27 +223,26 @@ const SCENARIOS: Scenario[] = [
     posixOnly: true,
   },
   // Authored keyless replays through the assembled app: the replay catalog
-  // declares flash image-capable (success) or text-only (refusal), and the
+  // declares the vision model image-capable and Flash text-only, and the
   // real read_image tool executes against the workspace fixture and the real
-  // attachment store. Both boot the same composed header (the tool registers
-  // with the attachment store, independent of route), so they share one class.
+  // attachment store. The success route selects the vision model while the
+  // refusal route retains text-only Flash, so each pins its exact header.
   {
     name: 'read-image',
     hasModelTurn: true,
     recorded: false,
     pinsHeader: true,
     headerClass: 'image',
-    // The overlay adds no prompt section (read_image carries no guidance), so
-    // the composed system prompt is byte-identical to the default class; only
-    // the tool-schema sidecar is class-specific.
-    systemPromptSource: 'text-turn',
     configPath: IMAGE_CONFIG,
   },
   {
     name: 'read-image-text-route',
     hasModelTurn: true,
     recorded: false,
-    headerClass: 'image',
+    pinsHeader: true,
+    headerClass: 'image-text-route',
+    systemPromptSource: 'text-turn',
+    toolSchemasSource: 'read-image',
     configPath: IMAGE_TEXT_ROUTE_CONFIG,
   },
   // Authored keyless replay of the oversized-image refusal: admission rejects
@@ -688,7 +688,7 @@ defineAcpSnapshotSuite({
   hasPwsh,
 })
 
-it('pins pi-ai image offload in the request sent by the assembled app', async () => {
+it('pins native DeepSeek image offload in the request sent by the assembled app', async () => {
   const requests: Record<string, unknown>[] = []
   const server = createServer((request: IncomingMessage, response: ServerResponse) => {
     let body = ''
@@ -697,13 +697,21 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
     request.on('end', () => {
       requests.push(JSON.parse(body) as Record<string, unknown>)
       response.writeHead(200, { 'content-type': 'text/event-stream' })
-      response.end([
-        'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
-        'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
-        'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
-        'data: [DONE]',
-        '',
-      ].join('\n\n'))
+      const events = requests.length === 1
+        ? [
+          'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}',
+          'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+          'data: [DONE]',
+          '',
+        ]
+        : [
+          'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
+          'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}',
+          'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+          'data: [DONE]',
+          '',
+        ]
+      response.end(events.join('\n\n'))
     })
   })
   await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
@@ -722,7 +730,7 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
           { type: 'image', data: image, mimeType: 'image/png' },
           { type: 'text', text: ' with the newer image ' },
           { type: 'image', data: image, mimeType: 'image/png' },
-          { type: 'text', text: ', then reply with DONE.' },
+          { type: 'text', text: ', then use read_image on red.png and reply with DONE.' },
         ],
       },
     ],
@@ -734,13 +742,14 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
       mode: 'record',
       configPath: IMAGE_OFFLOAD_CONFIG,
       fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'),
+      workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'),
       env: {
         DSH_SNAPSHOT_API_KEY: 'snapshot-key',
-        DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`,
+        DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`,
       },
     })
     expect(result.stderr).toBe('')
-    expect(requests).toHaveLength(1)
+    expect(requests).toHaveLength(2)
     const messages = requests[0]?.messages as { content?: unknown }[] | undefined
     const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted'))
     expect(offloaded?.content).toMatchInlineSnapshot(`
@@ -764,11 +773,62 @@ it('pins pi-ai image offload in the request sent by the assembled app', async ()
           "type": "image_url",
         },
         {
-          "text": ", then reply with DONE.",
+          "text": ", then use read_image on red.png and reply with DONE.",
           "type": "text",
         },
       ]
     `)
+
+    const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{
+      role?: unknown
+      content?: unknown
+    }>
+    const toolMessage = followup.find(message => message.role === 'tool')
+    if (toolMessage === undefined || typeof toolMessage.content !== 'string') {
+      throw new Error('native read_image request has no tool content')
+    }
+    const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => (
+      cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`]
+    )))]
+    let toolContent = toolMessage.content
+    for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}')
+    toolMessage.content = toolContent
+    expect(followup).toEqual([
+      {
+        role: 'user',
+        content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`,
+      },
+      {
+        role: 'user',
+        content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n'
+          + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n'
+          + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).',
+      },
+      {
+        role: 'assistant',
+        content: '',
+        tool_calls: [{
+          id: 'native-read-image',
+          type: 'function',
+          function: { name: 'read_image', arguments: '{"file_path":"red.png"}' },
+        }],
+      },
+      {
+        role: 'tool',
+        tool_call_id: 'native-read-image',
+        content: '<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>',
+      },
+      {
+        role: 'user',
+        content: [
+          { type: 'text', text: 'Attached image(s) from tool result:' },
+          {
+            type: 'image_url',
+            image_url: { url: `data:image/png;base64,${image}` },
+          },
+        ],
+      },
+    ])
   } finally {
     await new Promise<void>(resolve => server.close(() => { resolve() }))
   }

+ 14 - 19
examples/acp-agent/tests/fixtures/image-offload.cordis.yml

@@ -1,5 +1,5 @@
-# Keyless assembled-request snapshot for pi-ai image offload. The local
-# provider endpoint is supplied by the snapshot test; the real attachment
+# Keyless assembled-request snapshot for native DeepSeek image offload. The
+# local provider endpoint is supplied by the snapshot test; the real attachment
 # store and ACP bridge carry two uploaded images into one model request.
 - id: base
   name: '@deepseek-ai/cordis-plugin-include'
@@ -8,12 +8,21 @@
     patches:
       - id: llm-deepseek
         name: '@deepseek-ai/dsh-llm-deepseek'
-        disabled: true
+        config:
+          apiKeyEnv: DSH_SNAPSHOT_API_KEY
+          baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
+          thinking: disabled
+          maxRequestImageBytes: 92
+          models:
+            - id: deepseek-v4-flash-vision-exp
+              contextWindow: 32768
+              maxTokens: 1024
+              inputModalities: [text, image]
       - id: acp-agent
         name: '@deepseek-ai/dsh-acp-demo'
         config:
-          provider: snapshot
-          model: vision
+          provider: deepseek-official
+          model: deepseek-v4-flash-vision-exp
           persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
           persistenceCompression: none
           workspaceContext:
@@ -25,17 +34,3 @@
       - insert:
           - id: attachment-local
             name: '@deepseek-ai/dsh-attachment-local'
-          - id: llm-pi-ai
-            name: '@deepseek-ai/dsh-llm-pi-ai'
-            config:
-              providers:
-                snapshot:
-                  apiKeyEnv: DSH_SNAPSHOT_API_KEY
-                  api: openai-completions
-                  baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
-                  maxRequestImageBytes: 92
-                  models:
-                    - id: vision
-                      contextWindow: 32768
-                      maxTokens: 1024
-                      input: [text, image]

+ 4 - 4
examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl

@@ -6,13 +6,13 @@
 {"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1786431644558,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}}
 {"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"ef352c42-b661-4b71-8c6a-7dbbd0a9f591"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}
 {"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}}
 {"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}}
@@ -28,6 +28,6 @@
 {"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"}
+{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a721cef2-2c49-4336-8d07-5f6cc15f4b67"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"}
 {"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 1 - 1
examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md

@@ -1,6 +1,6 @@
 You are an AI agent powered by DeepSeek Harness.
 
-You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
+You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}.
 
 Verify your work by running the code or tests. Keep answers brief and factual.
 

+ 3 - 3
examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl

@@ -6,12 +6,12 @@
 {"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1783952000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"e58e49ab-9c34-4ba0-9276-9429b32c5ea0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}}
 {"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 4
examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl

@@ -6,13 +6,13 @@
 {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}}
 {"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}
 {"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}}
@@ -21,6 +21,6 @@
 {"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}}
 {"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
+{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
 {"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 4
examples/acp-agent/tests/snapshots/read-image/session.jsonl

@@ -6,13 +6,13 @@
 {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"}
 {"type":"user/message","seq":5,"time":1786344283033,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"}
 {"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}}
-{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
-{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}}
 {"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
+{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}
 {"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
 {"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}}
@@ -21,6 +21,6 @@
 {"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
 {"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
+{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"}
 {"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 24 - 0
examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md

@@ -0,0 +1,24 @@
+You are an AI agent powered by DeepSeek Harness.
+
+You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+Verify your work by running the code or tests. Keep answers brief and factual.
+
+
+Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
+
+Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.
+
+Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.
+
+Check the [exit code: N] marker on every bash result; investigate failures before moving on.
+
+Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
+
+Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
+
+Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
+
+Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
+
+Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.

+ 9 - 2
packages/client/connection/src/client/fixture.ts

@@ -1563,6 +1563,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   // live under one workspace, whose account carries them in attach order.
   const wid = (raw: string): WorkspaceId => raw as WorkspaceId
   const fixtureEpoch = new Date(Date.now() - 300_000).toISOString()
+  const FIXTURE_HOME = '/home/fixture'
   const workspaces: WorkspaceView[] = options.empty ? [] : [{
     workspaceId: wid('fx-ws-fixture'),
     path: '/tmp/fixture',
@@ -1570,6 +1571,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')],
     createdAt: fixtureEpoch,
     updatedAt: fixtureEpoch,
+  }, {
+    workspaceId: wid('fx-ws-home'),
+    path: `${FIXTURE_HOME}/Documents/project`,
+    title: 'project',
+    sessionIds: [],
+    createdAt: fixtureEpoch,
+    updatedAt: fixtureEpoch,
   }]
   let nextWorkspace = 1
   // Registry-global archive set mirroring the host: archived sessions keep
@@ -1580,7 +1588,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   // deterministic content mirroring the design mock so assembled Web tests
   // and snapshots can walk it. Leaves are materialized lazily: a child listed
   // by its parent lists as empty until something is created inside it.
-  const FIXTURE_HOME = '/home/fixture'
   const directoryTree = new Map<string, string[]>([
     ['/', ['home']],
     ['/home', ['fixture']],
@@ -2565,7 +2572,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     },
     host: {
       describe: request => ok(request, {
-        version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true,
+        version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
       }),
       // Deterministic native pick: the keyless lanes drive the full
       // pick-then-adopt path without an OS chooser (design-mock content,

+ 1 - 1
packages/client/connection/src/client/index.ts

@@ -62,7 +62,7 @@ export interface ConnectionHandle {
   readonly api: IApiClient
   /** Whether the current page authority is loopback; non-browser contexts default to true. */
   readonly isLoopback: boolean
-  /** Generation-scoped Host facts, including native path-open capability. */
+  /** Generation-scoped Host facts, including the account home and native path-open capability. */
   readonly hostDescription: HostDescriptionSource
   /** Generic logical RPC channels over the same Connection transport. */
   readonly rpc: ClientConnectionRpc

+ 5 - 5
packages/client/connection/tests/connection.client.spec.ts

@@ -80,7 +80,7 @@ describe('connection lifecycle', () => {
     try {
       await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
       expect(connected).toBe(0) // never announced during the failed generation
-      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
       await vi.waitFor(() => { expect(connected).toBe(1) })
     } finally {
       controller.stop()
@@ -102,7 +102,7 @@ describe('connection lifecycle', () => {
           },
         })
       }
-      return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
     }
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -188,7 +188,7 @@ describe('connection lifecycle', () => {
       describeCalls++
       return describeCalls === 1
         ? firstDescribe.promise
-        : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+        : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
     }
     const states: ConnectionState[] = []
     let connected = 0
@@ -201,7 +201,7 @@ describe('connection lifecycle', () => {
     try {
       await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) })
       api.endStreams()
-      firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
 
       await vi.waitFor(() => { expect(describeCalls).toBe(2) })
       await vi.waitFor(() => { expect(connected).toBe(1) })
@@ -283,7 +283,7 @@ describe('connection lifecycle', () => {
     controller.start()
     try {
       await vi.waitFor(() => { expect(describeCalls).toBe(3) })
-      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
+      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
       await vi.waitFor(() => { expect(connected).toBe(1) })
       expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
     } finally {

+ 2 - 1
packages/client/connection/tests/fake-api.client.ts

@@ -74,10 +74,11 @@ export class FakeApiClient implements IApiClient {
     version: string
     cwd: string
     attachedSessions: number
+    home: string
     canOpenPath: boolean
   }>> =
     () => Promise.resolve(ok({
-      version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
+      version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
     }))
   onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
     () => Promise.resolve(ok({ path: null }))

+ 13 - 5
packages/client/connection/tests/fixture.client.spec.ts

@@ -523,7 +523,9 @@ describe('createFixtureApi', () => {
   it('describe answers the fixture identity', async () => {
     const api = createFixtureApi()
     const response = await api.host.describe(req({}))
-    expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
+    expect(response.result).toMatchObject({
+      ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' },
+    })
     const empty = await createFixtureApi({ empty: true }).host.describe(req({}))
     expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
   })
@@ -548,10 +550,16 @@ describe('createFixtureApi', () => {
     const api = createFixtureApi()
     const listed = await api.workspace.list(req({}))
     if (!listed.result.ok) throw new Error('list failed')
-    expect(listed.result.value.items).toEqual([expect.objectContaining({
-      workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
-      sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
-    })])
+    expect(listed.result.value.items).toEqual([
+      expect.objectContaining({
+        workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture',
+        sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'],
+      }),
+      expect.objectContaining({
+        workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project',
+        sessionIds: [],
+      }),
+    ])
     // path collision → the existing entity comes back, created:false, no frame.
     const reused = await api.workspace.create(req({ path: '/tmp/fixture' }))
     if (!reused.result.ok) throw new Error('reuse failed')

+ 2 - 2
packages/client/runtime/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/runtime/README.md
-README.md: cad8a08f015a7115ebff76cfce4d3ab5eb7a175e
-README.zh.md: 262ab1eeb8ce2d09e801e2af790046d8cdc11e55
+README.md: 7d0e90809aaffb9503f2222e1f7426b13d72d7d5
+README.zh.md: d0a43c32b4861d02786aa0c42e1af72047c9032a

+ 2 - 0
packages/client/runtime/README.md

@@ -26,6 +26,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
 
 SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; ui-renderer creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
 
+`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged.
+
 `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.
 
 `SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.

+ 2 - 0
packages/client/runtime/README.zh.md

@@ -26,6 +26,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
 
 SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;ui-renderer 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。
 
+`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。
+
 `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。
 
 `SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。

+ 1 - 1
packages/client/runtime/src/client/index.ts

@@ -46,7 +46,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
 export { createScope } from './agents/scope.ts'
 export type { AgentScopeHandle } from './agents/scope.ts'
 export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts'
-export { resolveWorkspacePath } from './workspaces/path.ts'
+export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts'
 // Contract only: the scope implementation and its Host transport belong to
 // dsh-client-ui-settings (see that package's settings-scope.ts).
 export type {

+ 24 - 1
packages/client/runtime/src/client/workspaces/path.ts

@@ -5,9 +5,32 @@
  * @returns an absolute path when a workspace root is available, otherwise the original path.
  */
 export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
-  if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
+  if (path.startsWith('/') || isWindowsStylePath(path)) return path
   if (cwd === undefined || cwd === '') return path
   const base = cwd.replace(/[/\\]+$/, '')
   const rel = path.replace(/^[/\\]+/, '')
   return `${base}/${rel}`
 }
+
+/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */
+function isWindowsStylePath(value: string): boolean {
+  return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
+}
+
+/**
+ * Display-only POSIX home abbreviation. Windows drive and UNC paths stay
+ * verbatim, including when `home` itself is a Windows path. A missing, empty,
+ * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`.
+ * @param path - absolute or already-short display path.
+ * @param home - host account home from `host.describe`; absent skips abbreviation.
+ * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`.
+ */
+export function abbreviateHomePath(path: string, home?: string): string {
+  if (home === undefined || home === '') return path
+  if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path
+  const root = home.replace(/\/+$/, '')
+  if (root === '' || root === '/') return path
+  if (path.replace(/\/+$/, '') === root) return '~'
+  if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}`
+  return path
+}

+ 2 - 2
packages/client/runtime/tests/client-apply.client.spec.ts

@@ -91,7 +91,7 @@ describe('runtime client apply', () => {
     expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
     // Mux sink and onConnected route without throwing (manager semantics own the behavior).
     bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
-    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
+    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })
   })
 
   it('selects the recent Workspace once when the first baselines have no current session', async () => {
@@ -104,7 +104,7 @@ describe('runtime client apply', () => {
     }))
     bench.api.onList = () => Promise.resolve(ok({ items: [] }))
 
-    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
+    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })
     await flushMicrotasks()
 
     const sessions = bench.ctx.get('sessions') as SessionRuntime

+ 2 - 1
packages/client/runtime/tests/fake-api.client.ts

@@ -108,10 +108,11 @@ export class FakeApiClient implements IApiClient {
     version: string
     cwd: string
     attachedSessions: number
+    home: string
     canOpenPath: boolean
   }>> =
     () => Promise.resolve(ok({
-      version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
+      version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
     }))
   onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
     () => Promise.resolve(ok({ path: null }))

+ 41 - 0
packages/client/runtime/tests/path.client.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts'
+
+describe('abbreviateHomePath', () => {
+  it('collapses a POSIX home and its descendants', () => {
+    expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~')
+    expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~')
+    expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project')
+    expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/')
+  })
+
+  it('keeps prefix-adjacent names and non-home paths', () => {
+    expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts')
+    expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts')
+    expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts')
+    expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already')
+  })
+
+  it('does not abbreviate when home is missing, empty, or the filesystem root', () => {
+    expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts')
+    expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts')
+    expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts')
+    expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts')
+  })
+
+  it('leaves Windows drive and UNC paths verbatim', () => {
+    expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project')
+    expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project')
+    expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project')
+    expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u')
+  })
+})
+
+describe('resolveWorkspacePath', () => {
+  it('joins a relative path under cwd and passes absolute paths through', () => {
+    expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
+    expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
+    expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts')
+    expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
+  })
+})

+ 1 - 1
packages/client/runtime/tests/wire-events.client.spec.ts

@@ -127,7 +127,7 @@ describe('wire event bridge', () => {
     const bench = await mount()
     let resets = 0
     bench.ctx.on('connection/reset', () => { resets++ })
-    const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }
+    const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }
     bench.sinks?.onConnected?.(description)
     bench.sinks?.onConnected?.(description) // second generation after a reconnect
     expect(resets).toBe(2)

+ 2 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: d9b774bdf5bfc2beaa33fe0d3ada8263b863798d
-README.zh.md: 94da3def811fb901132f53fd6dbf4de0ccd6b3c8
+README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0
+README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -22,7 +22,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n
 
 A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
 
-The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent.
+The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)).
 
 The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -20,7 +20,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。
 
 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
 
-聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
+聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md))。
 
 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
 

+ 1 - 4
packages/client/ui-conversation/src/client/apply.ts

@@ -396,10 +396,7 @@ export function apply(ctx: Context): void {
         fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
         openFile: (path) => {
           const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
-          void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {
-            // Host/OS open failures stay silent in the chat row; the native
-            // app surfaces its own error dialog when the path is unusable.
-          })
+          return workspaces.openPath(resolveWorkspacePath(cwd, path))
         },
         loadOlder: () => { void scoped.loadOlder() },
         loadImage: attachment => conversation.resolveImage(sessionId, attachment),

+ 5 - 0
packages/client/ui-conversation/src/client/chat/ChatView.module.css

@@ -190,3 +190,8 @@
 .toBottom:hover {
   background: var(--dsw-alias-button-floating-hover);
 }
+
+/* Host open-path refusal: same dialog family as the workspace folder error. */
+.modalAction {
+  min-width: 72px;
+}

+ 87 - 3
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -1,7 +1,8 @@
 // ChatView: the default conversation view — one stable keyed parent list over
 // final business Nodes, plus paging, pending steering and bottom-follow.
 // Each row dispatches through 'conversation.chat.node'; ui-tool owns the
-// tool-call renderer and its recursive root/subcall composition.
+// tool-call renderer and its recursive root/subcall composition. A Host
+// open-path refusal from the injected opener is an in-page dialog here.
 //
 // Scroll: when nested under `[data-conversation-scroll]` (active conversation
 // column), that host is the scrollport and this view is flow content; when
@@ -14,7 +15,7 @@
 
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
-import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts'
 import { PendingSteeringBubble } from './MessageItem.tsx'
 import { ChatNodeSeat } from './ChatNodeSeat.tsx'
@@ -95,6 +96,17 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP
   }
 }
 
+/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */
+function openFailureMessage(error: unknown, fallback: string): string {
+  const message = error instanceof Error ? error.message : String(error)
+  return message === '' ? fallback : message
+}
+
+/** ProducedFiles opens the session workspace as `.`. */
+function isFolderOpenPath(path: string): boolean {
+  return path === '.'
+}
+
 function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
   let latest: number | null = null
   for (const turn of timeline.turns.values()) {
@@ -159,6 +171,40 @@ export function ChatView({
   const hasMore = useSession(s => s.hasMore)
   const loadingOlder = useSession(s => s.loadingOlder)
   const selectedCallId = useStore(s => s.selection?.callId)
+  const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
+  const [fileOpenBusy, setFileOpenBusy] = useState(false)
+  // Close/retry must ignore a settlement that started before the latest
+  // gesture; otherwise a cancelled in-flight refusal reopens the dialog.
+  const fileOpenRequest = useRef(0)
+
+  const requestOpenFile = useCallback((path: string) => {
+    const id = ++fileOpenRequest.current
+    setFileOpenBusy(true)
+    void openFile(path).then(
+      () => {
+        if (id !== fileOpenRequest.current) return
+        setFileOpenError(null)
+        setFileOpenBusy(false)
+      },
+      (error: unknown) => {
+        if (id !== fileOpenRequest.current) return
+        setFileOpenError({
+          path,
+          message: openFailureMessage(
+            error,
+            t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'),
+          ),
+        })
+        setFileOpenBusy(false)
+      },
+    )
+  }, [openFile, t])
+
+  const closeFileOpenError = useCallback(() => {
+    fileOpenRequest.current += 1
+    setFileOpenError(null)
+    setFileOpenBusy(false)
+  }, [])
 
   const pendingSteering = useMemo(
     () => inbox.filter(item => item.placement === 'steering'),
@@ -390,7 +436,7 @@ export function ChatView({
               useSession={useSession}
               selectedCallId={selectedCallId}
               cwd={cwd}
-              openFile={openFile}
+              openFile={requestOpenFile}
               inspectCall={inspectCall}
               forkAt={forkAt}
               renderMessageImages={renderMessageImages}
@@ -431,6 +477,44 @@ export function ChatView({
           </div>
         )}
       </div>
+      {fileOpenError !== null && (
+        <FileOpenErrorDialog
+          path={fileOpenError.path}
+          message={fileOpenError.message}
+          busy={fileOpenBusy}
+          onClose={closeFileOpenError}
+          onRetry={() => { requestOpenFile(fileOpenError.path) }}
+          t={t}
+        />
+      )}
     </div>
   )
 }
+
+/** In-page Host open-path refusal: the wire reason plus a retry of the same path. */
+function FileOpenErrorDialog({
+  path, message, busy, onClose, onRetry, t,
+}: {
+  path: string
+  message: string
+  busy: boolean
+  onClose: () => void
+  onRetry: () => void
+  t: ChatViewSlotProps['t']
+}) {
+  return (
+    <Modal
+      open
+      onClose={onClose}
+      closeLabel={t('close')}
+      title={t(isFolderOpenPath(path) ? 'fileOpen.folderTitle' : 'fileOpen.title')}
+      description={message}
+      footer={(
+        <>
+          <Button variant="outline" className={css.modalAction} onClick={onClose}>{t('cancel')}</Button>
+          <Button variant="primary" className={css.modalAction} disabled={busy} onClick={onRetry}>{t('retry')}</Button>
+        </>
+      )}
+    />
+  )
+}

+ 4 - 2
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -714,9 +714,11 @@ export interface ChatViewInjected {
   openDetails: (target: SelectionTarget) => void
   /**
    * Open a tool-arg filesystem path with the host OS default application
-   * (relative paths resolve against the session cwd).
+   * (relative paths resolve against the session cwd). Always returns a
+   * promise: fulfills when the Host opens the path, rejects when it cannot
+   * hand the path off (the chat view shows that reason and a retry).
    */
-  openFile: (path: string) => void
+  openFile: (path: string) => Promise<void>
   loadOlder: () => void
   /** Resolve a session-authorized historical image for inline display. */
   loadImage: (attachment: ImageAttachmentRef) => Promise<string>

+ 8 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -92,6 +92,10 @@ export const zh = {
   'chat.loadError': '历史加载失败:{message}({code})',
   'chat.loadOlder': '加载更早',
   'chat.toBottom': '回到底部',
+  'fileOpen.title': '无法打开文件',
+  'fileOpen.unknown': '无法打开此文件',
+  'fileOpen.folderTitle': '无法打开文件夹',
+  'fileOpen.folderUnknown': '无法打开此文件夹',
   'message.extraBlock': '附加内容块',
   'message.contextInjection': '上下文注入',
   'message.contextRecall': '跨会话召回',
@@ -263,6 +267,10 @@ export const en = {
   'chat.loadError': 'Failed to load history: {message} ({code})',
   'chat.loadOlder': 'Load earlier',
   'chat.toBottom': 'Back to bottom',
+  'fileOpen.title': 'Couldn’t open file',
+  'fileOpen.unknown': 'Couldn’t open this file',
+  'fileOpen.folderTitle': 'Couldn’t open folder',
+  'fileOpen.folderUnknown': 'Couldn’t open this folder',
   'message.extraBlock': 'Extra content block',
   'message.contextInjection': 'Context injection',
   'message.contextRecall': 'Session recall',

+ 9 - 1
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -233,13 +233,21 @@ describe('conversation slot inject API', () => {
   it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
     const b = await bench()
     const { injected } = b.chatViewApi(ROOT)
-    injected.openFile('src/a.ts')
+    await injected.openFile('src/a.ts')
     await vi.waitFor(() => {
       expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
     })
     await b.runtime.dispose()
   })
 
+  it('openFile rejects when the Host cannot open the path', async () => {
+    const b = await bench()
+    b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available')))
+    const { injected } = b.chatViewApi(ROOT)
+    await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available')
+    await b.runtime.dispose()
+  })
+
   it('routes workspace switching through the runtime owner, carrying the draft', async () => {
     const b = await bench()
     const resident = b.residentApi(ROOT)

+ 107 - 4
packages/client/ui-conversation/tests/chat-view.client.spec.tsx

@@ -4,7 +4,7 @@
 // ObservableSnapshot fake, no wire or Tool presentation plugin.
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { useEffect } from 'react'
 import type {
   AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
@@ -152,7 +152,7 @@ function emptyWorkspaces() {
 function makeHarness(init?: Partial<ConversationSnapshot>) {
   const { set, source } = makeSource(init)
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
-  const openFile = vi.fn<(path: string) => void>()
+  const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
   const loadOlder = vi.fn()
   const inspectCall = vi.fn<(callId: string) => void>()
   // In-memory scroll memory matching the apply.ts per-session map contract.
@@ -348,7 +348,7 @@ describe('Chat node rendering', () => {
       resolve: (value) => {
         if (value !== 'report.html') return undefined
         return {
-          open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
+          open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
           label: '打开 site/report.html',
           title: 'site/report.html',
         }
@@ -967,10 +967,113 @@ describe('ChatView', () => {
     })
     const owner = calls[0]?.owner as RoutedChatNodeOwner
     expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block)
-    expect(owner.openFile).toBe(h.openFile)
+    expect(owner.openFile).not.toBe(h.openFile)
+    owner.openFile('src/a.ts')
+    expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
     expect(owner.inspectCall).toBe(h.inspectCall)
   })
 
+  it('shows a Host open refusal with the reason and retries the same path', async () => {
+    const openFile = vi.fn<(path: string) => Promise<void>>()
+      .mockRejectedValueOnce(new Error('xdg-open is not available'))
+      .mockResolvedValueOnce(undefined)
+    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
+    h.props.openFile = openFile
+    render(<h.ChatView {...h.props} />)
+    await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy()
+    })
+    expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('xdg-open is not available')
+    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
+    await waitFor(() => {
+      expect(screen.queryByRole('dialog')).toBeNull()
+    })
+    expect(openFile).toHaveBeenCalledTimes(2)
+    expect(openFile).toHaveBeenNthCalledWith(1, 'src/a.ts')
+    expect(openFile).toHaveBeenNthCalledWith(2, 'src/a.ts')
+  })
+
+  it('keeps a non-Error Host refusal visible and dismisses it on cancel', async () => {
+    const openFile = vi.fn<(path: string) => Promise<void>>()
+      .mockRejectedValueOnce('permission denied')
+    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
+    h.props.openFile = openFile
+    render(<h.ChatView {...h.props} />)
+    await act(async () => { h.toolOwners[0]!.openFile('notes.md') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('permission denied')
+    })
+    fireEvent.click(screen.getByRole('button', { name: '取消' }))
+    expect(screen.queryByRole('dialog')).toBeNull()
+    expect(openFile).toHaveBeenCalledTimes(1)
+  })
+
+  it('substitutes the unknown-open copy when the Host refusal has no text', async () => {
+    const openFile = vi.fn<(path: string) => Promise<void>>()
+      .mockRejectedValueOnce(new Error(''))
+    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
+    h.props.openFile = openFile
+    render(<h.ChatView {...h.props} />)
+    await act(async () => { h.toolOwners[0]!.openFile('empty.ts') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('无法打开此文件')
+    })
+  })
+
+  it('names a workspace-folder Host refusal as a folder', async () => {
+    const openFile = vi.fn<(path: string) => Promise<void>>()
+      .mockRejectedValueOnce(new Error(''))
+    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
+    h.props.openFile = openFile
+    render(<h.ChatView {...h.props} />)
+    await act(async () => { h.toolOwners[0]!.openFile('.') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹')
+    })
+  })
+
+  it('ignores a Host refusal that settles after the dialog is dismissed', async () => {
+    let rejectRetry!: (error: unknown) => void
+    const openFile = vi.fn<(path: string) => Promise<void>>()
+      .mockRejectedValueOnce(new Error('first refusal'))
+      .mockImplementationOnce(() => new Promise<void>((_resolve, reject) => {
+        rejectRetry = reject
+      }))
+    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
+    h.props.openFile = openFile
+    render(<h.ChatView {...h.props} />)
+    await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal')
+    })
+    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
+    fireEvent.click(screen.getByRole('button', { name: '取消' }))
+    expect(screen.queryByRole('dialog')).toBeNull()
+    await act(async () => { rejectRetry(new Error('late refusal')) })
+    expect(screen.queryByRole('dialog')).toBeNull()
+  })
+
+  it('ignores a Host open that succeeds after the dialog is dismissed', async () => {
+    let resolveRetry!: () => void
+    const openFile = vi.fn<(path: string) => Promise<void>>()
+      .mockRejectedValueOnce(new Error('first refusal'))
+      .mockImplementationOnce(() => new Promise<void>((resolve) => {
+        resolveRetry = () => { resolve() }
+      }))
+    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
+    h.props.openFile = openFile
+    render(<h.ChatView {...h.props} />)
+    await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') })
+    await waitFor(() => {
+      expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal')
+    })
+    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) })
+    fireEvent.click(screen.getByRole('button', { name: '取消' }))
+    await act(async () => { resolveRetry() })
+    expect(screen.queryByRole('dialog')).toBeNull()
+  })
+
   it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
     const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true })
     const view = render(<h.ChatView {...h.props} />)

+ 1 - 1
packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx

@@ -45,7 +45,7 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
         // @ts-expect-error openDetails takes a SelectionTarget, not a string
         props.openDetails('nope')
         // @ts-expect-error openFile takes a path string, not a SelectionTarget
-        props.openFile({ turnSeq: 1, callId: 'c' })
+        void props.openFile({ turnSeq: 1, callId: 'c' })
         return null
       }
       void chatProps

+ 1 - 1
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -286,7 +286,7 @@ describe('ProducedFiles row', () => {
   ): Pick<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
     const description = canOpenPath === undefined
       ? undefined
-      : { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath }
+      : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath }
     return {
       isLoopback,
       useHostDescription: selector => selector(description),

+ 2 - 2
packages/client/ui-message-feedback/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-message-feedback/README.md
-README.md: 461e87589567eb95b075839d5893cc551e67a035
-README.zh.md: 31f722021ff65f476a6ba1ab0211fd4e091671d2
+README.md: d3bb4e28b95da2fde0d26b7ef83ebca377b4939f
+README.zh.md: d823bb9de2b7d39d0bc5d00164b8ce8dffec4a34

+ 1 - 1
packages/client/ui-message-feedback/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
+Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. The note editor itself does not sit in that row: it is a `role="dialog"` popover portaled to `document.body` and anchored under its trigger, so the row keeps its single line whether the editor is open or closed and the panel is not clipped by the conversation column. A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
 
 One `MessageFeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history.
 

+ 1 - 1
packages/client/ui-message-feedback/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
+单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。备注编辑器本身不在这一行里:它是一个 `role="dialog"` 的浮层,portal 到 `document.body` 并锚定在其触发按钮下方,因此无论编辑器是否打开该行都保持单行,面板也不会被会话列裁掉。评分或列表加载失败在行内展示;备注保存失败在浮层内展示,且面板保持打开以便修正草稿。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
 
 每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。
 

+ 1 - 0
packages/client/ui-message-feedback/package.json

@@ -71,6 +71,7 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },

+ 49 - 13
packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css

@@ -1,6 +1,9 @@
 /* Per-message feedback controls. The rating buttons mirror the shared message
-   IconActions chrome so the strip reads as one row; the note editor is an
-   inline expansion anchored to the same row. */
+   IconActions chrome so the strip reads as one row. The note editor is a
+   popover portaled to document.body and fixed from the note trigger's rect,
+   so it neither competes with the row for inline width nor gets cropped by the
+   conversation column's overflow clip. Surface recipe follows the Menu card:
+   r12, inverted hairline border, shadow-lv3. */
 
 .action {
   display: inline-flex;
@@ -47,29 +50,58 @@
   cursor: pointer;
 }
 
-.noteOpen:hover {
+.noteOpen:hover,
+.noteOpen[aria-expanded='true'] {
   background: var(--dsw-alias-interactive-bg-hover);
   color: var(--dsw-alias-label-secondary);
 }
 
-.noteEditor {
-  display: inline-flex;
-  align-items: flex-start;
-  gap: 6px;
+/* Portal surface: fixed in the viewport, left/top supplied inline from the
+   trigger rect. Portaled panels must layer above modal overlays (z 1000). */
+.notePanel {
+  position: fixed;
+  z-index: 1100;
+  box-sizing: border-box;
+  width: 320px;
+  max-width: min(360px, calc(100vw - 24px));
+  /* The width bound's counterpart. `resize: vertical` on the textarea lets the
+     panel be dragged taller, and a panel taller than the viewport would push
+     the placement clamp's upper bound below its own margin, so `top` would go
+     negative and cut off the panel's head. Both bounds keep the 12px margin
+     the clamp uses. */
+  max-height: calc(100vh - 24px);
+  overflow-y: auto;
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  border: 1px solid var(--dsw-alias-border-inverted);
+  border-radius: 12px;
+  background: var(--dsw-specific-menu);
+  box-shadow: var(--dsw-shadow-lv3);
+  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
 }
 
 .noteInput {
-  width: 260px;
+  width: 100%;
+  box-sizing: border-box;
   padding: 6px 8px;
-  border: 1px solid var(--dsw-alias-border-secondary);
+  border: 1px solid var(--dsw-alias-border-l2);
   border-radius: 8px;
-  background: var(--dsw-alias-bg-primary);
+  background: var(--dsw-alias-bg-layer-1);
   color: var(--dsw-alias-label-primary);
   font: inherit;
   font-size: 13px;
   resize: vertical;
 }
 
+.noteActions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 6px;
+}
+
 .noteSave,
 .noteCancel {
   height: 28px;
@@ -81,8 +113,12 @@
 }
 
 .noteSave {
-  background: var(--dsw-alias-interactive-bg-primary);
-  color: var(--dsw-alias-label-inverse);
+  background: var(--dsw-alias-button-primary-fill);
+  color: var(--dsw-alias-label-primary-foreground);
+}
+
+.noteSave:hover:not(:disabled) {
+  background: var(--dsw-alias-button-primary-hover);
 }
 
 .noteSave:disabled {
@@ -104,5 +140,5 @@
   padding-left: 4px;
   color: var(--dsw-alias-label-tertiary);
   font-size: 13px;
-  line-height: 28px;
+  line-height: 20px;
 }

+ 215 - 44
packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx

@@ -1,23 +1,47 @@
 /**
  * Per-message feedback controls: a Like/Dislike pair plus an optional note.
- * Rendered inside the assistant message's IconActions row, so the buttons
- * reuse that row's chrome and sit between copy and branch.
+ * The buttons render inside the assistant message's IconActions row, so they
+ * reuse that row's chrome and sit between copy and branch. The note editor is
+ * a popover (portaled to `document.body`) anchored to the note trigger, not an
+ * inline expansion: a 260px textarea plus buttons cannot fit the row at any
+ * viewport, and an inline element pushed the branch action and clock out of the
+ * conversation column. Portaling out of the column also escapes its `overflow`
+ * clip, so the panel cannot be cropped or detached from the message it annotates.
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions
  */
 
-import { useCallback, useEffect, useRef, useState } from 'react'
 import {
-  IconDislikeOutline16, IconLikeOutline16, Tooltip,
+  useCallback, useEffect, useRef, useState,
+  type CSSProperties,
+} from 'react'
+import { createPortal } from 'react-dom'
+import {
+  IconDislikeOutline16, IconLikeOutline16, Tooltip, useAnchoredPosition,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
 import type { MessageFeedbackActionProps } from './slots.ts'
 import css from './MessageFeedbackActions.module.css'
 
+/** Safe distance kept between the panel and the viewport edges (the Menu portal margin). */
+const PANEL_MARGIN = 12
+
+/** Distance between the trigger's bottom edge and the panel's top. */
+const PANEL_GAP = 4
+
+/**
+ * Unplaced portal panel: hidden but laid out so `offsetWidth` is real for the
+ * clamp. The explicit insets match `Menu`'s measure style — a `position: fixed`
+ * element with auto insets otherwise sits at its static position, a different
+ * origin than the one the first placement measures from.
+ */
+const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
+
 /**
  * One message's feedback controls.
  * @param props - the owner's message identity, the injected verbs, and the
  * shared feedback hook.
- * @returns the rating buttons, plus the note editor while it is open.
+ * @returns the rating buttons and the note trigger, with the note editor
+ * portal-open beneath the trigger while it is open.
  */
 export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: MessageFeedbackActionProps) {
   const item = useFeedback(view => view.items.get(messageId))
@@ -26,7 +50,15 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN
   const [noteOpen, setNoteOpen] = useState(false)
   const [draft, setDraft] = useState('')
   const [pending, setPending] = useState(false)
-  const [failure, setFailure] = useState<string | null>(null)
+  // A rating or load failure surfaces beside the rating buttons, always legible
+  // whether or not the note popover is open.
+  const [rowFailure, setRowFailure] = useState<string | null>(null)
+  // A note save failure surfaces inside the note popover, where the human is
+  // looking; it stays open so the draft survives to be corrected.
+  const [noteFailure, setNoteFailure] = useState<string | null>(null)
+  const triggerRef = useRef<HTMLButtonElement>(null)
+  const panelRef = useRef<HTMLDivElement>(null)
+  const inputRef = useRef<HTMLTextAreaElement>(null)
   // The controls mount for every settled message in the transcript, so the
   // Session's feedback is read once on first hover/focus rather than on mount.
   const seeded = useRef(false)
@@ -39,47 +71,158 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN
   const alive = useRef(true)
   useEffect(() => () => { alive.current = false }, [])
 
-  const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => {
+  /** Bumped whenever an editing session ends, so a late save can tell it is stale. */
+  const noteGeneration = useRef(0)
+
+  /** Current panel open-state, readable from a stale closure via a ref. */
+  const noteOpenRef = useRef(false)
+  useEffect(() => { noteOpenRef.current = noteOpen }, [noteOpen])
+
+  const errorCopy = useCallback((result: { ok: boolean; error?: { code: string } }) => {
+    return result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic')
+  }, [t])
+
+  const settleRating = useCallback((result: { ok: boolean; error?: { code: string } }) => {
     if (!alive.current) return
     setPending(false)
-    if (result.ok) {
-      setFailure(null)
-      return
-    }
-    setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic'))
-  }, [t])
+    setRowFailure(result.ok ? null : errorCopy(result))
+  }, [errorCopy])
+
+  const closeNote = useCallback(() => {
+    // Ends the editing session, so any save still in flight becomes stale.
+    noteGeneration.current += 1
+    setNoteOpen(false)
+  }, [])
 
   const onRate = useCallback((next: MessageFeedbackRating) => {
     setPending(true)
-    setFailure(null)
+    setRowFailure(null)
     // The controller decides retract-vs-replace from the committed item, so a
     // click that lands before the first list read still toggles the stored
     // value instead of this render's empty view.
-    setNoteOpen(false)
-    void toggle(messageId, next).then(settle)
-  }, [messageId, settle, toggle])
+    closeNote()
+    void toggle(messageId, next).then(settleRating)
+  }, [closeNote, messageId, settleRating, toggle])
 
   // The rating is a parameter because only the note editor's render site can
   // prove one is recorded; that removes an unreachable undefined guard here.
   const onSaveNote = useCallback((current: MessageFeedbackRating) => {
     const trimmed = draft.trim()
     setPending(true)
-    setFailure(null)
+    setNoteFailure(null)
+    // A save belongs to the editing session that started it. Closing and
+    // reopening the panel begins a new one, and a late reply from the old
+    // session must not act on it: a stale success would shut the panel the
+    // human just opened, and a stale failure would describe a draft this
+    // session never sent.
+    const generation = noteGeneration.current
+    // What a session reopened before this save commits would be seeded with.
+    const staleSeed = item?.note ?? ''
     // An emptied editor removes the note explicitly; `rate` alone preserves a
     // stored note, so it cannot express deletion.
     const settled = trimmed.length === 0
       ? clearNote(messageId)
       : rate(messageId, current, trimmed)
     void settled.then((result) => {
-      settle(result)
-      if (result.ok && alive.current) setNoteOpen(false)
+      if (!alive.current) return
+      // `pending` tracks the request in flight, not the editing session, so it
+      // is released either way; all three of like, dislike and Save read
+      // `disabled={pending}`, and holding it would lock the row until remount.
+      // Releasing it unconditionally is safe because those three are the only
+      // mutation entries and each is gated by it, so at most one request is ever
+      // in flight. A future entry that bypasses the gate would have to bind
+      // `pending` to the generation instead of clearing it here.
+      setPending(false)
+      if (result.ok) {
+        // Only the session that is still open may act on a success: closing it
+        // already discarded the draft, and reopening seeded a new one.
+        if (generation === noteGeneration.current) {
+          setNoteFailure(null)
+          setNoteOpen(false)
+          return
+        }
+        // A newer session is open, seeded from the note as it read before this
+        // save committed. Resync it so the editor shows what is stored and the
+        // next save cannot overwrite the text that just landed. An edited draft
+        // is the human's, so it is left alone.
+        setDraft(draftNow => (draftNow === staleSeed ? trimmed : draftNow))
+        return
+      }
+      // A failure from the session still on screen belongs in its panel. One
+      // from an abandoned session is reported only when no new session has
+      // taken over: the row then carries it, so a save that failed after the
+      // human walked away is not silently dropped. Writing it into a reopened
+      // panel instead would label the new draft with the old attempt's error.
+      // `noteOpenRef` — not the `noteOpen` this closure was created from — is
+      // read here, because a close+reopen between the save and resolution
+      // leaves this closure with the panel state from when the save started.
+      if (generation === noteGeneration.current || !noteOpenRef.current) {
+        setNoteFailure(errorCopy(result))
+      }
     })
-  }, [clearNote, draft, messageId, rate, settle])
+  }, [clearNote, draft, errorCopy, item?.note, messageId, noteOpenRef, rate])
 
-  const openNote = useCallback(() => {
+  // The trigger toggles: while closed it opens the popover (seeding the draft
+  // with the recorded note), while open it closes it. Toggling closed via the
+  // trigger also fires the outside/within logic correctly because the trigger
+  // is inside the panel's "inside" region.
+  const toggleNote = useCallback(() => {
+    if (noteOpen) {
+      closeNote()
+      return
+    }
     setDraft(item?.note ?? '')
+    // A note-save failure belongs to the editing session that produced it. The
+    // panel stays open on failure so the draft can be corrected, but once it is
+    // closed and reopened the draft is reseeded from the stored note, so a
+    // carried-over error would describe an attempt the new draft never made.
+    // A failure that arrives after the panel closed is reported in the row, and
+    // clearing it here is what retires that notice when a new session starts.
+    setNoteFailure(null)
     setNoteOpen(true)
-  }, [item?.note])
+  }, [noteOpen, closeNote, item?.note])
+
+  // Place the portaled panel from the trigger rect before paint and keep it
+  // with the trigger on scroll/resize, the same anchoring `Menu` uses for its
+  // portal mode.
+  const pos = useAnchoredPosition({
+    open: noteOpen,
+    anchorRef: triggerRef,
+    panelRef,
+    gap: PANEL_GAP,
+    margin: PANEL_MARGIN,
+  })
+
+  // Focus the input and close on Escape or outside pointer-down while open.
+  useEffect(() => {
+    if (!noteOpen) return
+    inputRef.current?.focus()
+    const onPointerDown = (e: PointerEvent) => {
+      if (!(e.target instanceof Node)) return
+      if (triggerRef.current?.contains(e.target) === true) return
+      if (panelRef.current?.contains(e.target) === true) return
+      closeNote()
+    }
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') closeNote()
+    }
+    document.addEventListener('pointerdown', onPointerDown)
+    document.addEventListener('keydown', onKeyDown)
+    return () => {
+      document.removeEventListener('pointerdown', onPointerDown)
+      document.removeEventListener('keydown', onKeyDown)
+    }
+  }, [noteOpen, closeNote])
+
+  // Return focus to the trigger only when the panel actually closes, not on the
+  // initial mount (a freshly rendered message with a recorded rating must not
+  // pull focus into its action row).
+  const wasOpen = useRef(false)
+  useEffect(() => {
+    if (noteOpen) { wasOpen.current = true; return }
+    if (wasOpen.current) triggerRef.current?.focus()
+    wasOpen.current = false
+  }, [noteOpen])
 
   const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like')
   const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike')
@@ -116,38 +259,66 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN
           <IconDislikeOutline16 />
         </button>
       </Tooltip>
-      {rating !== undefined && !noteOpen && (
-        <button type="button" className={css.noteOpen} onClick={openNote}>
+      {rating !== undefined && (
+        <button
+          ref={triggerRef}
+          type="button"
+          className={css.noteOpen}
+          aria-haspopup="dialog"
+          aria-expanded={noteOpen}
+          onClick={toggleNote}
+        >
           {item?.note === undefined ? t('note.open') : item.note}
         </button>
       )}
-      {rating !== undefined && noteOpen && (
-        <span className={css.noteEditor}>
+      {rowFailure === null && loadFailed && (
+        <span className={css.failure} role="status">{t('error.load')}</span>
+      )}
+      {rowFailure !== null && <span className={css.failure} role="status">{rowFailure}</span>}
+      {/* A note-save failure normally lives inside the panel, beside the buttons
+          that produced it. Whenever the panel is not on screen it falls back to
+          the row instead: the rating may have disappeared underneath an open
+          editor (another client retracts the feedback, a `version-conflict`
+          reply commits `current: null`, the item goes away), or the human may
+          have closed the panel before a slow save came back. Either way the row
+          reports that the save did not land rather than dropping it. */}
+      {!(rating !== undefined && noteOpen) && noteFailure !== null && (
+        <span className={css.failure} role="status">{noteFailure}</span>
+      )}
+      {rating !== undefined && noteOpen && createPortal(
+        <div
+          ref={panelRef}
+          className={css.notePanel}
+          role="dialog"
+          aria-label={t('note.dialog')}
+          style={pos ?? MEASURE_STYLE}
+        >
           <textarea
+            ref={inputRef}
             className={css.noteInput}
             aria-label={t('note.aria')}
             placeholder={t('note.placeholder')}
             value={draft}
-            rows={2}
+            rows={3}
             onChange={(event) => { setDraft(event.target.value) }}
           />
-          <button
-            type="button"
-            className={css.noteSave}
-            disabled={pending}
-            onClick={() => { onSaveNote(rating) }}
-          >
-            {t('note.save')}
-          </button>
-          <button type="button" className={css.noteCancel} onClick={() => { setNoteOpen(false) }}>
-            {t('note.cancel')}
-          </button>
-        </span>
-      )}
-      {failure === null && loadFailed && (
-        <span className={css.failure} role="status">{t('error.load')}</span>
+          <div className={css.noteActions}>
+            <button
+              type="button"
+              className={css.noteSave}
+              disabled={pending}
+              onClick={() => { onSaveNote(rating) }}
+            >
+              {t('note.save')}
+            </button>
+            <button type="button" className={css.noteCancel} onClick={closeNote}>
+              {t('note.cancel')}
+            </button>
+          </div>
+          {noteFailure !== null && <span className={css.failure} role="status">{noteFailure}</span>}
+        </div>,
+        document.body,
       )}
-      {failure !== null && <span className={css.failure} role="status">{failure}</span>}
     </>
   )
 }

+ 2 - 0
packages/client/ui-message-feedback/src/client/locales.ts

@@ -7,6 +7,7 @@ export const zh = {
   'action.dislike': '有问题的回答',
   'action.dislikeActive': '取消标记',
   'note.open': '补充说明',
+  'note.dialog': '反馈',
   'note.placeholder': '这条回答哪里好,或哪里有问题?(可选)',
   'note.save': '保存',
   'note.cancel': '取消',
@@ -33,6 +34,7 @@ export const en = {
   'action.dislike': 'Bad response',
   'action.dislikeActive': 'Remove rating',
   'note.open': 'Add a note',
+  'note.dialog': 'Feedback',
   'note.placeholder': 'What was good, or what went wrong? (optional)',
   'note.save': 'Save',
   'note.cancel': 'Cancel',

+ 491 - 1
packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx

@@ -8,7 +8,7 @@
  */
 import { useSyncExternalStore } from 'react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
@@ -246,4 +246,494 @@ describe('MessageFeedbackActions', () => {
     await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
     expect(ui.queryByText(zh['error.load'])).toBeNull()
   })
+
+  it('portals the note editor to the document body, not into the actions row', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+
+    // The editor must float above the transcript (escaping the conversation
+    // column's overflow clip), so it renders through a portal to document.body
+    // rather than inline inside the component's own container.
+    const panel = ui.getByRole('dialog')
+    expect(panel).toBeTruthy()
+    expect(ui.container.querySelector('[role="dialog"]')).toBeNull()
+    expect(document.body.contains(panel)).toBe(true)
+  })
+
+  it('closes the note popover on Escape', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+
+    expect(ui.queryByRole('dialog')).toBeNull()
+  })
+
+  it('closes the note popover on an outside pointer-down', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    fireEvent.pointerDown(document.body)
+
+    expect(ui.queryByRole('dialog')).toBeNull()
+  })
+
+  it('keeps the note popover open on a pointer-down inside it', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    const panel = ui.getByRole('dialog')
+    expect(panel).toBeTruthy()
+
+    fireEvent.pointerDown(panel)
+
+    expect(ui.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('does not close the note popover on a pointer-down on its trigger', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    // The trigger is inside the panel's own region, so pressing it must not be
+    // treated as an outside click; the toggle click below then closes it.
+    fireEvent.pointerDown(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('toggles the note popover closed and open from its trigger', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+    expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.queryByRole('dialog')).toBeNull()
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('ignores keys other than Escape while the popover is open', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    fireEvent.keyDown(document, { key: 'Enter' })
+
+    expect(ui.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('publishes no rating-state after the row unmounts mid-flight', async () => {
+    // Directly exercise the early-return of a rating settle once the control has
+    // unmounted: the promise resolution must not touch React state.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: true as const }) }
+    })
+    const view: MessageFeedbackView = { status: 'ready', items: new Map(), error: null }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      toggle: vi.fn(() => gate),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+    const errors: unknown[] = []
+    const onError = (event: ErrorEvent): void => { errors.push(event.error) }
+    window.addEventListener('error', onError)
+
+    fireEvent.click(ui.getByLabelText(zh['action.like']))
+    ui.unmount()
+    release()
+    await gate
+
+    window.removeEventListener('error', onError)
+    expect(errors).toEqual([])
+  })
+
+  it('publishes no note-state after the row unmounts mid-save', async () => {
+    // Same unmount early-return for the note-save settle path: resolving the
+    // save promise after unmount must not touch React state.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: true as const }) }
+    })
+    const view: MessageFeedbackView = { status: 'ready', items: new Map([[MSG, item({ rating: 'positive' })]]), error: null }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const errors: unknown[] = []
+    const onError = (event: ErrorEvent): void => { errors.push(event.error) }
+    window.addEventListener('error', onError)
+
+    const ui = render(<MessageFeedbackActions {...props} />)
+    fireEvent.click(ui.getByText(zh['note.open']))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+    ui.unmount()
+    release()
+    await gate
+
+    window.removeEventListener('error', onError)
+    expect(errors).toEqual([])
+  })
+
+  it('ignores a pointer-down whose target is not a DOM node', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    // The outside-click guard returns without closing when the event target is
+    // not a DOM node. `document.dispatchEvent` delivers straight to the
+    // document listener, and a non-Node target is not `instanceof Node`.
+    const event = new MouseEvent('pointerdown', { bubbles: true })
+    Object.defineProperty(event, 'target', { configurable: true, value: { notANode: true } })
+    document.dispatchEvent(event)
+
+    expect(ui.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('returns focus to the trigger when the popover closes', () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
+    const trigger = ui.getByText(zh['note.open'])
+
+    fireEvent.click(trigger)
+    expect(ui.getByRole('dialog')).toBeTruthy()
+    fireEvent.keyDown(document, { key: 'Escape' })
+
+    // Closing hands focus back, so a keyboard user resumes on the row they
+    // came from rather than at the document root.
+    expect(ui.queryByRole('dialog')).toBeNull()
+    expect(document.activeElement).toBe(trigger)
+  })
+
+  it('does not pull focus when an already-rated message mounts', () => {
+    // The `wasOpen` guard exists for this: a transcript of already-rated
+    // messages must not drag focus into an action row as each one mounts.
+    // Only a real open-then-close returns focus.
+    const elsewhere = document.createElement('button')
+    document.body.append(elsewhere)
+    elsewhere.focus()
+
+    mount({ current: item({ rating: 'positive' }) })
+
+    expect(document.activeElement).toBe(elsewhere)
+    elsewhere.remove()
+  })
+
+  it('drops a stale save failure when the popover is reopened', async () => {
+    // The failure belongs to the editing session that produced it: reopening
+    // reseeds the draft from the stored note, so a carried-over error would
+    // describe an attempt the new draft never made.
+    const ui = mount({
+      current: item({ rating: 'positive' }),
+      rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
+    })
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+    await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(ui.getByText(zh['note.open']))
+
+    expect(ui.queryByText(zh['error.generic'])).toBeNull()
+  })
+
+  it('keeps a save failure visible when the rating disappears underneath it', async () => {
+    // Another client retracts the feedback while the editor is open: the
+    // controller commits `current: null`, the item goes away, and the panel
+    // unmounts. The failure must not vanish with it, so it falls back to the row.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
+    })
+    const view: MessageFeedbackView = {
+      status: 'ready',
+      items: new Map([[MSG, item({ rating: 'positive' })]]),
+      error: null,
+    }
+    let notify = (): void => {}
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore((cb) => { notify = cb; return () => {} }, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+
+    // The retract lands first, then the save rejects.
+    view.items = new Map()
+    notify()
+    release()
+    await gate
+
+    await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
+    expect(ui.queryByRole('dialog')).toBeNull()
+  })
+
+  it('ignores a save that resolves after its editing session ended', async () => {
+    // Closing and reopening starts a new session. A late success from the old
+    // one must not shut the panel the human just opened.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: true as const }) }
+    })
+    const view: MessageFeedbackView = {
+      status: 'ready',
+      items: new Map([[MSG, item({ rating: 'positive' })]]),
+      error: null,
+    }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+
+    // Abandon that session and start another before the save lands.
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    release()
+    await gate
+    // Flush the `.then` continuation and the render it would cause. Asserted
+    // directly rather than through `waitFor`, which would retry past a panel
+    // that the stale result closed.
+    await act(async () => { await Promise.resolve() })
+
+    expect(ui.getByRole('dialog')).toBeTruthy()
+    // The reply is discarded, but the request is no longer in flight, so the
+    // controls must not stay disabled: `pending` gates the rating buttons and
+    // Save, and leaving it set locks this message's row until it remounts.
+    expect(ui.getByLabelText(zh['action.likeActive']).hasAttribute('disabled')).toBe(false)
+    expect(ui.getByLabelText(zh['action.dislike']).hasAttribute('disabled')).toBe(false)
+    expect(ui.getByText(zh['note.save']).hasAttribute('disabled')).toBe(false)
+  })
+
+  it('reports a save that fails after the human closed the panel', async () => {
+    // A slow save that rejects once the panel is gone must not be swallowed:
+    // the human would otherwise believe the note was stored. With no panel to
+    // show it in, the row carries the notice.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => {
+        resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
+      }
+    })
+    const view: MessageFeedbackView = {
+      status: 'ready',
+      items: new Map([[MSG, item({ rating: 'positive' })]]),
+      error: null,
+    }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+
+    // Walk away before the reply lands, and leave it closed.
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(ui.queryByRole('dialog')).toBeNull()
+
+    release()
+    await gate
+    await act(async () => { await Promise.resolve() })
+
+    expect(ui.getByText(zh['error.generic'])).toBeTruthy()
+  })
+
+  it('does not write an abandoned session\'s failure into a reopened panel', async () => {
+    // The old request rejects after the panel was closed and reopened, so the
+    // new session owns the panel. Its draft was not the one that failed, so the
+    // stale error must not be shown there; it belongs to the abandoned session.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => {
+        resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
+      }
+    })
+    const view: MessageFeedbackView = {
+      status: 'ready',
+      items: new Map([[MSG, item({ rating: 'positive' })]]),
+      error: null,
+    }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+
+    fireEvent.click(ui.getByText(zh['note.open']))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+
+    // Abandon that session and start another before the save rejects; unlike
+    // the closed-and-left case, a new panel is now on screen.
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(ui.getByText(zh['note.open']))
+    expect(ui.getByRole('dialog')).toBeTruthy()
+
+    release()
+    await gate
+    await act(async () => { await Promise.resolve() })
+
+    // The stale failure names a draft the new session never sent, so it stays
+    // out of the reopened panel's status area.
+    expect(ui.queryByText(zh['error.generic'])).toBeNull()
+    expect(ui.getByRole('dialog')).toBeTruthy()
+  })
+
+  it('resyncs an untouched reopened draft to the note that just committed', async () => {
+    // The reopened session seeded from the note as it read before the save
+    // committed, so an untouched draft would show stale text and the next save
+    // could overwrite what just landed.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: true as const }) }
+    })
+    const view: MessageFeedbackView = {
+      status: 'ready',
+      items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
+      error: null,
+    }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+
+    fireEvent.click(ui.getByText('old'))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+
+    // Close and reopen before the save lands: the new draft is seeded from the
+    // still-stale stored note.
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(ui.getByText('old'))
+    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old')
+
+    release()
+    await gate
+    await act(async () => { await Promise.resolve() })
+
+    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('saved text')
+  })
+
+  it('leaves a reopened draft alone once the human has edited it', async () => {
+    // The opposite arm: an edited draft belongs to the human, so a late save
+    // must not overwrite what they are typing.
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: true as const }) }
+    })
+    const view: MessageFeedbackView = {
+      status: 'ready',
+      items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
+      error: null,
+    }
+    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
+      useSyncExternalStore(() => () => {}, () => select(view))) as never
+    const props = {
+      messageId: MSG,
+      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      rate: vi.fn(() => gate),
+      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      useFeedback,
+      t,
+    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
+    const ui = render(<MessageFeedbackActions {...props} />)
+
+    fireEvent.click(ui.getByText('old'))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
+    fireEvent.click(ui.getByText(zh['note.save']))
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+    fireEvent.click(ui.getByText('old'))
+    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'my new words' } })
+
+    release()
+    await gate
+    await act(async () => { await Promise.resolve() })
+
+    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('my new words')
+  })
 })

+ 93 - 0
packages/client/ui-message-feedback/tests/styles.client.spec.ts

@@ -0,0 +1,93 @@
+/**
+ * Feedback controls stylesheet contract, asserted against the CSS text on disk.
+ *
+ * A `--dsw-*` name the theme never declares fails silently, and for this sheet
+ * it failed loudly in the product: `border`, `background`, and the primary
+ * button's fill and label each named a token that does not exist, so every one
+ * of those declarations was invalid at computed-value time and dropped. The
+ * note editor shipped with no border and no surface, and its Save button with
+ * neither fill nor readable label. Nothing downstream reports this — the sheet
+ * parses, the classes attach, and the DOM snapshots are unchanged.
+ *
+ * The editor is a popover portaled to `document.body` and fixed-positioned
+ * from the note trigger's rect, so it never enters the IconActions row's flex
+ * layout at all — the row keeps its single 28px line of icons and the note
+ * trigger, and no wrapping (`flex-wrap`) or `order` is needed for it. The
+ * width-independent half of that contract is asserted here (the panel is a
+ * fixed portal, not an inline flex item); the resulting geometry is measured
+ * in a real engine by `apps/web/tests/message-feedback-layout`.
+ */
+import { readdirSync, readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const css = readFileSync(
+  fileURLToPath(new URL('../src/client/MessageFeedbackActions.module.css', import.meta.url)),
+  'utf8',
+)
+// The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
+// stay on the source plane rather than needing a build. Every theme sheet, not
+// just the platform tokens: font and scrollbar variables are declared in
+// siblings, and a gate reading one file would call their names undeclared.
+const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
+  .filter(name => name.endsWith('.css'))
+  .map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
+  .join('\n')
+
+/**
+ * The declarations of one top-level rule, by selector.
+ * @param selector - the class selector to read, including its leading dot.
+ * @returns the rule's declaration text.
+ */
+function block(selector: string): string {
+  const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
+  if (match === null) throw new Error(`MessageFeedbackActions.module.css has no \`${selector}\` rule`)
+  return match[1] ?? ''
+}
+
+describe('MessageFeedbackActions theme styles', () => {
+  it('names only theme variables the token sheet defines', () => {
+    // The regression that motivated this file. An undeclared custom property
+    // has no fallback and does not inherit a usable value: the entire
+    // declaration is thrown away, so the control renders as if the line had
+    // never been written. Every theme-variable prefix the sheets actually use,
+    // not just `--dsw-`: a `--dsh-` name reads as a plausible sibling and would
+    // otherwise slip past into an invalid declaration.
+    const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
+    // Vacuity guard: the sheet has to actually name tokens, or the filter below
+    // is satisfied by an empty list and this test proves nothing.
+    expect(named.length).toBeGreaterThan(5)
+    const undeclared = [...new Set(named)].filter(name => !tokens.includes(`  ${String(name)}:`))
+    expect(undeclared).toEqual([])
+  })
+
+  it('never falls back to a literal colour', () => {
+    // A token that resolves is never the problem; an undeclared one takes this
+    // branch, and a literal here is a single colour for both themes.
+    expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
+  })
+
+  it('keeps the note editor out of the row as a fixed portal, not a flex item', () => {
+    // The editor is a popover portaled to document.body, so the IconActions row
+    // never has to grow or wrap around it. Fixed positioning comes from the
+    // placement code (inline `left`/`top`), not a class, so only `position:
+    // fixed` and the elevated surface live in the sheet — plus the absence of a
+    // flex rule on the panel, which would resurrect the row-overflow defect an
+    // inline editor had. The row stays one 28px line, so a fixed `width` on the
+    // panel is fine (it floats, it does not compete for row space).
+    expect(block('.notePanel')).toMatch(/position:\s*fixed/)
+    // The panel flex-sets its own children (textarea over buttons), which is
+    // fine. What must be absent is the flex-SIZING that made an inline editor a
+    // row item: grow/shrink/basis (or the `flex:` shorthand) would let it rejoin
+    // the IconActions layout, resurrecting the overflow defect.
+    expect(block('.notePanel')).not.toMatch(/flex-(?:grow|shrink|basis)\s*:/)
+    expect(block('.notePanel')).not.toMatch(/^\s*flex\s*:/m)
+  })
+
+  it('closes every block, so no rule is swallowed by the one above it', () => {
+    // A missing `}` is not a parse error: every rule after it silently becomes
+    // part of the block above, and the controls would paint unstyled.
+    const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
+    expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
+  })
+})

+ 17 - 8
packages/client/ui-model-selection/src/client/ModelSelect.module.css

@@ -11,7 +11,10 @@
   align-items: center;
   gap: 4px;
   min-width: 0;
+  /* Legacy fallback, then a row-relative cap that grants long names more
+     room without letting this trailing control consume the composer. */
   max-width: 220px;
+  max-width: min(360px, 45cqw);
   height: 28px;
   padding: 0 4px 0 8px;
   border: none;
@@ -69,7 +72,11 @@
   z-index: 20;
   display: flex;
   flex-direction: column;
-  width: min(240px, calc(100vw - 32px));
+  /* Let the visible pane's rows set the card width, but keep pathological ids
+     and narrow screens inside a bounded card where ellipsis remains. */
+  width: max-content;
+  min-width: min(240px, calc(100vw - 32px));
+  max-width: min(420px, calc(100vw - 32px));
   max-height: min(360px, calc(100vh - 96px));
   overflow: hidden;
   padding: 4px;
@@ -150,10 +157,12 @@
 }
 
 .option {
+  box-sizing: border-box;
   display: flex;
   align-items: center;
   gap: 8px;
-  width: 100%;
+  width: auto;
+  min-width: 100%;
   min-height: 38px;
   padding: 6px 8px;
   border: none;
@@ -218,10 +227,12 @@
    padding, 8px gap, 10px radius; 14/22 label in primary, value in the
    #81858C tertiary tone, right chevron drilling into the sub-list. */
 .cell {
+  box-sizing: border-box;
   display: flex;
   align-items: center;
   gap: 8px;
-  width: 100%;
+  width: auto;
+  min-width: 100%;
   height: 40px;
   padding: 0 10px;
   border: none;
@@ -239,19 +250,17 @@
 }
 
 .cellLabel {
-  flex: 1 1 auto;
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
+  flex: 0 0 auto;
   white-space: nowrap;
 }
 
 .cellValue {
-  flex: 0 1 auto;
+  flex: 1 1 auto;
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  text-align: right;
   color: var(--dsw-alias-label-tertiary);
 }
 

+ 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: 9a5b33e6b2ecae0652d640d2a6927e3f1d05b1a1
-README.zh.md: a94935235790504bbeb7f5091e8c8fba86e1c903
+README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9
+README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock.
+Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), the `useAnchoredPosition` hook that holds a fixed-position floating panel under its anchor (measure, offset, clamp inside the viewport margin, re-placed on capture-phase scroll, window resize, and the panel's own size changes), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock.
 
 ## Hover cards
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。
+纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、`useAnchoredPosition` 钩子(让固定定位的浮动面板跟住锚点:测量、偏移、按视口边距钳制,并在捕获阶段滚动、窗口缩放与面板自身尺寸变化时重新定位)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。
 
 ## 悬浮卡片
 

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -13,6 +13,8 @@ export { Input } from './Input.tsx'
 export { Menu } from './Menu.tsx'
 export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
 export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
+export { useAnchoredPosition } from './useAnchoredPosition.ts'
+export type { AnchoredPositionOptions } from './useAnchoredPosition.ts'
 export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
 export { HoverCard } from './HoverCard.tsx'
 export { Modal } from './Modal.tsx'

+ 81 - 0
packages/client/ui-primitives/src/useAnchoredPosition.ts

@@ -0,0 +1,81 @@
+/**
+ * Keep a fixed-position floating element anchored to a trigger.
+ *
+ * A portaled panel is positioned from its anchor's viewport rect, which stops
+ * being true the moment anything scrolls or the window resizes. This owns that
+ * one concern: measure the anchor, offset the panel below it, clamp the result
+ * inside the viewport, and re-run on scroll (capture phase, so scrollers nested
+ * inside the page are caught too), on resize, and on the panel's own size
+ * changes while the element is open.
+ * @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition
+ */
+
+import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
+
+/** Inputs for {@link useAnchoredPosition}. */
+export interface AnchoredPositionOptions {
+  /** Whether the floating element is mounted and should track its anchor. */
+  open: boolean
+  /** The element the panel is placed from. */
+  anchorRef: RefObject<HTMLElement | null>
+  /** The floating element, measured so the clamp uses real dimensions. */
+  panelRef: RefObject<HTMLElement | null>
+  /** Distance kept between the anchor's bottom edge and the panel's top. */
+  gap: number
+  /** Distance kept between the panel and each viewport edge. */
+  margin: number
+}
+
+/**
+ * Track an anchor and return the panel's fixed coordinates.
+ * @param options - the open state, the two refs, and the gap/margin distances.
+ * @returns `left`/`top` for the panel, or `null` before the first measurement.
+ */
+export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
+  const { open, anchorRef, panelRef, gap, margin } = options
+  const [position, setPosition] = useState<CSSProperties | null>(null)
+  useLayoutEffect(() => {
+    if (!open) {
+      setPosition(null)
+      return
+    }
+    const place = () => {
+      /* v8 ignore start -- geometry read from real layout: jsdom reports zero
+         offset sizes, so the positive-size clamp arms are exercised by browser
+         scenarios rather than unit tests. */
+      const rect = anchorRef.current?.getBoundingClientRect()
+      if (rect === undefined) return
+      const panel = panelRef.current
+      const width = panel?.offsetWidth ?? 0
+      const height = panel?.offsetHeight ?? 0
+      let left = rect.left
+      let top = rect.bottom + gap
+      if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
+      if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
+      /* v8 ignore stop */
+      setPosition({ left, top })
+    }
+    // The first run measures the panel in the same commit that opened it, so
+    // the clamp uses real dimensions before anything paints.
+    place()
+    window.addEventListener('scroll', place, true)
+    window.addEventListener('resize', place)
+    // The panel's own height changes without either event — a status line
+    // appearing inside it, or a `resize: vertical` textarea dragged taller —
+    // and a stale clamp would let a panel near the bottom edge cross the
+    // margin it is supposed to respect. The guard keeps the hook usable where
+    // `ResizeObserver` is absent, which is how jsdom runs.
+    const panel = panelRef.current
+    let observer: ResizeObserver | null = null
+    if (typeof ResizeObserver !== 'undefined' && panel !== null) {
+      observer = new ResizeObserver(place)
+      observer.observe(panel)
+    }
+    return () => {
+      observer?.disconnect()
+      window.removeEventListener('scroll', place, true)
+      window.removeEventListener('resize', place)
+    }
+  }, [open, anchorRef, panelRef, gap, margin])
+  return position
+}

+ 111 - 0
packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx

@@ -0,0 +1,111 @@
+// @vitest-environment jsdom
+/**
+ * `useAnchoredPosition` wiring: a floating panel is placed from its anchor and
+ * keeps tracking it while open.
+ *
+ * The geometry itself needs real layout, which jsdom does not provide — the
+ * browser layout scenario in `apps/web/tests/message-feedback-layout.e2e.ts`
+ * owns that. What is asserted here is the wiring the clamp depends on: the
+ * listeners and the panel-size observer are attached while open and released on
+ * close, a size change replays the placement, and the hook still works where
+ * `ResizeObserver` does not exist.
+ */
+import { useRef } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { useAnchoredPosition } from '../src/useAnchoredPosition.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+
+/** One recorded `ResizeObserver` instance, so a test can drive its callback. */
+interface Recorded {
+  callback: ResizeObserverCallback
+  observed: Element[]
+  disconnected: boolean
+}
+
+/**
+ * Install a recording `ResizeObserver` double.
+ * @returns the list every constructed observer registers itself in.
+ */
+function stubResizeObserver(): Recorded[] {
+  const made: Recorded[] = []
+  vi.stubGlobal('ResizeObserver', class {
+    private readonly record: Recorded
+
+    constructor(callback: ResizeObserverCallback) {
+      this.record = { callback, observed: [], disconnected: false }
+      made.push(this.record)
+    }
+
+    observe(element: Element) { this.record.observed.push(element) }
+    disconnect() { this.record.disconnected = true }
+  })
+  return made
+}
+
+/**
+ * Host component that anchors a panel and reports the computed position.
+ * @param props - whether the panel is open.
+ * @returns the anchor and, while open, the panel carrying the position.
+ */
+function Host({ open }: { open: boolean }) {
+  const anchorRef = useRef<HTMLButtonElement>(null)
+  const panelRef = useRef<HTMLDivElement>(null)
+  const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 })
+  return (
+    <>
+      <button ref={anchorRef} type="button">anchor</button>
+      {open && <div ref={panelRef} data-testid="panel" style={position ?? { visibility: 'hidden' }} />}
+    </>
+  )
+}
+
+describe('useAnchoredPosition', () => {
+  it('observes the panel while open and disconnects when it closes', () => {
+    const made = stubResizeObserver()
+    const ui = render(<Host open />)
+
+    expect(made).toHaveLength(1)
+    expect(made[0]?.observed).toEqual([ui.getByTestId('panel')])
+    expect(made[0]?.disconnected).toBe(false)
+
+    ui.rerender(<Host open={false} />)
+
+    expect(made[0]?.disconnected).toBe(true)
+  })
+
+  it('replaces the panel when its own size changes', () => {
+    const made = stubResizeObserver()
+    render(<Host open />)
+    const before = made[0]?.callback
+    expect(before).toBeDefined()
+
+    // A status line appearing inside the panel, or a dragged textarea, changes
+    // the height without a scroll or resize event; the observer is the only
+    // thing that notices, so driving its callback must not throw.
+    expect(() => { before?.([], {} as ResizeObserver) }).not.toThrow()
+  })
+
+  it('still places the panel where ResizeObserver does not exist', () => {
+    // jsdom's own condition, and any host without the API: the hook must fall
+    // back to scroll and resize rather than fail at mount.
+    vi.stubGlobal('ResizeObserver', undefined)
+
+    expect(() => render(<Host open />)).not.toThrow()
+  })
+
+  it('attaches no listeners while the element is closed', () => {
+    const made = stubResizeObserver()
+    const add = vi.spyOn(window, 'addEventListener')
+
+    render(<Host open={false} />)
+
+    expect(made).toHaveLength(0)
+    expect(add.mock.calls.filter(([type]) => type === 'scroll' || type === 'resize')).toEqual([])
+    add.mockRestore()
+  })
+})

+ 2 - 2
packages/client/ui-tool/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-tool/README.md
-README.md: 6e2bef2f5ad4b136510c3acbb8f8b8e83c4c9212
-README.zh.md: 169417747541db9e02cb552b175ca7c100420aeb
+README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31
+README.zh.md: 3bae0b3f4cb3ad695371ec7a66fb531a935a4d2f

+ 1 - 1
packages/client/ui-tool/README.md

@@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () =>
   }, BusinessToolRow))
 ```
 
-The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
+The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
 
 This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`.
 

+ 1 - 1
packages/client/ui-tool/README.zh.md

@@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () =>
   }, BusinessToolRow))
 ```
 
-owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`、`inspect` 回调。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。
+owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。
 
 本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
 

+ 2 - 0
packages/client/ui-tool/package.json

@@ -32,6 +32,7 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-runtime",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation"
@@ -50,6 +51,7 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",

Some files were not shown because too many files changed in this diff