Sfoglia il codice sorgente

Merge branch 'master' into turtle/master-ci-browser-realpath

Turtle 1 settimana fa
parent
commit
b576419a4c
100 ha cambiato i file con 563 aggiunte e 286 eliminazioni
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
  2. 4 4
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
  3. 4 4
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml
  5. 4 3
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
  6. 4 3
      .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml
  8. 25 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md
  9. 25 0
      .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md
  10. 2 2
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml
  11. 2 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md
  12. 2 0
      .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md
  13. 2 3
      .github/workflows/ci-master.yml
  14. 1 1
      apps/cli/package.json
  15. 1 1
      apps/desktop-host/package.json
  16. 1 1
      apps/desktop/package.json
  17. 1 1
      apps/web/package.json
  18. 1 1
      apps/web/tests/built-boot.expected.e2e.ts
  19. 9 4
      apps/web/tests/feedback-release.e2e.ts
  20. 25 14
      apps/web/tests/message-feedback.e2e.ts
  21. 67 0
      apps/web/tests/present.e2e.ts
  22. 31 0
      apps/web/tests/produced-files.e2e.ts
  23. 1 1
      apps/web/tests/question-composer.e2e.ts
  24. 2 2
      docs/subsystems/feedback.i18n.yaml
  25. 3 3
      docs/subsystems/feedback.md
  26. 3 3
      docs/subsystems/feedback.zh.md
  27. 1 1
      package.json
  28. 1 1
      packages/acp/acp/package.json
  29. 1 1
      packages/api/gateway/package.json
  30. 1 1
      packages/api/remotes/package.json
  31. 1 1
      packages/api/session-controller/package.json
  32. 1 1
      packages/api/settings-controller/package.json
  33. 1 1
      packages/api/workspace-controller/package.json
  34. 1 1
      packages/api/workspace-files/package.json
  35. 1 1
      packages/attachment/attachment-local/package.json
  36. 1 1
      packages/attachment/attachment/package.json
  37. 1 1
      packages/boot/app-boot/package.json
  38. 1 1
      packages/boot/cmdline/package.json
  39. 1 1
      packages/bundle/acp-app/package.json
  40. 1 1
      packages/bundle/base/package.json
  41. 1 1
      packages/bundle/headless/package.json
  42. 1 1
      packages/bundle/sdk-app/package.json
  43. 1 1
      packages/bundle/sdk-minimal/package.json
  44. 1 1
      packages/bundle/web-app/package.json
  45. 2 2
      packages/client/README.i18n.yaml
  46. 1 1
      packages/client/README.md
  47. 1 1
      packages/client/README.zh.md
  48. 1 1
      packages/client/connection/package.json
  49. 1 1
      packages/client/file-upload/package.json
  50. 1 1
      packages/client/hmr/package.json
  51. 1 1
      packages/client/locale/package.json
  52. 1 1
      packages/client/modules/package.json
  53. 1 1
      packages/client/resources/package.json
  54. 1 1
      packages/client/store/package.json
  55. 1 1
      packages/client/ui-agent-preset/package.json
  56. 1 1
      packages/client/ui-approval/package.json
  57. 1 1
      packages/client/ui-attachment/package.json
  58. 1 1
      packages/client/ui-brand-official/package.json
  59. 2 2
      packages/client/ui-chat/README.i18n.yaml
  60. 6 0
      packages/client/ui-chat/README.md
  61. 6 0
      packages/client/ui-chat/README.zh.md
  62. 1 1
      packages/client/ui-chat/package.json
  63. 3 0
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
  64. 16 0
      packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts
  65. 1 1
      packages/client/ui-commands/package.json
  66. 1 1
      packages/client/ui-conversation/package.json
  67. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  68. 2 2
      packages/client/ui-deliverables/README.md
  69. 2 2
      packages/client/ui-deliverables/README.zh.md
  70. 1 1
      packages/client/ui-deliverables/package.json
  71. 12 9
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  72. 4 1
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  73. 1 1
      packages/client/ui-deliverables/src/client/PresentedFileCard.tsx
  74. 1 1
      packages/client/ui-deliverables/src/client/ProducedFiles.module.css
  75. 11 2
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  76. 24 0
      packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts
  77. 1 1
      packages/client/ui-directory-picker-browse/package.json
  78. 1 1
      packages/client/ui-directory-picker-native/package.json
  79. 1 1
      packages/client/ui-dockkit/package.json
  80. 1 1
      packages/client/ui-goal/package.json
  81. 1 1
      packages/client/ui-input-trigger/package.json
  82. 1 1
      packages/client/ui-jobs/package.json
  83. 1 1
      packages/client/ui-layout/package.json
  84. 2 2
      packages/client/ui-message-feedback/README.i18n.yaml
  85. 5 6
      packages/client/ui-message-feedback/README.md
  86. 5 6
      packages/client/ui-message-feedback/README.zh.md
  87. 2 2
      packages/client/ui-message-feedback/package.json
  88. 0 8
      packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css
  89. 22 7
      packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx
  90. 16 30
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx
  91. 8 18
      packages/client/ui-message-feedback/src/client/controller.ts
  92. 16 9
      packages/client/ui-message-feedback/src/client/dialog.ts
  93. 5 5
      packages/client/ui-message-feedback/src/client/index.ts
  94. 2 2
      packages/client/ui-message-feedback/src/client/locales.ts
  95. 11 11
      packages/client/ui-message-feedback/src/client/slots.ts
  96. 3 3
      packages/client/ui-message-feedback/src/client/surface.ts
  97. 18 20
      packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx
  98. 29 15
      packages/client/ui-message-feedback/tests/controller.client.spec.ts
  99. 27 13
      packages/client/ui-message-feedback/tests/dialog.client.spec.ts
  100. 26 8
      packages/client/ui-message-feedback/tests/feedback-dialog.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
-2026-09-08-feedback-dialog-and-categories.md: 02a41f08541cca85dad7cd3c2c984b5e7cd8fd6d
-2026-09-08-feedback-dialog-and-categories.zh.md: 40d83360e52bff569120820b78959a7cc3f4db4f
+2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31
+2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0

+ 4 - 4
.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md

@@ -12,9 +12,9 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe
 
 `command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit.
 
-`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback <text>` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it.
+`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback <text>` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor.
 
-The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code.
+The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure.
 
 ## Alternatives considered
 
@@ -24,9 +24,9 @@ The dialog is the shared Modal card at the design's width; the design's checkbox
 
 **Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs.
 
-**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike.
+**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves both message-rating paths and the Session dialog.
 
-**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition.
+**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; the File row uses the same `action` kind, so one definition serves both entries.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md

@@ -12,9 +12,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`
 
 `command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。
 
-`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback <text>` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回
+`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback <text>` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器
 
-弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开
+弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误
 
 ## 考虑过的替代方案
 
@@ -24,9 +24,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`
 
 **在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。
 
-**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径
+**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务两种消息评分路径与 Session 弹窗
 
-**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可
+**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;「文件」行使用同一个 `action` 种类,一份定义即可服务两个条目
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md
-2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488
-2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef
+2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f
+2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2

+ 4 - 3
.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md

@@ -14,11 +14,11 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh
 
 `FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`.
 
-Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
+Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name.
 
 `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change.
 
-Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
+Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces.
 
 ## Alternatives considered
 
@@ -32,12 +32,13 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac
 
 ## Testing
 
-The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
+The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative.
 
 ## Consequences
 
 - Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic.
 - A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes.
 - Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override.
+- The fixed 48-entry artwork table adds about 35 kB uncompressed and 17 kB gzip to the shared browser bundle; adding categories must justify that static baseline cost.
 - The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`.
 - The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust.

+ 4 - 3
.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md

@@ -14,11 +14,11 @@ Status: implemented
 
 `FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。
 
-已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
+已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。
 
 `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。
 
-附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
+附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。
 
 ## 备选方案
 
@@ -32,12 +32,13 @@ Status: implemented
 
 ## 测试
 
-`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
+`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。
 
 ## 后果
 
 - 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。
 - 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。
 - 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。
+- 固定的 48 项图稿表为共享浏览器 bundle 增加约 35 kB 未压缩体积和 17 kB gzip 体积;新增类别必须证明这份静态基线成本是必要的。
 - primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。
 - 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md
+2026-09-10-symmetric-message-feedback-submission.md: 51c754dbd3ecc566e15ec1b7a276fc2a3cf7c551
+2026-09-10-symmetric-message-feedback-submission.zh.md: 0ba5d1a1c53f08b76c5bbc32b84687c3dcd38ec2

+ 25 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md

@@ -0,0 +1,25 @@
+# Agent Note: Symmetric message feedback submission
+
+Status: implemented
+
+English | [中文](2026-09-10-symmetric-message-feedback-submission.zh.md)
+
+## Problem
+
+The assistant-message rating controls used different commit points. Like recorded a positive rating immediately, while Dislike opened the feedback dialog and recorded only after Submit. The asymmetry made an accidental Like durable before confirmation and prevented positive feedback from carrying the same optional category and description as negative feedback.
+
+## Decision
+
+The [feedback dialog and categories](2026-09-08-feedback-dialog-and-categories.md) decision owns the shared form and durable taxonomy. Both unrecorded ratings open the shared feedback dialog and record only after Submit. A message `FeedbackDialogTarget` carries the selected `positive` or `negative` rating, and `FeedbackSurface` passes that rating with the dialog entry to `MessageFeedbackController.rate`. The action row reads the committed item before either action: clicking its current rating calls the injected `retract` operation, while clicking an absent or opposite rating opens the dialog. `retract` rechecks the committed rating inside the controller's serialized mutation queue and becomes a no-op after a concurrent change, so it cannot turn stale UI intent into a bare rating put. The dialog remains optional-input: submitting without a category or description records the selected rating and raises the acknowledgement toast; dismissing it records nothing.
+
+## Alternatives considered
+
+**Keep Like as an immediate action.** This preserves one fewer click for positive feedback, but keeps two submission models beside each other and prevents positive reports from carrying context.
+
+**Require the dialog to retract a recorded rating.** Retraction has no category or description to collect, and an extra confirmation would make the existing undo action less direct.
+
+**Create separate positive and negative forms.** The fields, validation, failures, and acknowledgement are identical; carrying the rating in the existing target keeps one draft and submission lifecycle.
+
+## Consequences
+
+Neither rating creates a `feedback/message-put` event until the user submits the dialog. Positive and negative records can both include a category and note, while clicking the active rating continues to create `feedback/message-delete` without opening the dialog. Unit coverage pins the shared action path and target routing, and the keyless Web scenarios submit both ratings through the real dialog before checking durable events and telemetry release.

+ 25 - 0
.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 对称消息反馈提交
+
+Status: implemented
+
+[English](2026-09-10-symmetric-message-feedback-submission.md) | 中文
+
+## 问题
+
+助手消息的两个评分控件使用不同的提交时点。点赞会立即记录正面评分,点踩则先打开反馈弹窗,仅在用户提交后记录。这种不对称会让误触点赞在确认前就持久化,也让正面反馈无法携带与负面反馈相同的可选分类和描述。
+
+## 决策
+
+[反馈弹窗与分类](2026-09-08-feedback-dialog-and-categories.zh.md)决策负责共用表单和持久化分类表。两种未记录的评分都打开共用反馈弹窗,并且只在提交后记录。消息 `FeedbackDialogTarget` 携带所选的 `positive` 或 `negative` 评分,`FeedbackSurface` 将该评分与弹窗条目一起传给 `MessageFeedbackController.rate`。动作行会在任一操作前读取已提交条目:点击当前评分会调用注入的 `retract` 操作,点击未记录或相反评分则打开弹窗。`retract` 会在控制器的串行变更队列内重新检查已提交评分,并在并发变更后变为无操作,因此不会把陈旧的 UI 意图转成裸评分 put。弹窗的输入仍可全部留空:不选分类也不填描述时提交会记录所选评分并弹出确认 toast;关闭弹窗不会记录任何内容。
+
+## 考虑过的替代方案
+
+**保留点赞立即提交。** 这能让正面反馈少一次点击,但会让并列的两个评分继续使用不同提交模式,也无法让正面反馈携带上下文。
+
+**撤回已记录评分也必须经过弹窗。** 撤回没有需要收集的分类或描述,多一次确认会让现有撤销操作变得不够直接。
+
+**分别创建正面和负面表单。** 两者的字段、校验、失败处理与确认完全相同;在现有目标中携带评分即可共用一套草稿与提交生命周期。
+
+## 后果
+
+用户提交弹窗前,两种评分都不会创建 `feedback/message-put` 事件。正面与负面记录都可以包含分类和备注,而点击当前评分仍会直接创建 `feedback/message-delete`,无需打开弹窗。单元测试固定共用动作路径与目标路由,无密钥 Web 场景则通过真实弹窗提交两种评分,再检查持久事件与遥测投递。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.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/testing/2026-09-10-hosted-image-test-assumptions.md
-2026-09-10-hosted-image-test-assumptions.md: e6cd1ef37dce318920d75545f816006a98989076
-2026-09-10-hosted-image-test-assumptions.zh.md: e60910aebdc2952a2bfb6ab25963a973ea41ac87
+2026-09-10-hosted-image-test-assumptions.md: 5cf3b14503162d67d0fbb27743228cb1a8d7ffe2
+2026-09-10-hosted-image-test-assumptions.zh.md: db82566c11b50b9203d30f7be6aec1378512f94b

+ 2 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md

@@ -20,6 +20,8 @@ Terminal cases that drive a mocked PTY exit pin the containment they need (`inte
 
 Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
 
+The stray-output sealing test in `packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: [run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies.
+
 The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host.
 
 The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption.

+ 2 - 0
.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md

@@ -20,6 +20,8 @@ Status: implemented
 
 `packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
 
+`packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:[run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。
+
 Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。
 
 Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。

+ 2 - 3
.github/workflows/ci-master.yml

@@ -249,10 +249,9 @@ jobs:
       - name: Configure persistent pnpm store
         shell: pwsh
         # The store must share the ReFS workspace volume for the clone
-        # import method below; LOCALAPPDATA (C:) would cross volumes and
-        # break block clone. See 2026-08-30-windows-refs-store-block-clone-install.
+        # import method below. Runner workspaces can reside on different drives.
         run: |
-          $storeRoot = "F:\.pnpm-store"
+          $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store'
           echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
 
       - name: Install (immutable)

+ 1 - 1
apps/cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
apps/desktop-host/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-desktop-host",
   "description": "Private upstream-Node host process for the Electron desktop application",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "private": true,
   "license": "MIT",
   "type": "module",

+ 1 - 1
apps/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-desktop",
   "description": "Electron desktop shell for a bundled dsh runtime and external plugins",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "private": true,
   "license": "MIT",
   "type": "module",

+ 1 - 1
apps/web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-frontend",
   "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
apps/web/tests/built-boot.expected.e2e.ts

@@ -101,7 +101,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
   // Skip the resident fixture's three questions, then resolve its approval so
   // the ordinary composer bar (which owns ContextMeter) resumes.
   for (let index = 0; index < 3; index += 1) {
-    fireEvent.click(await screen.findByRole('button', { name: 'Skip this question' }))
+    fireEvent.click(await screen.findByRole('button', { name: 'Skip' }))
   }
   fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
 

+ 9 - 4
apps/web/tests/feedback-release.e2e.ts

@@ -218,12 +218,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     const like = page.getByRole('button', { name: 'Good response' })
     await like.hover()
     await like.click()
+    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
+    await dialog.getByRole('button', { name: 'Instruction understanding and following', exact: true }).click()
+    await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Clear and complete.')
+    expect(captured()).toHaveLength(releasedCount)
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await expect.poll(() => dialog.count()).toBe(0)
     const rated = page.getByRole('button', { name: 'Remove rating' })
     await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true')
     await expectFeedbackRelease('feedback/message-put', 1)
-    // Dislike collects the category and note in the dialog; typing releases nothing.
+    // The second rating uses the same dialog; typing releases nothing.
     await page.getByRole('button', { name: 'Bad response' }).click()
-    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
     await dialog.getByRole('button', { name: 'Task result', exact: true }).click()
     await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.')
     expect(captured()).toHaveLength(releasedCount)
@@ -242,7 +247,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
       { data: { text: 'the second remark' } },
     ])
     expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([
-      { data: { sessionId, item: { rating: 'positive' } } },
+      { data: { sessionId, item: { rating: 'positive', note: 'Clear and complete.', category: 'instruction-following' } } },
       { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } },
     ])
     expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }])
@@ -251,7 +256,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     expect(captured()).toHaveLength(releasedCount)
     const wire = uploads.join('\n')
     for (const text of ['the diff view is unreadable', 'the second remark',
-      'Read both files before answering.']) expect(wire).toContain(text)
+      'Clear and complete.', 'Read both files before answering.']) expect(wire).toContain(text)
     const feedback = events.flatMap<Record<string, string | undefined>>((event) => {
       switch (event.type) {
         case 'feedback/record': return [{ type: event.type, text: event.data.text }]

+ 25 - 14
apps/web/tests/message-feedback.e2e.ts

@@ -1,8 +1,8 @@
 // Keyless browser regression for durable per-message feedback. Cold-seeds a
-// settled two-turn transcript (zero model calls), likes one assistant message
-// and sees the acknowledgement, replaces the Like through the Dislike dialog
-// with a category and a note, proves the judgment survives a full page reload
-// from the Host's canonical log, then retracts it.
+// settled two-turn transcript (zero model calls), records a Like through the
+// feedback dialog, replaces it through the same dialog with a Dislike, proves
+// the judgment survives a full page reload from the Host's canonical log, then
+// retracts it.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
@@ -20,6 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'message-feedback-web-e2e'
+const POSITIVE_NOTE = 'Clear and complete.'
 const NOTE = 'Read both files before answering.'
 
 describe('web e2e: durable per-message feedback', () => {
@@ -58,7 +59,7 @@ describe('web e2e: durable per-message feedback', () => {
     await sessionRow.click()
   }
 
-  it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => {
+  it.skipIf(MODE === 'record')('submits both ratings through the dialog, persists the Dislike, then retracts it', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback'))
     await openSeededSession()
 
@@ -71,21 +72,29 @@ describe('web e2e: durable per-message feedback', () => {
     await like.scrollIntoViewIfNeeded()
     await like.hover()
     await like.click()
-    // A Like records at once and is acknowledged; a recorded rating relabels
-    // the button to what the next click would do.
+    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Stability and speed', exact: true }).click()
+    await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(POSITIVE_NOTE)
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
     await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 })
     const rated = page.getByRole('button', { name: 'Remove rating' }).first()
     await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true')
 
-    // Dislike opens the Session's feedback dialog; its submission replaces
-    // the Like with a negative judgment carrying the category and note.
+    // The same dialog records a negative judgment with its own category and
+    // note, replacing the positive judgment only after submission.
     await page.getByRole('button', { name: 'Bad response' }).first().click()
-    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
     await dialog.waitFor({ timeout: 10_000 })
     await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder'))
       .toBe('Add details to help us improve. Your submission will include the current conversation log.')
     await dialog.getByRole('button', { name: 'Task result', exact: true }).click()
-    await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE)
+    const details = dialog.getByRole('textbox', { name: 'Feedback details' })
+    await details.fill('x'.repeat(8193))
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await page.getByRole('alert').filter({ hasText: 'The description is too long' }).waitFor({ timeout: 10_000 })
+    expect(await dialog.count()).toBe(1)
+    await details.fill(NOTE)
     await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
     await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating')
@@ -116,9 +125,11 @@ describe('web e2e: durable per-message feedback', () => {
     await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false')
     const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
     if (agent === undefined) throw new Error('seeded session did not attach an agent')
-    const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1)
-    expect(put?.type === 'feedback/message-put' ? put.data.item : undefined)
-      .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' })
+    const puts = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put')
+    expect(puts.map(event => event.type === 'feedback/message-put' ? event.data.item : undefined)).toMatchObject([
+      { rating: 'positive', note: POSITIVE_NOTE, category: 'service-stability' },
+      { rating: 'negative', note: NOTE, category: 'task-result' },
+    ])
 
     // Re-clicking the active rating retracts it, and the note goes with it.
     await restored.click()

+ 67 - 0
apps/web/tests/present.e2e.ts

@@ -184,6 +184,73 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
       expect(await failed.innerText()).toContain('Delivery failed')
       expect(await delivered.innerText()).toContain('Delivered')
       await page.locator('[data-turn-process]').click()
+      const geometry = await page.evaluate(() => {
+        const requiredElement = <T extends Element>(value: T | null | undefined, name: string): T => {
+          if (value === null || value === undefined) throw new Error(`present layout is missing ${name}`)
+          return value
+        }
+        const answer = requiredElement(
+          [...document.querySelectorAll<HTMLElement>('[data-chat-flow-kind="assistant-step"]')]
+            .find(element => element.textContent?.includes('PRESENT_DONE')),
+          'final answer',
+        )
+        const presentedGrid = requiredElement(
+          document.querySelector<HTMLElement>('[data-presented-files-row]'),
+          'presented grid',
+        )
+        const presentedRoot = requiredElement(presentedGrid.parentElement, 'presented root')
+        const turnTail = requiredElement(presentedRoot.closest<HTMLElement>('[data-turn-tail]'), 'turn tail')
+        const actions = requiredElement(
+          turnTail.querySelector<HTMLButtonElement>('button[aria-label="Copy"]')?.parentElement,
+          'action row',
+        )
+        const cards = [...presentedGrid.querySelectorAll<HTMLElement>('[data-presented-file]')]
+        const report = requiredElement(
+          cards.find(card => card.textContent?.includes('report.txt')),
+          'report card',
+        )
+        const title = requiredElement(
+          report.querySelector<HTMLElement>('span[title="report.txt"]')
+            ?? [...report.querySelectorAll<HTMLElement>('span')]
+              .find(element => element.textContent === 'report.txt'),
+          'report title',
+        )
+        const description = requiredElement(report.querySelector<HTMLElement>('span[role="status"]'), 'report status')
+        const open = requiredElement(
+          report.querySelector<HTMLButtonElement>('button[aria-label="Open report.txt in sidebar"]'),
+          'report open action',
+        )
+        const icon = requiredElement(report.querySelector<SVGElement>('svg'), 'report icon')
+        const secondCard = requiredElement(cards[1], 'second card')
+        const answerRect = answer.getBoundingClientRect()
+        const presentedRect = presentedRoot.getBoundingClientRect()
+        const actionsRect = actions.getBoundingClientRect()
+        const firstCard = report.getBoundingClientRect()
+        const secondCardRect = secondCard.getBoundingClientRect()
+        const gridStyle = getComputedStyle(presentedGrid)
+        return {
+          answerToPresented: presentedRect.top - answerRect.bottom,
+          presentedToActions: actionsRect.top - presentedRect.bottom,
+          cardHeight: firstCard.height,
+          cardColumnGap: secondCardRect.left - firstCard.right,
+          gridColumnGap: gridStyle.columnGap,
+          gridRowGap: gridStyle.rowGap,
+          iconWidth: icon.getAttribute('width'),
+          titleFontSize: getComputedStyle(title).fontSize,
+          descriptionFontSize: getComputedStyle(description).fontSize,
+          openFontSize: getComputedStyle(open).fontSize,
+        }
+      })
+      expect(geometry.answerToPresented).toBeCloseTo(20, 1)
+      expect(geometry.presentedToActions).toBeCloseTo(20, 1)
+      expect(geometry.cardHeight).toBeCloseTo(60, 1)
+      expect(geometry.cardColumnGap).toBeCloseTo(10, 1)
+      expect(geometry.gridColumnGap).toBe('10px')
+      expect(geometry.gridRowGap).toBe('10px')
+      expect(geometry.iconWidth).toBe('20')
+      expect(geometry.titleFontSize).toBe('13px')
+      expect(geometry.descriptionFontSize).toBe('10px')
+      expect(geometry.openFontSize).toBe('12px')
       await page.setViewportSize({ width: 480, height: 900 })
       const row = page.locator('[data-presented-files-row]')
       await row.scrollIntoViewIfNeeded()

+ 31 - 0
apps/web/tests/produced-files.e2e.ts

@@ -171,6 +171,37 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
     expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
+    const turnSpacing = await page.evaluate((done) => {
+      const requiredElement = <T extends Element>(value: T | null | undefined, name: string): T => {
+        if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`)
+        return value
+      }
+      const answer = requiredElement(
+        [...document.querySelectorAll<HTMLElement>('[data-chat-flow-kind="assistant-step"]')]
+          .find(element => element.textContent?.includes(done)),
+        'final answer',
+      )
+      const producedRow = requiredElement(
+        document.querySelector<HTMLElement>('[data-produced-files-row]'),
+        'produced row',
+      )
+      const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root')
+      const turnTail = requiredElement(producedRoot.closest<HTMLElement>('[data-turn-tail]'), 'turn tail')
+      const actions = requiredElement(
+        turnTail.querySelector<HTMLButtonElement>('button[aria-label="Copy"]')?.parentElement,
+        'action row',
+      )
+      const answerRect = answer.getBoundingClientRect()
+      const producedRect = producedRoot.getBoundingClientRect()
+      const actionsRect = actions.getBoundingClientRect()
+      return {
+        answerToProduced: producedRect.top - answerRect.bottom,
+        producedToActions: actionsRect.top - producedRect.bottom,
+      }
+    }, DONE)
+    expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1)
+    expect(turnSpacing.producedToActions).toBeCloseTo(20, 1)
+
     const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))
     expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)

+ 1 - 1
apps/web/tests/question-composer.e2e.ts

@@ -345,7 +345,7 @@ describe('web e2e: resident question composer round trip', () => {
     expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
 
     // Settle the wait so teardown is not racing a pending question.
-    await composer.getByRole('button', { name: 'Skip this question' }).click()
+    await composer.getByRole('button', { name: 'Skip' }).click()
     expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
     await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
   }, 60_000)

+ 2 - 2
docs/subsystems/feedback.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/feedback.md
-feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5
-feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99
+feedback.md: e153047bcc260bcd06847b42eb6b983ac9247d03
+feedback.zh.md: 60263ea4509c0b10ed9f57d13f1564ced1e066c7

+ 3 - 3
docs/subsystems/feedback.md

@@ -27,7 +27,7 @@ interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
-  /** Category the human filed a negative judgment under. */
+  /** Category the human filed the judgment under. */
   readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
@@ -298,9 +298,9 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session
 
 The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows.
 
-One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read.
+One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. The injected `retract` operation rechecks the committed rating inside that queue and becomes a no-op after a concurrent change, so stale UI cannot bypass the dialog by recording a bare rating. A `connection/reset` refreshes only Sessions already read.
 
-Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback <text>` keeps the Host command path. Clicking a recorded rating retracts it.
+Either unrecorded rating opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts the selected judgment carrying the chosen category and the trimmed description, or neither; success closes the dialog and shows the acknowledgement toast, while failure keeps the dialog and draft open and shows a warning toast. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback <text>` keeps the Host command path. Clicking a recorded rating retracts it without opening the dialog.
 
 ## Boundaries and limitations
 

+ 3 - 3
docs/subsystems/feedback.zh.md

@@ -27,7 +27,7 @@ interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
-  /** Category the human filed a negative judgment under. */
+  /** Category the human filed the judgment under. */
   readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
@@ -298,9 +298,9 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s
 
 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。
 
-每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。
+每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。注入的 `retract` 操作会在该队列内重新检查已提交评分,并在并发变更后变为无操作,因此陈旧 UI 无法绕过弹窗记录裸评分。`connection/reset` 只刷新已读取过的 Session。
 
-点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback <text>` 仍走宿主命令路径。再次点击已记录的评分会撤回它
+任一未记录的评分都会打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 所选评分,带上所选分类与去除首尾空白的描述,两者也可都不带;成功会关闭弹窗并显示确认 toast,失败则保留弹窗与草稿并显示警告 toast。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback <text>` 仍走宿主命令路径。再次点击已记录的评分会直接撤回,不打开弹窗
 
 ## 边界与限制
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-root",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "license": "MIT",
   "private": true,
   "type": "module",

+ 1 - 1
packages/acp/acp/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp",
   "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/gateway/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-gateway",
   "description": "Typert Remote Host dispatcher and Client API endpoint",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/remotes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-remotes",
   "description": "Remote BFF assembly for application-selected Host capabilities",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/session-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-session-controller",
   "description": "Session Remote commands, cold reads, and live control transport",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/settings-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-settings-controller",
   "description": "Remote owner for the configuration surfaces over the settings-domain seams",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-controller/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-controller",
   "description": "Workspace Remote commands and reconnect-safe state transport",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/api/workspace-files/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-api-workspace-files",
   "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment-local/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment-local",
   "description": "Private content-addressed DSH_HOME attachment storage",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/attachment/attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-attachment",
   "description": "Durable immutable attachment storage seam for the DeepSeek Harness",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/app-boot/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-app-boot",
   "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/boot/cmdline/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-cmdline",
   "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/acp-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-acp-app",
   "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/base/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-base",
   "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/headless/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-headless",
   "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/sdk-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-app",
   "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/sdk-minimal/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-sdk-minimal",
   "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/bundle/web-app/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-web-app",
   "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 2
packages/client/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/README.md
-README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969
-README.zh.md: 5805ad129fc5d1f241fb6594adc7a5f00ca3d0e8
+README.md: 0894d4e0ef5e45876a58abea40cdad840e7dfacd
+README.zh.md: 8183ef33ca7e08bec2533eb2e21486809c94cedd

+ 1 - 1
packages/client/README.md

@@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
 | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — |
-| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — |
+| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |

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

@@ -71,7 +71,7 @@ kind: "package-group"
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
 | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — |
-| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — |
+| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |
 | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — |

+ 1 - 1
packages/client/connection/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-connection",
   "description": "Authenticated RPC transport, generation lifecycle, and browser fixture",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/file-upload/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-file-upload",
   "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/hmr/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-hmr",
   "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/locale/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-locale",
   "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/modules/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-modules",
   "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/resources/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-resources",
   "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/store/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-store",
   "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-agent-preset/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-agent-preset",
   "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-approval/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-approval",
   "description": "Approval composer takeover over the scoped Remote Event waterfall",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-attachment/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-attachment",
   "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-brand-official/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
   "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: 2499163dcc0795278ce67bed38be2ed106bbae20
-README.zh.md: 33fa9e30fd0c9e46cd9c600decd89e4dc0357921
+README.md: 2333739f2827eb673525b9f00547d12e8d343888
+README.zh.md: 0de859abfcfac1679d605cf4447a2f4ac2312f5d

+ 6 - 0
packages/client/ui-chat/README.md

@@ -17,6 +17,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 - [Reference previews](#reference-previews)
 - [System prompt row](#system-prompt-row)
 - [Turn token usage](#turn-token-usage)
+- [Completed-turn footer](#completed-turn-footer)
 - [Turn Process Folding](#turn-process-folding)
 - [Scroll ownership](#scroll-ownership)
 - [Model Experience](#model-experience)
@@ -40,6 +41,11 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a
 
 A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
 
+<a id="completed-turn-footer"></a>
+## Completed-turn footer
+
+The completed-turn action footer starts 20px below the preceding prose or extension content.
+
 -----
 
 <a id="turn-process-folding"></a>

+ 6 - 0
packages/client/ui-chat/README.zh.md

@@ -17,6 +17,7 @@ kind: "package-reference"
 - [引用预览](#reference-previews)
 - [系统提示词行](#system-prompt-row)
 - [轮次 token 用量](#turn-token-usage)
+- [已完成轮次的页脚](#completed-turn-footer)
 - [轮次过程折叠](#turn-process-folding)
 - [滚动归属](#scroll-ownership)
 - [模型体验](#model-experience)
@@ -42,6 +43,11 @@ kind: "package-reference"
 
 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
 
+<a id="completed-turn-footer"></a>
+## 已完成轮次的页脚
+
+已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。
+
 -----
 
 <a id="turn-process-folding"></a>

+ 1 - 1
packages/client/ui-chat/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-chat",
   "description": "Chat Conversation target, node definitions, renderers, and details surface",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 3 - 0
packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css

@@ -5,5 +5,8 @@
 }
 
 .actions {
+  /* With tail content, this adds to the root gap; without it, the Chat flow's
+     preceding-item offset supplies the other 16px. */
+  margin-top: 4px;
   margin-left: -6px;
 }

+ 16 - 0
packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts

@@ -0,0 +1,16 @@
+/** CSS checks for the completed-turn footer's 20px content spacing. */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const read = (name: string): string =>
+  readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8')
+
+describe('completed-turn spacing', () => {
+  it('combines the flow and footer offsets around turn-tail content', () => {
+    expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 16px\)/)
+    const tail = read('TurnTailNodeView.module.css')
+    expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s)
+    expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s)
+  })
+})

+ 1 - 1
packages/client/ui-commands/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-commands",
   "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-conversation/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-conversation",
   "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: ac0e7a4508bdf2b3705491d85837e84f5911d727
-README.zh.md: f0ce8691051aae4fbf664048c2f6e322db47684a
+README.md: 5ae62206bed36e611635511f0ad3472ea4e98107
+README.zh.md: 3d154d9c4239f119624c74e219980c0be3ab81c0

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

@@ -30,13 +30,13 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
 
-The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
+The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply.
 
 The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
 
 ### The row
 
-The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
+The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the row, and the action footer starts 20px below the last file section. The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
 
 ### Inline-code links
 

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

@@ -30,13 +30,13 @@ kind: "package-reference"
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
 ### 该行
 
-“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
+“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
 
 ### 行内代码链接
 

+ 1 - 1
packages/client/ui-deliverables/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-deliverables",
   "description": "Produced-files turn tail and clickable final-response file references for Web",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 12 - 9
packages/client/ui-deliverables/src/client/Deliverables.module.css

@@ -1,27 +1,30 @@
 /** Delivery cards use one full-width row or a two-column, four-card summary. */
-.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 16px; }
+.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; }
+/* The turn-tail gap alone separates adjacent file sections. The default top
+   margin remains when deliveries are the first file section after prose. */
+.root[data-after-produced-files='true'] { margin-top: 0; }
 :global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); }
 .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
-.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; min-width: 0; }
+.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; }
 .presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); }
-.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 72px; padding: 12px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; }
+.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 60px; padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; }
 .file:hover { background: var(--deliverable-hover); }
 .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
 .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); }
-.fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); }
+.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--deliverable-fill); color: var(--dsw-alias-link); }
 .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; }
 .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; }
-.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; }
-.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
+.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; }
+.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
 .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); }
 .previewHint { display: none; }
 .file:hover .secondaryText { display: none; }
 .file:hover .previewHint { display: inline; }
-.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 32px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-button-floating-fill); }
+.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); }
 .menuAnchor { align-self: stretch; }
 .open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); }
-.open { padding: 5px 10px; font-size: 14px; line-height: 22px; }
-.chevron { padding: 5px 6px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); }
+.open { padding: 4px 8px; font-size: 12px; line-height: 18px; }
+.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); }
 .open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
 .chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; }
 .menuActionIcon { display: block; width: 16px; height: 16px; }

+ 4 - 1
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -58,7 +58,10 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope
   }, [matched.presented.length, host, reloadPresentedHost])
   return <>
     {matched.produced.length > 0 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
-    {matched.presented.length > 0 && <div className={css.root}>
+    {matched.presented.length > 0 && <div
+      className={css.root}
+      data-after-produced-files={matched.produced.length > 0 || undefined}
+    >
       {host === 'error' && <div className={css.hostStatus}>
         <span>{t('presented.hostError')}</span>
         <Button size="sm" onClick={() => { void reloadPresentedHost() }}>{t('presented.retry')}</Button>

+ 1 - 1
packages/client/ui-deliverables/src/client/PresentedFileCard.tsx

@@ -51,7 +51,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction,
   return <div className={css.file} data-presented-file>
     <button type="button" className={css.cardPreview} title={resolveWorkspacePath(cwd, file.path)}
       aria-label={t('presented.previewCard', { name: file.path })} onClick={onPreview} />
-    <span className={css.fileIcon}><FileTypeIcon path={file.path} /></span>
+    <span className={css.fileIcon}><FileTypeIcon path={file.path} size={20} /></span>
     <div className={css.fileBody}>
       <div className={css.details}>
         <span className={css.fileName}>{name}</span>

+ 1 - 1
packages/client/ui-deliverables/src/client/ProducedFiles.module.css

@@ -5,7 +5,7 @@
   grid-template-columns: max-content minmax(0, 1fr);
   align-items: start;
   column-gap: 8px;
-  margin-top: 16px;
+  margin-top: 4px;
   font-size: 13px;
   line-height: 22px;
 }

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

@@ -673,7 +673,16 @@ it('shows descriptions and falls back to file metadata without hiding extensionl
   expect(view.getByText('report.txt')).toBeTruthy()
 })
 
-it('distinguishes PDF, Word, Markdown, and code files with full-size decorative card icons', () => {
+it('marks delivery cards that directly follow the produced-files row', () => {
+  const shared = { ...openProps(), openFile: () => {}, sessionId: SessionId('session'), t: makeTranslate(en) }
+  const presented = [{ path: 'report.txt', seq: 2, index: 0 }]
+  const view = render(<Deliverables {...shared} matched={{ produced: ['source.ts'], presented }} />)
+  expect(view.getByText('Files changed')).toBeTruthy()
+  expect(view.container.querySelector('[data-presented-files-row]')?.parentElement
+    ?.getAttribute('data-after-produced-files')).toBe('true')
+})
+
+it('distinguishes PDF, Word, Markdown, and code files with compact decorative card icons', () => {
   const paths = ['report.pdf', 'report.docx', 'README.md', 'index.tsx']
   const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented:
     paths.map((path, index) => ({ path, seq: 2, index })),
@@ -681,7 +690,7 @@ it('distinguishes PDF, Word, Markdown, and code files with full-size decorative
   const icons = [...view.container.querySelectorAll('[data-presented-file]')].map((card) => {
     const icon = card.querySelector('svg')!
     expect(icon.getAttribute('aria-hidden')).toBe('true')
-    expect(icon.getAttribute('width')).toBe('28')
+    expect(icon.getAttribute('width')).toBe('20')
     return icon.innerHTML
   })
   expect(new Set(icons).size).toBe(paths.length)

+ 24 - 0
packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts

@@ -0,0 +1,24 @@
+/** Source checks for the produced-files and explicit-delivery layout. */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const read = (name: string): string =>
+  readFileSync(fileURLToPath(new URL(`../src/client/${name}`, import.meta.url)), 'utf8')
+
+describe('deliverables layout', () => {
+  it('keeps the first file-section offset and removes the second', () => {
+    expect(read('ProducedFiles.module.css')).toMatch(/\.root\s*\{[^}]*margin-top:\s*4px/s)
+    const deliveries = read('Deliverables.module.css')
+    expect(deliveries).toMatch(/\.root\s*\{[^}]*margin-top:\s*4px/s)
+    expect(deliveries).toMatch(/\.root\[data-after-produced-files='true'\]\s*\{\s*margin-top:\s*0;\s*\}/)
+    expect(deliveries).toMatch(/\.file\s*\{[^}]*height:\s*60px;[^}]*padding:\s*8px 10px/s)
+    expect(deliveries).toMatch(/\.fileIcon\s*\{[^}]*box-sizing:\s*border-box;[^}]*width:\s*40px;[^}]*height:\s*40px/s)
+    expect(deliveries).toMatch(/\.fileIcon\s*\{[^}]*border:\s*0\.5px solid var\(--dsw-alias-border-l1\)/s)
+    expect(read('PresentedFileCard.tsx')).toMatch(/<FileTypeIcon path=\{file\.path\} size=\{20\} \/>/)
+    expect(deliveries).toMatch(/\.fileName\s*\{[^}]*font-size:\s*13px;[^}]*line-height:\s*20px/s)
+    expect(deliveries).toMatch(/\.description\s*\{[^}]*font-size:\s*10px;[^}]*line-height:\s*16px/s)
+    expect(deliveries).toMatch(/\.open\s*\{[^}]*padding:\s*4px 8px;[^}]*font-size:\s*12px;[^}]*line-height:\s*18px/s)
+    expect(deliveries).toMatch(/\.presented\s*\{[^}]*gap:\s*10px/s)
+  })
+})

+ 1 - 1
packages/client/ui-directory-picker-browse/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse",
   "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-directory-picker-native/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
   "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-dockkit/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-dockkit",
   "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-goal/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-goal",
   "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-input-trigger/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-input-trigger",
   "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 1 - 1
packages/client/ui-jobs/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-jobs",
   "description": "Session-header background-job list: live registry state mirrored from session/jobs frames",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "type": "module",
   "main": "lib/index.js",
   "types": "lib/types/index.d.ts",

+ 1 - 1
packages/client/ui-layout/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-layout",
   "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 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: 8ffcf87a59ec95fe783a0379423b93d4bf965a3e
-README.zh.md: 36fc54c945e8c25af03e5f307478b0c5fce811aa
+README.md: b37278e8d115a2ad61bb17ef8d884b0d888cd6a7
+README.zh.md: cd233061640f5e52ea9ca54ffe973bff3b957197

+ 5 - 6
packages/client/ui-message-feedback/README.md

@@ -1,5 +1,5 @@
 ---
-description: "The Web feedback surface: the Like/Dislike pair in the finalized assistant message's action row, the feedback dialog behind Dislike and `/feedback`, and the acknowledgement toast; for users and maintainers of the feedback experience."
+description: "The Web feedback surface: the Like/Dislike pair in the finalized assistant message's action row, the feedback dialog behind both ratings and `/feedback`, and its acknowledgement and failure toasts; for users and maintainers of the feedback experience."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package is the Web GUI's feedback surface: the Like/Dislike pair in the finalized assistant message's action strip, the feedback dialog with its acknowledgement toast in the composer overlay, and a decoration that opens the dialog from a bare `/feedback`. Like records at once and shows the toast; Dislike opens the dialog, which collects a category and an optional description. One surface per Session backs every entry, so a single list read seeds the whole transcript and one dialog serves the Session and its messages. Ratings, categories, and notes are log-only Session events that never enter model context.
+This package is the Web GUI's feedback surface: the Like/Dislike pair in the finalized assistant message's action strip, the feedback dialog with its acknowledgement and failure toasts in the composer overlay, and a decoration that opens the dialog from a bare `/feedback`. Like and Dislike both open the dialog, which collects a category and an optional description before recording the selected rating. One surface per Session backs every entry, so a single list read seeds the whole transcript and one dialog serves the Session and its messages. Ratings, categories, and notes are log-only Session events that never enter model context.
 
 ## Table of Contents
 
@@ -25,11 +25,11 @@ This package is the Web GUI's feedback surface: the Like/Dislike pair in the fin
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and the Feedback row of the composer menu opens the dialog. A recorded rating shows the filled glyph and stays visible without hover. Like records immediately and the toast thanks the user for the feedback. Dislike opens the dialog: seven category chips and a detail box, both optional; Submit records a negative judgment carrying whatever was filled in, and the conversation log travels with every feedback event. Clicking the recorded rating retracts it. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback <text>` keeps the Host command path and its acknowledgement row.
+Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and the Feedback row of the composer menu opens the dialog. A recorded rating shows the filled glyph and stays visible without hover. Like and Dislike both open the dialog: seven category chips and a detail box are optional, and Submit records the selected judgment with whatever was filled in before the toast thanks the user; the conversation log travels with every feedback event. Clicking the recorded rating retracts it without opening the dialog. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback <text>` keeps the Host command path and its acknowledgement row.
 
 ### Failures
 
-A rating or list-load failure shows inline in the row; a submission failure shows inside the dialog, which stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
+A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
 
 -----
 
@@ -41,7 +41,7 @@ A rating or list-load failure shows inline in the row; a submission failure show
 
 The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command.
 
-Per Session, one `MessageFeedbackController` backs every message control and one `FeedbackDialogController` owns the dialog draft, the submission, and the toast sequence. The message controller reads `messageFeedback.list` once, deferred to the first hover or focus rather than fired on mount, and serializes mutations so each carries the version last observed; a `version-conflict` reply carries the authoritative item and reconciles the view without refetching. `toggle` reports the rating now committed, so the row acknowledges a recorded Like and not a retraction. The dialog controller submits by target: a message target puts a negative judgment with the dialog's note and category through the message controller, and the Session target records through `ctx.remote.sessionFeedback`. Success closes the draft and raises the toast; a late success from a superseded draft raises the toast without closing the new draft; a failure keeps the draft open with its code.
+Per Session, one `MessageFeedbackController` backs every message control and one `FeedbackDialogController` owns the dialog draft, the submission, and the toast sequence. The message controller reads `messageFeedback.list` once, deferred to the first hover or focus rather than fired on mount, and serializes mutations so each carries the version last observed; a `version-conflict` reply carries the authoritative item and reconciles the view without refetching. Before either rating action proceeds, the row checks the committed item: the matching rating calls `retract`, which rechecks the rating in the serialized queue and becomes a no-op after a concurrent change, while any other state opens the dialog with the requested rating. The dialog controller submits by target: a message target puts that rating with the dialog's note and category through the message controller, and the Session target records through `ctx.remote.sessionFeedback`. Success closes the draft and raises the acknowledgement toast; a late success from a superseded draft raises that toast without closing the new draft; a failure keeps the draft open and raises a longer-lived warning toast.
 
 </details>
 
@@ -77,7 +77,6 @@ None; feedback mutations leave the model-visible history unchanged.
 These limits define the current feedback surface. They are current package constraints, not a general rating comparison or a task backlog.
 
 - **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The dialog does not pre-check the limit, so an oversized description for a message fails on submit rather than while typing; a Session remark has no bound.
-- **No note on a Like** — only the Dislike dialog collects a category and description; a Like records the bare judgment.
 - **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the controller does not consume feedback log events.
 - **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes carry the same `messageId`.
 

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

@@ -1,5 +1,5 @@
 ---
-description: "Web 反馈界面:已定稿助手消息动作行中的 Like/Dislike 对、点踩与 `/feedback` 背后的反馈弹窗,以及确认 toast;供反馈体验的用户与维护者阅读。"
+description: "Web 反馈界面:已定稿助手消息动作行中的 Like/Dislike 对、两种评分与 `/feedback` 共用的反馈弹窗,以及确认和失败 toast;供反馈体验的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包是 Web GUI 的反馈界面:已定稿助手消息动作条中的 Like/Dislike 对、输入框浮层中的反馈弹窗及其确认 toast,以及让不带文本的 `/feedback` 打开弹窗的装饰。点赞立即记录并显示 toast;点踩打开弹窗,弹窗收集分类与可选描述。每个 Session 一个 surface 支撑所有条目,因此一次列表读取即可填充整段对话,一个弹窗同时服务 Session 与其消息。评分、分类与备注是仅写日志的 Session 事件,绝不进入模型上下文。
+本包是 Web GUI 的反馈界面:已定稿助手消息动作条中的 Like/Dislike 对、输入框浮层中的反馈弹窗及其确认与失败 toast,以及让不带文本的 `/feedback` 打开弹窗的装饰。点赞和点踩都会打开弹窗,先收集分类与可选描述,再记录所选评分。每个 Session 一个 surface 支撑所有条目,因此一次列表读取即可填充整段对话,一个弹窗同时服务 Session 与其消息。评分、分类与备注是仅写日志的 Session 事件,绝不进入模型上下文。
 
 ## 目录
 
@@ -25,11 +25,11 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞立即记录,toast 显示感谢反馈。点踩打开弹窗:七个分类标签和一个详情框,都可不填;提交会记录一条带上所填内容的差评,对话日志随每个反馈事件一起投递。再次点击已记录的评分会撤回它。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback <text>` 仍走宿主命令路径并显示确认行。
+与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback <text>` 仍走宿主命令路径并显示确认行。
 
 ### 失败
 
-评分或列表加载失败在行内展示;提交失败在弹窗内展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
+评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
 
 -----
 
@@ -41,7 +41,7 @@ kind: "package-reference"
 
 本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。
 
-每个 Session 有一个 `MessageFeedbackController` 支撑所有消息控件,以及一个 `FeedbackDialogController` 拥有弹窗草稿、提交与 toast 序号。消息控制器只读取一次 `messageFeedback.list`,且延迟到首次 hover 或 focus 才发起,而非挂载时触发;变更串行执行,每次都携带最后观察到的版本,`version-conflict` 响应带回权威条目,据此对账视图而不重新拉取。`toggle` 会报告提交后的评分,因此该行只对记录成功的点赞做确认,撤回不做。弹窗控制器按目标提交:消息目标通过消息控制器 put 一条带弹窗备注与分类的差评,Session 目标通过 `ctx.remote.sessionFeedback` 记录。成功会关闭草稿并弹出 toast;被替换的旧草稿迟到的成功只弹 toast、不关闭新草稿;失败让草稿保持打开并带上失败码
+每个 Session 有一个 `MessageFeedbackController` 支撑所有消息控件,以及一个 `FeedbackDialogController` 拥有弹窗草稿、提交与 toast 序号。消息控制器只读取一次 `messageFeedback.list`,且延迟到首次 hover 或 focus 才发起,而非挂载时触发;变更串行执行,每次都携带最后观察到的版本,`version-conflict` 响应带回权威条目,据此对账视图而不重新拉取。任一评分操作执行前,该行都会检查已提交条目:评分相同则调用 `retract`,它会在串行队列内重新检查评分并在并发变更后变为无操作;其他状态则携带所选评分打开弹窗。弹窗控制器按目标提交:消息目标通过消息控制器 put 一条带弹窗备注与分类的对应评分,Session 目标通过 `ctx.remote.sessionFeedback` 记录。成功会关闭草稿并弹出确认 toast;被替换的旧草稿迟到的成功只弹确认 toast、不关闭新草稿;失败会保留弹窗并弹出停留时间更长的警告 toast
 
 </details>
 
@@ -77,7 +77,6 @@ kind: "package-reference"
 这些限制界定了当前反馈界面。它们是当前包约束,不是通用评分对比或任务积压。
 
 - **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。弹窗不预先校验该上限,因此针对消息的超长描述在提交时才失败,而不是在输入过程中;Session 级备注没有上限。
-- **点赞不带备注**——只有点踩弹窗收集分类与描述;点赞只记录判断本身。
 - **无跨标签页推送**——另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;控制器不消费反馈日志事件。
 - **仅限对话视图**——trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点也带有相同的 `messageId`。
 

+ 2 - 2
packages/client/ui-message-feedback/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-message-feedback",
-  "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind Dislike and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes",
-  "version": "0.1.5-rc.1",
+  "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 0 - 8
packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css

@@ -82,14 +82,6 @@
   box-shadow: 0 0 0 1px var(--dsw-alias-border-l3);
 }
 
-.failure {
-  display: block;
-  margin-top: 8px;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: 13px;
-  line-height: 20px;
-}
-
 .submit {
   width: 100%;
   height: 44px;

+ 22 - 7
packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx

@@ -1,5 +1,5 @@
 /**
- * The feedback dialog and its acknowledgement toast, rendered as one entry
+ * The feedback dialog and its acknowledgement and failure toasts, rendered as one entry
  * of `conversation.input.overlay` so each Session owns exactly one of each.
  * The Modal and the Toast both portal to `document.body`; the overlay slot
  * only supplies the per-session controller and the composer card the toast
@@ -8,7 +8,9 @@
  */
 
 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
-import { Button, IconCheckOutline16, Modal, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
+import {
+  Button, IconCheckOutline16, IconWarningOutline16, Modal, Toast,
+} from '@deepseek-ai/dsh-client-ui-primitives'
 import type { FeedbackCategory } from '@deepseek-ai/dsh-command-feedback/types'
 import type { FeedbackDialogProps } from './slots.ts'
 import css from './FeedbackDialog.module.css'
@@ -38,9 +40,11 @@ const FAILURE_COPY: Partial<Record<string, 'error.conflict' | 'error.noteTooLarg
 /**
  * Render one Session's feedback dialog and toast.
  * @param props - the dialog hook, the draft verbs, and the locale seat.
- * @returns the modal while a target is open, the toast while one is showing.
+ * @returns the modal while a target is open and either toast while it is showing.
  */
-export function FeedbackDialog({ useDialog, edit, submit, dismiss, dismissToast, t }: FeedbackDialogProps) {
+export function FeedbackDialog({
+  useDialog, edit, submit, dismiss, dismissFailure, dismissToast, t,
+}: FeedbackDialogProps) {
   const state = useDialog(s => s)
   // The toast centers over the composer card this entry renders inside of.
   const probeRef = useRef<HTMLSpanElement>(null)
@@ -53,12 +57,14 @@ export function FeedbackDialog({ useDialog, edit, submit, dismiss, dismissToast,
   // A toast retires with the entry that showed it: the Toast's own timer dies
   // on unmount, and the Session's controller must not replay it on return.
   useEffect(() => () => { dismissToast(toast) }, [dismissToast, toast])
-  const failure = state.failure === null ? null : t(FAILURE_COPY[state.failure] ?? 'error.generic')
+  const failureCode = state.failure
+  const failure = failureCode === null ? null : t(FAILURE_COPY[failureCode] ?? 'error.generic')
+  const onFailureDone = useCallback(() => { dismissFailure() }, [dismissFailure])
 
   return (
     <>
       <span ref={probeRef} hidden />
-      {toast > 0 && (
+      {toast > 0 && failure === null && (
         <Toast
           key={toast}
           text={t('toast.recorded')}
@@ -67,6 +73,16 @@ export function FeedbackDialog({ useDialog, edit, submit, dismiss, dismissToast,
           onDone={onToastDone}
         />
       )}
+      {failure !== null && (
+        <Toast
+          key={`failure-${failureCode}`}
+          text={failure}
+          icon={<IconWarningOutline16 />}
+          anchor={card}
+          holdMs={6000}
+          onDone={onFailureDone}
+        />
+      )}
       <Modal
         open={state.target !== null}
         title={t('dialog.title')}
@@ -106,7 +122,6 @@ export function FeedbackDialog({ useDialog, edit, submit, dismiss, dismissToast,
           readOnly={state.submitting}
           onChange={(event) => { edit({ text: event.target.value }) }}
         />
-        {failure !== null && <span className={css.failure} role="status">{failure}</span>}
       </Modal>
     </>
   )

+ 16 - 30
packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx

@@ -1,10 +1,9 @@
 /**
  * Per-message feedback controls: the Like/Dislike pair inside the assistant
- * message's IconActions row, between copy and branch. Like records at once
- * and raises the Session's acknowledgement toast; Dislike opens the Session's
- * feedback dialog, whose submission records the negative judgment with its
- * category and text. Clicking the recorded rating retracts it. A recorded
- * rating shows the filled glyph so the signal survives a pointer leaving the row.
+ * message's IconActions row, between copy and branch. Either rating opens the
+ * Session's feedback dialog, whose submission records that judgment with its
+ * category and text. Clicking the recorded rating retracts it. A recorded rating
+ * shows the filled glyph so the signal survives a pointer leaving the row.
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions
  */
 
@@ -12,6 +11,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
 import {
   IconDislikeFill16, IconDislikeOutline16, IconLikeFill16, IconLikeOutline16, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
 import type { MessageFeedbackActionFailure } from './controller.ts'
 import type { MessageFeedbackActionProps } from './slots.ts'
 import css from './MessageFeedbackActions.module.css'
@@ -23,7 +23,7 @@ import css from './MessageFeedbackActions.module.css'
  * @returns the rating buttons with any failure notice beside them.
  */
 export function MessageFeedbackActions({
-  messageId, ensure, current, toggle, openDialog, acknowledge, useFeedback, t,
+  messageId, ensure, current, retract, openDialog, useFeedback, t,
 }: MessageFeedbackActionProps) {
   const item = useFeedback(view => view.items.get(messageId))
   const loadFailed = useFeedback(view => view.status === 'error')
@@ -47,43 +47,29 @@ export function MessageFeedbackActions({
     return result.error.code === 'version-conflict' ? t('error.conflict') : t('error.generic')
   }, [t])
 
-  // The controller decides record-vs-retract from the committed item, so a
-  // click that lands before the first list read still toggles the stored
-  // value; the reply says which happened, and only a recording is acknowledged.
-  const onLike = useCallback(() => {
-    setPending(true)
-    setFailure(null)
-    void toggle(messageId, 'positive').then((result) => {
-      if (!alive.current) return
-      setPending(false)
-      if (!result.ok) {
-        setFailure(errorCopy(result))
-        return
-      }
-      if (result.rating === 'positive') acknowledge()
-    })
-  }, [acknowledge, errorCopy, messageId, toggle])
-
-  // A recorded Dislike retracts on click; otherwise the dialog collects the
-  // reason and records the judgment on submit. The decision waits for the
+  // A recorded rating retracts on click; either unrecorded rating opens the
+  // dialog and records only after submission. The decision waits for the
   // seeding read, so a click on a cold row still sees the stored judgment.
-  const onDislike = useCallback(() => {
+  const choose = useCallback((nextRating: MessageFeedbackRating) => {
     setPending(true)
     setFailure(null)
     void ensure().then((loaded) => {
       if (!alive.current) return
-      if (!loaded.ok || current(messageId)?.rating !== 'negative') {
+      if (!loaded.ok || current(messageId)?.rating !== nextRating) {
         setPending(false)
-        openDialog(messageId)
+        openDialog(messageId, nextRating)
         return
       }
-      void toggle(messageId, 'negative').then((result) => {
+      void retract(messageId, nextRating).then((result) => {
         if (!alive.current) return
         setPending(false)
         if (!result.ok) setFailure(errorCopy(result))
       })
     })
-  }, [current, ensure, errorCopy, messageId, openDialog, toggle])
+  }, [current, ensure, errorCopy, messageId, openDialog, retract])
+
+  const onLike = useCallback(() => { choose('positive') }, [choose])
+  const onDislike = useCallback(() => { choose('negative') }, [choose])
 
   const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like')
   const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike')

+ 8 - 18
packages/client/ui-message-feedback/src/client/controller.ts

@@ -38,11 +38,6 @@ export interface MessageFeedbackActionFailure {
 /** Settled action shape rendered by the message-level controls. */
 export type MessageFeedbackActionResult = { ok: true } | MessageFeedbackActionFailure
 
-/** A settled toggle, carrying the rating now committed so the control can tell a record from a retraction. */
-export type MessageFeedbackToggleResult =
-  | { ok: true; rating: MessageFeedbackRating | null }
-  | MessageFeedbackActionFailure
-
 // `Object.freeze` does not protect a Map: `set`/`delete` write internal slots,
 // not properties. Immutability here is by discipline instead — the view type is
 // ReadonlyMap and every publish hands over a freshly built Map that this class
@@ -176,24 +171,19 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
   }
 
   /**
-   * Replace one message's rating with the opposite judgment, or retract it when
-   * the committed rating already matches. The decision reads the committed item
-   * inside the serialized mutation, so a click that lands before the first list
-   * read still toggles against the stored value rather than the empty view a
-   * cold control rendered. A replacement stores the bare judgment; the note
-   * and category of the judgment it replaces do not carry over.
+   * Retract one message's matching committed rating. The serialized operation
+   * rechecks the current item and becomes a no-op if another operation already
+   * changed or removed it, so a stale retraction can never record a bare rating.
    * @param messageId - target assistant message.
-   * @param rating - the judgment the human asked for.
-   * @returns the settled mutation result with the rating now committed.
+   * @param rating - judgment the human asked to retract.
+   * @returns the settled mutation result.
    */
-  toggle(messageId: MessageId, rating: MessageFeedbackRating): Promise<MessageFeedbackToggleResult> {
+  retract(messageId: MessageId, rating: MessageFeedbackRating): Promise<MessageFeedbackActionResult> {
     return this.mutate(async () => {
       const observed = this.view.items.get(messageId)
-      const retract = observed?.rating === rating
-      const result = retract
+      return observed?.rating === rating
         ? await this.deleteCommitted(messageId, observed)
-        : await this.putCommitted(messageId, rating, {}, observed)
-      return result.ok ? { ok: true, rating: retract ? null : rating } : result
+        : OK
     })
   }
 

+ 16 - 9
packages/client/ui-message-feedback/src/client/dialog.ts

@@ -1,21 +1,21 @@
 /**
- * Headless state of one Session's feedback dialog and its acknowledgement
- * toast. One form serves two targets: the Session itself (a bare `/feedback`)
- * and one assistant message (Dislike). The overlay view renders from the
- * store; the message controls raise the toast after a Like through
- * {@link FeedbackDialogController.acknowledge}.
+ * Headless state of one Session's feedback dialog and its acknowledgement and
+ * failure toasts. One form serves two targets: the Session itself (a bare `/feedback`)
+ * and one assistant message (Like or Dislike). The overlay view renders from
+ * the store and raises the acknowledgement after a successful submission.
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client/dialog
  */
 
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { FeedbackCategory, FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
+import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
 import type { MessageFeedbackActionResult } from './controller.ts'
 
 /** What one open dialog submits to. */
 export type FeedbackDialogTarget =
   | { readonly kind: 'session' }
-  | { readonly kind: 'message'; readonly messageId: MessageId }
+  | { readonly kind: 'message'; readonly messageId: MessageId; readonly rating: MessageFeedbackRating }
 
 /** Dialog and toast state the overlay view renders from. */
 export interface FeedbackDialogState {
@@ -82,7 +82,8 @@ export class FeedbackDialogController {
 
   /**
    * Submit the draft; an empty draft is a valid submission. Success closes the
-   * dialog and raises the toast; a failure keeps the dialog open with its code.
+   * dialog and raises the acknowledgement toast; a failure keeps the dialog
+   * open and publishes its code for the failure toast.
    * @returns after the submission settles.
    */
   async submitDraft(): Promise<void> {
@@ -103,11 +104,17 @@ export class FeedbackDialogController {
       return
     }
     if (generation !== this.generation) return
-    this.state.set({ ...this.state.getSnapshot(), submitting: false, failure: result.error.code })
+    this.state.set({ ...this.state.getSnapshot(), submitting: false, failure: result.error.code, toast: 0 })
+  }
+
+  /** Clear the current failure toast without closing its draft. */
+  dismissFailure(): void {
+    const s = this.state.getSnapshot()
+    this.state.set({ ...s, failure: null })
   }
 
   /** Show the acknowledgement toast; a toast already on screen restarts. */
-  acknowledge(): void {
+  private acknowledge(): void {
     this.toastSeq += 1
     this.state.set({ ...this.state.getSnapshot(), toast: this.toastSeq })
   }

+ 5 - 5
packages/client/ui-message-feedback/src/client/index.ts

@@ -1,7 +1,7 @@
 /**
  * Feedback surface plugin, browser half: the Like/Dislike entry in the
  * conversation.chat.assistant-actions strip, the feedback dialog and its
- * acknowledgement toast in conversation.input.overlay, and the `/feedback`
+ * acknowledgement and failure toasts in conversation.input.overlay, and the `/feedback`
  * decoration that opens the dialog from the composer menu or a bare typed
  * command. One FeedbackSurface per Session backs every entry in that Session.
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client
@@ -29,7 +29,7 @@ import { en, zh } from './locales.ts'
 
 export type {
   MessageFeedbackActionFailure, MessageFeedbackActionResult, MessageFeedbackStatus,
-  MessageFeedbackToggleResult, MessageFeedbackView,
+  MessageFeedbackView,
 } from './controller.ts'
 export type { FeedbackDialogState, FeedbackDialogTarget, FeedbackSubmit } from './dialog.ts'
 export type {
@@ -84,9 +84,8 @@ export function apply(ctx: ClientContext): void {
         hooks: { feedback },
         ensure: () => feedback.ensure(),
         current: messageId => feedback.getSnapshot().items.get(messageId),
-        toggle: (messageId, rating) => feedback.toggle(messageId, rating),
-        openDialog: (messageId) => { dialog.open({ kind: 'message', messageId }) },
-        acknowledge: () => { dialog.acknowledge() },
+        retract: (messageId, rating) => feedback.retract(messageId, rating),
+        openDialog: (messageId, rating) => { dialog.open({ kind: 'message', messageId, rating }) },
       }
     },
   }, MessageFeedbackActions))
@@ -103,6 +102,7 @@ export function apply(ctx: ClientContext): void {
         edit: (draft) => { dialog.edit(draft) },
         submit: () => dialog.submitDraft(),
         dismiss: () => { dialog.dismiss() },
+        dismissFailure: () => { dialog.dismissFailure() },
         dismissToast: (seq) => { dialog.dismissToast(seq) },
       }
     },

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

@@ -13,7 +13,7 @@ export const zh = {
   'category.task-result': '任务结果',
   'category.instruction-following': '指令理解与遵循',
   'category.product-interaction': '产品功能与交互',
-  'category.service-stability': '服务稳定性',
+  'category.service-stability': '稳定性和速度',
   'category.resource-cost': '资源使用与费用',
   'category.security-privacy-permission': '安全隐私与权限',
   'category.other': '其他',
@@ -47,7 +47,7 @@ export const en = {
   'category.task-result': 'Task result',
   'category.instruction-following': 'Instruction understanding and following',
   'category.product-interaction': 'Product features and interaction',
-  'category.service-stability': 'Service stability',
+  'category.service-stability': 'Stability and speed',
   'category.resource-cost': 'Resource usage and cost',
   'category.security-privacy-permission': 'Security, privacy, and permissions',
   'category.other': 'Other',

+ 11 - 11
packages/client/ui-message-feedback/src/client/slots.ts

@@ -16,7 +16,7 @@ import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { MessageFeedbackItem, MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
 // Type-only: pulls this package's LocaleNamespaceMap merge (the 'feedback' seat).
 import type {} from './locales.ts'
-import type { MessageFeedbackActionResult, MessageFeedbackToggleResult, MessageFeedbackView } from './controller.ts'
+import type { MessageFeedbackActionResult, MessageFeedbackView } from './controller.ts'
 import type { FeedbackDialogState } from './dialog.ts'
 
 /** Injected business face of one assistant-message feedback entry. */
@@ -33,21 +33,19 @@ export interface MessageFeedbackInjected {
    */
   current: (messageId: MessageId) => MessageFeedbackItem | undefined
   /**
-   * Apply the requested judgment, retracting instead when the committed rating
-   * already matches. The controller decides from the committed item, so a click
-   * before the first list read still toggles the stored value.
+   * Retract the requested judgment only while the committed rating still
+   * matches; a concurrent change makes the serialized operation a no-op.
    * @param messageId - target assistant message.
-   * @param rating - the judgment the human asked for.
+   * @param rating - judgment the human asked to retract.
    */
-  toggle: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<MessageFeedbackToggleResult>
+  retract: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<MessageFeedbackActionResult>
   /**
-   * Open the Session's feedback dialog for one message; its submission
-   * records a negative judgment with the dialog's category and text.
+   * Open the Session's feedback dialog for one message and judgment; its
+   * submission records that rating with the dialog's category and text.
    * @param messageId - target assistant message.
+   * @param rating - judgment to record on submission.
    */
-  openDialog: (messageId: MessageId) => void
-  /** Show the Session's acknowledgement toast. */
-  acknowledge: () => void
+  openDialog: (messageId: MessageId, rating: MessageFeedbackRating) => void
 }
 
 /** Full props of one assistant-message feedback entry. */
@@ -71,6 +69,8 @@ export interface FeedbackDialogInjected {
   submit: () => Promise<void>
   /** Close the dialog and discard the draft. */
   dismiss: () => void
+  /** Retire the current submission-failure toast without closing its draft. */
+  dismissFailure: () => void
   /**
    * Retire the toast the view finished showing.
    * @param seq - the toast sequence.

+ 3 - 3
packages/client/ui-message-feedback/src/client/surface.ts

@@ -1,8 +1,8 @@
 /**
  * One Session's feedback surface: the message-feedback object layer and the
  * dialog controller, plus the routing between them. A message target puts a
- * negative judgment through the message controller; the Session target
- * records through the `sessionFeedback` Remote.
+ * selected judgment through the message controller; the Session target records
+ * through the `sessionFeedback` Remote.
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client/surface
  */
 
@@ -26,7 +26,7 @@ export class FeedbackSurface {
   constructor(private readonly ctx: ClientContext, private readonly sessionId: SessionId) {
     this.feedback = new MessageFeedbackController(ctx, sessionId)
     this.dialog = new FeedbackDialogController((target, entry) => target.kind === 'message'
-      ? this.feedback.rate(target.messageId, 'negative', entry)
+      ? this.feedback.rate(target.messageId, target.rating, entry)
       : this.recordSession(entry))
   }
 

+ 18 - 20
packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx

@@ -122,7 +122,7 @@ describe('ui-message-feedback browser plugin', () => {
     expect(b.entry()?.inject).toBeTypeOf('function')
   })
 
-  it('exposes the feedback hook plus the ensure/toggle/openDialog/acknowledge verbs', async () => {
+  it('exposes the feedback hook plus the ensure/retract/openDialog verbs', async () => {
     const b = await bench()
     await b.fiber.await()
 
@@ -132,9 +132,8 @@ describe('ui-message-feedback browser plugin', () => {
     expect(face.current(MSG)).toBeUndefined()
     await face.ensure()
     expect(face.current(MSG)).toMatchObject({ messageId: MSG, rating: 'positive' })
-    expect(face.toggle).toBeTypeOf('function')
+    expect(face.retract).toBeTypeOf('function')
     expect(face.openDialog).toBeTypeOf('function')
-    expect(face.acknowledge).toBeTypeOf('function')
   })
 
   it('registers the dialog entry with the documented id, order, and locale', async () => {
@@ -144,16 +143,17 @@ describe('ui-message-feedback browser plugin', () => {
     expect(b.dialogEntry()).toMatchObject({ id: 'feedback-dialog', order: 2, locale: 'feedback' })
     const face = b.dialogEntry()!.inject!(sid('s1'))
     expect(face.hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 0 })
+    expect(face.dismissFailure).toBeTypeOf('function')
   })
 
-  it('opens one dialog per Session from the message entry, the decoration, and acknowledges from Like', async () => {
+  it('opens one dialog per Session from the message entry and the decoration', async () => {
     const b = await bench()
     await b.fiber.await()
 
     const message = b.entry()!.inject!(sid('s1'))
     const dialog = b.dialogEntry()!.inject!(sid('s1'))
-    message.openDialog(MSG)
-    expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'message', messageId: MSG })
+    message.openDialog(MSG, 'positive')
+    expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'message', messageId: MSG, rating: 'positive' })
 
     const decoration = b.decorations.get('feedback')
     expect(decoration?.available({ sessionId: sid('s1') })).toBe(true)
@@ -161,8 +161,6 @@ describe('ui-message-feedback browser plugin', () => {
     decoration.ui.run({ sessionId: sid('s1') })
     expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'session' })
 
-    message.acknowledge()
-    expect(dialog.hooks.dialog.getSnapshot().toast).toBe(1)
     expect(b.dialogEntry()!.inject!(sid('s2')).hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 0 })
   })
 
@@ -181,11 +179,11 @@ describe('ui-message-feedback browser plugin', () => {
       .toEqual({ sessionId: 's1', text: 'timed out', category: 'service-stability' })
     expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 1 })
 
-    message.openDialog(MSG)
+    message.openDialog(MSG, 'positive')
     dialog.edit({ category: 'task-result' })
     await dialog.submit()
     expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
-      sessionId: 's1', messageId: MSG, rating: 'negative', category: 'task-result', ifVersion: 'v1',
+      sessionId: 's1', messageId: MSG, rating: 'positive', category: 'task-result', ifVersion: 'v1',
     })
     expect(dialog.hooks.dialog.getSnapshot().toast).toBe(2)
     dialog.dismissToast(2)
@@ -203,6 +201,8 @@ describe('ui-message-feedback browser plugin', () => {
     await dialog.submit()
 
     expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: { kind: 'session' }, failure: 'gateway/internal', toast: 0 })
+    dialog.dismissFailure()
+    expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: { kind: 'session' }, failure: null })
   })
 
   it('keeps the dialog open with the failure code when the Host rejects the remark', async () => {
@@ -251,19 +251,17 @@ describe('ui-message-feedback browser plugin', () => {
     ])
   })
 
-  it('routes toggle to the Remote with the addressed message', async () => {
+  it('routes only a matching retraction to the Remote', async () => {
     const b = await bench()
     await b.fiber.await()
 
     const face = b.entry()!.inject!(sid('s1'))
-    // The seeded item is positive, so a negative toggle replaces it through
-    // put and a positive one retracts it through delete.
-    expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true, rating: 'negative' })
-    expect(await face.toggle(MSG, 'positive')).toEqual({ ok: true, rating: null })
+    // A stale or opposite retraction is a no-op; only the matching rating
+    // reaches delete, so this entry can never bypass the dialog through put.
+    expect(await face.retract(MSG, 'negative')).toEqual({ ok: true })
+    expect(await face.retract(MSG, 'positive')).toEqual({ ok: true })
 
-    expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
-      sessionId: 's1', messageId: MSG, rating: 'negative',
-    })
+    expect(b.calls.filter(call => call.method === 'put')).toHaveLength(0)
     expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
       sessionId: 's1', messageId: MSG,
     })
@@ -292,7 +290,7 @@ describe('ui-message-feedback browser plugin', () => {
     const face = b.entry()!.inject!(sid('s1'))
     const dialog = b.dialogEntry()!.inject!(sid('s1'))
     await face.ensure()
-    face.openDialog(MSG)
+    face.openDialog(MSG, 'negative')
 
     await b.fiber.dispose()
 
@@ -302,7 +300,7 @@ describe('ui-message-feedback browser plugin', () => {
     expect(dialog.hooks.dialog.getSnapshot().target).toBeNull()
     // A disposed controller refuses further mutations, so no request outlives the fiber.
     const before = b.calls.length
-    expect(await face.toggle(MSG, 'negative')).toMatchObject({ ok: false, error: { code: 'disposed' } })
+    expect(await face.retract(MSG, 'negative')).toMatchObject({ ok: false, error: { code: 'disposed' } })
     expect(b.calls).toHaveLength(before)
   })
 

+ 29 - 15
packages/client/ui-message-feedback/tests/controller.client.spec.ts

@@ -126,24 +126,38 @@ describe('MessageFeedbackController', () => {
     expect(puts[1]).not.toHaveProperty('category')
   })
 
-  it('toggle retracts a matching rating, records an absent one, and reports the committed rating', async () => {
+  it('retract deletes a matching rating and ignores an opposite judgment', async () => {
     const { ctx, calls } = fakeRemote({
       list: () => Promise.resolve({ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] } }),
-      put: () => Promise.resolve({ ok: true, value: item({ rating: 'negative', version: version('v2') }) }),
     })
     const controller = new MessageFeedbackController(ctx, SESSION)
 
-    expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true, rating: null })
-    expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true, rating: 'negative' })
+    expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true })
+    expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
 
+    expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
     expect(calls.filter(call => call.method === 'delete')[0]?.request)
       .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v1') })
-    // A replacement stores the bare judgment: the retracted item's note and
-    // category do not carry over.
-    const put = calls.filter(call => call.method === 'put')[0]?.request as Record<string, unknown>
-    expect(put).toMatchObject({ rating: 'negative', ifVersion: null })
-    expect(put).not.toHaveProperty('note')
-    expect(put).not.toHaveProperty('category')
+  })
+
+  it('never turns a queued stale retraction into a bare rating put', async () => {
+    const existing = item({ rating: 'positive', version: version('v1') })
+    const replacement = item({ rating: 'negative', version: version('v2') })
+    const { ctx, calls } = fakeRemote({
+      list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
+      put: () => Promise.resolve({ ok: true, value: replacement }),
+    })
+    const controller = new MessageFeedbackController(ctx, SESSION)
+    await controller.ensure()
+
+    const replaced = controller.rate(MSG, 'negative')
+    const staleRetraction = controller.retract(MSG, 'positive')
+
+    await expect(replaced).resolves.toEqual({ ok: true })
+    await expect(staleRetraction).resolves.toEqual({ ok: true })
+    expect(calls.filter(call => call.method === 'put')).toHaveLength(1)
+    expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
+    expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement)
   })
 
   it('reconciles a version conflict from the authoritative item without refetching', async () => {
@@ -176,7 +190,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.toggle(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
+    expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
     expect(controller.getSnapshot().items.has(MSG)).toBe(false)
   })
 
@@ -187,7 +201,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true, rating: null })
+    expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
 
     expect(calls.filter(call => call.method === 'delete')[0]?.request)
       .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
@@ -402,7 +416,7 @@ describe('MessageFeedbackController', () => {
     })
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
-    const pending = controller.toggle(MSG, 'positive')
+    const pending = controller.retract(MSG, 'positive')
 
     const listener = vi.fn()
     controller.subscribe(listener)
@@ -439,7 +453,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.toggle(MSG, 'positive')).toMatchObject({
+    expect(await controller.retract(MSG, 'positive')).toMatchObject({
       ok: false,
       error: { code: 'session-not-found' },
     })
@@ -547,7 +561,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.toggle(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
+    expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
     expect(controller.getSnapshot().items.has(MSG)).toBe(true)
   })
 })

+ 27 - 13
packages/client/ui-message-feedback/tests/dialog.client.spec.ts

@@ -10,6 +10,7 @@ import type { MessageFeedbackActionResult } from '../src/client/controller.ts'
 import { FeedbackDialogController, type FeedbackSubmit } from '../src/client/dialog.ts'
 
 const MSG = 'm-1' as MessageId
+const MESSAGE_TARGET = { kind: 'message', messageId: MSG, rating: 'positive' } as const
 
 function bench(result: () => Promise<MessageFeedbackActionResult> = () => Promise.resolve({ ok: true })) {
   const submit = vi.fn<FeedbackSubmit>(() => result())
@@ -36,9 +37,9 @@ describe('FeedbackDialogController', () => {
     })
 
     controller.dismiss()
-    controller.open({ kind: 'message', messageId: MSG })
+    controller.open(MESSAGE_TARGET)
     expect(controller.state.getSnapshot()).toMatchObject({
-      target: { kind: 'message', messageId: MSG }, category: null, text: '',
+      target: MESSAGE_TARGET, category: null, text: '',
     })
   })
 
@@ -77,29 +78,39 @@ describe('FeedbackDialogController', () => {
 
   it('submits the message target with the entry', async () => {
     const { controller, submit } = bench()
-    controller.open({ kind: 'message', messageId: MSG })
+    controller.open(MESSAGE_TARGET)
     controller.edit({ category: 'task-result' })
     controller.edit({ text: 'wrong file' })
 
     await controller.submitDraft()
 
-    expect(submit).toHaveBeenCalledWith({ kind: 'message', messageId: MSG }, { text: 'wrong file', category: 'task-result' })
+    expect(submit).toHaveBeenCalledWith(MESSAGE_TARGET, { text: 'wrong file', category: 'task-result' })
     expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
   })
 
   it('keeps the draft open with the failure code when the submission is rejected', async () => {
     const { controller } = bench(() => Promise.resolve({ ok: false, error: { code: 'version-conflict', message: 'changed' } }))
-    controller.open({ kind: 'message', messageId: MSG })
+    controller.open(MESSAGE_TARGET)
     controller.edit({ text: 'draft' })
 
     await controller.submitDraft()
 
     expect(controller.state.getSnapshot()).toMatchObject({
-      target: { kind: 'message', messageId: MSG }, text: 'draft', submitting: false,
+      target: MESSAGE_TARGET, text: 'draft', submitting: false,
       failure: 'version-conflict', toast: 0,
     })
   })
 
+  it('retires the failure toast without closing its draft', async () => {
+    const { controller } = bench(() => Promise.resolve({ ok: false, error: { code: 'version-conflict', message: 'changed' } }))
+    controller.open(MESSAGE_TARGET)
+    await controller.submitDraft()
+
+    controller.dismissFailure()
+    expect(controller.state.getSnapshot().failure).toBeNull()
+    expect(controller.state.getSnapshot().target).toEqual(MESSAGE_TARGET)
+  })
+
   it('freezes the draft and refuses a second submit while one is in flight', async () => {
     let release = (): void => {}
     const gate = new Promise<MessageFeedbackActionResult>((resolve) => { release = () => { resolve({ ok: true }) } })
@@ -125,14 +136,14 @@ describe('FeedbackDialogController', () => {
     const { controller } = bench(() => gate)
     controller.open({ kind: 'session' })
     const pending = controller.submitDraft()
-    controller.open({ kind: 'message', messageId: MSG })
+    controller.open(MESSAGE_TARGET)
     controller.edit({ text: 'new draft' })
 
     release()
     await pending
 
     expect(controller.state.getSnapshot()).toMatchObject({
-      target: { kind: 'message', messageId: MSG }, text: 'new draft', toast: 1,
+      target: MESSAGE_TARGET, text: 'new draft', toast: 1,
     })
   })
 
@@ -152,11 +163,13 @@ describe('FeedbackDialogController', () => {
     expect(controller.state.getSnapshot()).toMatchObject({ target: null, failure: null, submitting: false, toast: 0 })
   })
 
-  it('retires only the toast the view finished showing', () => {
+  it('retires only the toast the view finished showing', async () => {
     const { controller } = bench()
 
-    controller.acknowledge()
-    controller.acknowledge()
+    controller.open({ kind: 'session' })
+    await controller.submitDraft()
+    controller.open({ kind: 'session' })
+    await controller.submitDraft()
     expect(controller.state.getSnapshot().toast).toBe(2)
 
     controller.dismissToast(1)
@@ -165,9 +178,10 @@ describe('FeedbackDialogController', () => {
     expect(controller.state.getSnapshot().toast).toBe(0)
   })
 
-  it('keeps a toast on screen across a dismiss and drops everything on dispose', () => {
+  it('keeps a toast on screen across a dismiss and drops everything on dispose', async () => {
     const { controller } = bench()
-    controller.acknowledge()
+    controller.open({ kind: 'session' })
+    await controller.submitDraft()
     controller.open({ kind: 'session' })
 
     controller.dismiss()

+ 26 - 8
packages/client/ui-message-feedback/tests/feedback-dialog.client.spec.tsx

@@ -3,9 +3,9 @@
  * FeedbackDialog rendering: the modal shows the seven category chips, the
  * detail box, and the hint while a target is open; a chip toggles the
  * category through the injected verb; Submit routes to the controller and
- * stays enabled with an empty draft; a failure code renders its copy; and the
- * acknowledgement toast mounts from the toast sequence and retires through
- * dismissToast once its fade completes.
+ * stays enabled with an empty draft; a failure code renders a warning toast;
+ * and the acknowledgement toast mounts from the toast sequence and retires
+ * through dismissToast once its fade completes.
  */
 import { useSyncExternalStore } from 'react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -31,6 +31,7 @@ function mount(overrides: Partial<FeedbackDialogState> = {}) {
     edit: vi.fn(),
     submit: vi.fn(() => Promise.resolve()),
     dismiss: vi.fn(),
+    dismissFailure: vi.fn(),
     dismissToast: vi.fn(),
   }
   const useDialog = (<T,>(select: (v: FeedbackDialogState) => T): T =>
@@ -40,9 +41,11 @@ function mount(overrides: Partial<FeedbackDialogState> = {}) {
 }
 
 describe('FeedbackDialog', () => {
-  it('discloses conversation-log inclusion in both supported locales', () => {
+  it('owns the conversation-log disclosure and stability category in both supported locales', () => {
     expect(zh['dialog.hint']).toBe('填写详情以帮助我们改进体验,提交内容会包括当前对话的日志')
     expect(en['dialog.hint']).toBe('Add details to help us improve. Your submission will include the current conversation log.')
+    expect(zh['category.service-stability']).toBe('稳定性和速度')
+    expect(en['category.service-stability']).toBe('Stability and speed')
   })
 
   it('renders nothing but the probe while closed with no toast', () => {
@@ -99,17 +102,32 @@ describe('FeedbackDialog', () => {
     expect([...chips].every(chip => chip.hasAttribute('disabled'))).toBe(true)
   })
 
-  it('renders the conflict and size copy for their codes and the generic copy otherwise', () => {
+  it('renders submission failures as toasts outside the dialog', () => {
     const conflict = mount({ failure: 'version-conflict' })
-    expect(conflict.getByRole('status').textContent).toBe(zh['error.conflict'])
+    const conflictToast = conflict.getByRole('alert')
+    expect(conflictToast.textContent).toBe(zh['error.conflict'])
+    expect(conflict.getByRole('dialog').contains(conflictToast)).toBe(false)
     cleanup()
 
     const oversized = mount({ failure: 'note-too-large' })
-    expect(oversized.getByRole('status').textContent).toBe(zh['error.noteTooLarge'])
+    expect(oversized.getByRole('alert').textContent).toBe(zh['error.noteTooLarge'])
     cleanup()
 
     const other = mount({ failure: 'session-not-found' })
-    expect(other.getByRole('status').textContent).toBe(zh['error.generic'])
+    expect(other.getByRole('alert').textContent).toBe(zh['error.generic'])
+  })
+
+  it('retires a submission-failure toast after its extended hold', () => {
+    vi.useFakeTimers()
+    try {
+      const ui = mount({ failure: 'version-conflict' })
+
+      act(() => { vi.advanceTimersByTime(7000) })
+
+      expect(ui.dismissFailure).toHaveBeenCalledTimes(1)
+    } finally {
+      vi.useRealTimers()
+    }
   })
 
   it('retires the toast when the entry unmounts, so a Session switch does not replay it', () => {

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