Quellcode durchsuchen

feat(feedback): open one feedback dialog from /feedback and Dislike, with categories and a toast

Dislike and a bare /feedback open the Session's feedback dialog, which
collects one of seven fixed categories and an optional description and
records through the messageFeedback put or the new sessionFeedback
Remote; Like records at once. A recorded rating shows the filled glyph
and every submission is acknowledged by a toast. feedback/record and
MessageFeedbackItem carry the optional category; the note popover, its
layout scenario, and the clearNote/clear verbs are removed. ui-commands
gains the action command kind the /feedback decoration runs as.

Closes #3515
creatixchu vor 2 Tagen
Ursprung
Commit
f2f9700de2
100 geänderte Dateien mit 2291 neuen und 1844 gelöschten Zeilen
  1. 6 0
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml
  2. 33 0
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md
  3. 33 0
      .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md
  4. 12 7
      apps/web/tests/feedback-release.e2e.ts
  5. 0 336
      apps/web/tests/message-feedback-layout.e2e.ts
  6. 30 16
      apps/web/tests/message-feedback.e2e.ts
  7. 0 1
      apps/web/tsconfig.json
  8. 2 2
      docs/capability-seams.i18n.yaml
  9. 4 0
      docs/capability-seams.md
  10. 4 0
      docs/capability-seams.zh.md
  11. 2 2
      docs/config-catalog.i18n.yaml
  12. 1 1
      docs/config-catalog.md
  13. 1 1
      docs/config-catalog.zh.md
  14. 2 2
      docs/event-producer-consumer.i18n.yaml
  15. 1 1
      docs/event-producer-consumer.md
  16. 1 1
      docs/event-producer-consumer.zh.md
  17. 2 2
      docs/module-graph.i18n.yaml
  18. 16 14
      docs/module-graph.md
  19. 16 14
      docs/module-graph.zh.md
  20. 2 2
      docs/persistence-catalog.i18n.yaml
  21. 4 4
      docs/persistence-catalog.md
  22. 2 2
      docs/persistence-catalog.zh.md
  23. 2 2
      docs/subsystems/feedback.i18n.yaml
  24. 96 6
      docs/subsystems/feedback.md
  25. 96 6
      docs/subsystems/feedback.zh.md
  26. 2 1
      packages/api/remotes/package.json
  27. 3 1
      packages/api/remotes/src/client/index.ts
  28. 3 0
      packages/api/remotes/tsconfig.client.json
  29. 3 2
      packages/bundle/web-app/cordis.patch.yml
  30. 2 2
      packages/client/README.i18n.yaml
  31. 1 1
      packages/client/README.md
  32. 1 1
      packages/client/README.zh.md
  33. 2 2
      packages/client/ui-commands/README.i18n.yaml
  34. 2 2
      packages/client/ui-commands/README.md
  35. 2 2
      packages/client/ui-commands/README.zh.md
  36. 21 3
      packages/client/ui-commands/src/client/contract.ts
  37. 2 1
      packages/client/ui-commands/src/client/index.ts
  38. 26 17
      packages/client/ui-commands/src/client/service.ts
  39. 39 2
      packages/client/ui-commands/tests/service.client.spec.ts
  40. 2 2
      packages/client/ui-message-feedback/README.i18n.yaml
  41. 15 10
      packages/client/ui-message-feedback/README.md
  42. 16 11
      packages/client/ui-message-feedback/README.zh.md
  43. 7 3
      packages/client/ui-message-feedback/package.json
  44. 93 0
      packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css
  45. 110 0
      packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx
  46. 0 102
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
  47. 53 248
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx
  48. 37 54
      packages/client/ui-message-feedback/src/client/controller.ts
  49. 129 0
      packages/client/ui-message-feedback/src/client/dialog.ts
  50. 77 46
      packages/client/ui-message-feedback/src/client/index.ts
  51. 27 13
      packages/client/ui-message-feedback/src/client/locales.ts
  52. 44 24
      packages/client/ui-message-feedback/src/client/slots.ts
  53. 44 0
      packages/client/ui-message-feedback/src/client/surface.ts
  54. 141 32
      packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx
  55. 30 99
      packages/client/ui-message-feedback/tests/controller.client.spec.ts
  56. 182 0
      packages/client/ui-message-feedback/tests/dialog.client.spec.ts
  57. 122 0
      packages/client/ui-message-feedback/tests/feedback-dialog.client.spec.tsx
  58. 84 547
      packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx
  59. 28 42
      packages/client/ui-message-feedback/tests/styles.client.spec.ts
  60. 9 0
      packages/client/ui-message-feedback/tsconfig.json
  61. 13 8
      packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
  62. 15 11
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  63. 2 3
      packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx
  64. 1 0
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  65. 35 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  66. 2 2
      packages/feedback/README.i18n.yaml
  67. 2 2
      packages/feedback/README.md
  68. 2 2
      packages/feedback/README.zh.md
  69. 2 2
      packages/feedback/command-feedback/README.i18n.yaml
  70. 33 13
      packages/feedback/command-feedback/README.md
  71. 33 13
      packages/feedback/command-feedback/README.zh.md
  72. 26 4
      packages/feedback/command-feedback/package.json
  73. 63 20
      packages/feedback/command-feedback/src/index.ts
  74. 83 0
      packages/feedback/command-feedback/src/types.ts
  75. 69 8
      packages/feedback/command-feedback/tests/command-feedback.spec.ts
  76. 3 0
      packages/feedback/command-feedback/tsconfig.json
  77. 2 2
      packages/feedback/message-feedback/README.i18n.yaml
  78. 5 5
      packages/feedback/message-feedback/README.md
  79. 5 5
      packages/feedback/message-feedback/README.zh.md
  80. 4 2
      packages/feedback/message-feedback/package.json
  81. 5 1
      packages/feedback/message-feedback/src/index.ts
  82. 5 0
      packages/feedback/message-feedback/src/types.ts
  83. 56 0
      packages/feedback/message-feedback/tests/message-feedback.spec.ts
  84. 3 0
      packages/feedback/message-feedback/tsconfig.json
  85. 1 1
      packages/session/session-log-deepseek/tests/feedback-composition.spec.ts
  86. 1 1
      packages/session/session-telemetry-otel/tests/egress.spec.ts
  87. 1 1
      packages/session/session-telemetry-otel/tests/fixtures/driver.ts
  88. 16 16
      packages/session/session-telemetry-otel/tests/otel.spec.ts
  89. 22 0
      pnpm-lock.yaml
  90. 3 0
      scripts/gen-cordis-catalog.ts
  91. 7 0
      scripts/gen-doc-graphs.ts
  92. 1 1
      scripts/smoke-python-runtime.py
  93. 2 1
      scripts/snapshots/python-sdk-single-exe/advanced/session.v2.jsonl
  94. 30 0
      scripts/type-equiv.manifest.json
  95. 1 1
      snapshots/sdk/sdk.snapshot.ts
  96. 4 2
      snapshots/sdk/text-turn/feedback-producer.mjs
  97. 3 2
      snapshots/sdk/text-turn/session.v2.jsonl
  98. 3 2
      snapshots/web/feedback-release/feedback-release.expected.json
  99. 0 10
      snapshots/web/message-feedback-layout/geometry.expected.md
  100. 0 9
      snapshots/web/message-feedback-layout/snapshot.yml

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

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

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

@@ -0,0 +1,33 @@
+# Agent Note: Feedback dialog, categories, and the acknowledgement toast
+
+Status: implemented
+
+English | [中文](2026-09-08-feedback-dialog-and-categories.zh.md)
+
+## Problem
+
+The Web client had two disconnected feedback paths with no visible outcome. `/feedback <text>` recorded a Session remark and rendered an acknowledgement row in the transcript; the Like/Dislike pair recorded a rating at once, with a note popover anchored under the row for free text. Neither path told the user what was submitted or where it went, neither collected a category, and a Dislike, the case in which a user is most willing to explain, asked nothing. Issue #3515 and the design doc for it ask for one dialog reachable from the composer menu, from a bare `/feedback`, and from Dislike, with seven fixed categories, an optional description, a success toast, and a filled glyph for a recorded rating, while Like keeps recording at once.
+
+## Decision
+
+`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.
+
+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.
+
+## Alternatives considered
+
+**Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by.
+
+**Submit the dialog through the command plane as `/feedback <text>`.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint.
+
+**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 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.
+
+## Consequences
+
+Adding a category means adding it to the union, to the Host tuple, to the dialog's chip record, and to the `feedback` dictionaries; the client bundle purity gate forbids a value import from a Host package, so the dialog restates the taxonomy as a `Record<FeedbackCategory, true>` whose key order is the chip order and whose completeness the compiler checks. The frozen released-v2 payload inventory still lists `feedback/record` as `text` only: it governs artifacts migrated from older generations, which cannot carry the new members, while equal-version restoration applies the installed vocabulary. The `message-feedback-layout` web scenario that pinned the popover's geometry is deleted with the popover. The message-feedback and feedback-release web goldens and the feedback subsystem doc changed in the same PR; the `/feedback <text>` command path and its SDK expected outputs are unchanged.

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

@@ -0,0 +1,33 @@
+# Agent Note: 反馈弹窗、分类与确认 toast
+
+Status: implemented
+
+[English](2026-09-08-feedback-dialog-and-categories.md) | 中文
+
+## 问题
+
+Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`/feedback <text>` 记录一条 Session 备注并在转录里渲染一行确认;赞踩对立即记录评分,自由文本则通过锚定在该行下方的备注浮层填写。两条路径都不告诉用户提交了什么、去了哪里,都不收集分类,而点踩这个用户最愿意解释的场景什么也不问。Issue #3515 及其设计稿要求:一个弹窗,可从输入框菜单、不带文本的 `/feedback` 和点踩三处打开,带七个固定分类、可选描述、成功 toast,以及已记录评分的实心图标;点赞保持立即记录。
+
+## 决策
+
+`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` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回。
+
+弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开。
+
+## 考虑过的替代方案
+
+**把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。
+
+**让弹窗经命令平面以 `/feedback <text>` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。
+
+**在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。
+
+**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径。
+
+**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可。
+
+## 后果
+
+新增分类意味着把它加进联合类型、宿主元组、弹窗的标签记录和 `feedback` 词典;客户端打包纯度门禁止从宿主包做值导入,因此弹窗以 `Record<FeedbackCategory, true>` 重述分类表,键的顺序就是标签顺序,完整性由编译器检查。冻结的已发布 v2 载荷清单仍把 `feedback/record` 列为仅有 `text`:它管辖从旧代际迁移来的产物,那些产物不可能携带新成员,而同版本恢复应用的是已安装词汇。固定浮层几何的 `message-feedback-layout` Web 场景随浮层一起删除。message-feedback 与 feedback-release 的 Web 期望输出和反馈子系统文档在同一个 PR 中更新;`/feedback <text>` 命令路径及其 SDK 期望输出不变。

+ 12 - 7
apps/web/tests/feedback-release.e2e.ts

@@ -219,14 +219,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     const rated = page.getByRole('button', { name: 'Remove rating' })
     await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true')
     await expectFeedbackRelease('feedback/message-put', 1)
-    await page.getByRole('button', { name: 'Add a note' }).click()
-    await page.getByRole('textbox', { name: 'Feedback note' }).fill('Read both files before answering.')
+    // Dislike collects the category and note in the 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)
-    await page.getByRole('button', { name: 'Save', exact: true }).click()
-    await page.getByText('Read both files before answering.', { exact: true }).waitFor()
+    await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
+    await expect.poll(() => dialog.count()).toBe(0)
     await expectFeedbackRelease('feedback/message-put', 2)
     await rated.click()
-    await expect.poll(() => like.getAttribute('aria-pressed')).toBe('false')
+    await expect.poll(() => page.getByRole('button', { name: 'Bad response' }).getAttribute('aria-pressed')).toBe('false')
     await expectFeedbackRelease('feedback/message-delete', 1)
     const agent = scaffold.ctx.agents.get(sessionId)
     if (agent === undefined) throw new Error('feedback session has no active agent')
@@ -238,7 +241,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     ])
     expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([
       { data: { sessionId, item: { rating: 'positive' } } },
-      { data: { sessionId, item: { rating: 'positive', note: 'Read both files before answering.' } } },
+      { 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 } }])
     expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
@@ -250,7 +253,9 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     const feedback = events.flatMap<Record<string, string | undefined>>((event) => {
       switch (event.type) {
         case 'feedback/record': return [{ type: event.type, text: event.data.text }]
-        case 'feedback/message-put': return [{ type: event.type, rating: event.data.item.rating, note: event.data.item.note }]
+        case 'feedback/message-put': return [{
+          type: event.type, rating: event.data.item.rating, note: event.data.item.note, category: event.data.item.category,
+        }]
         case 'feedback/message-delete': return [{ type: event.type }]
         default: return []
       }

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

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

+ 30 - 16
apps/web/tests/message-feedback.e2e.ts

@@ -1,11 +1,13 @@
 // Keyless browser regression for durable per-message feedback. Cold-seeds a
-// settled two-turn transcript (zero model calls), rates one assistant message,
-// attaches a note, proves both survive a full page reload from the Host's
-// message-feedback sidecar, then retracts the rating.
+// 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.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
+import { SessionId } from '@deepseek-ai/dsh-session'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
   acknowledgeReloadConnectionLoss, launchWebScaffold,
@@ -56,7 +58,7 @@ describe('web e2e: durable per-message feedback', () => {
     await sessionRow.click()
   }
 
-  it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => {
+  it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback'))
     await openSeededSession()
 
@@ -69,18 +71,23 @@ describe('web e2e: durable per-message feedback', () => {
     await like.scrollIntoViewIfNeeded()
     await like.hover()
     await like.click()
-    // A recorded rating relabels the button to what the next click would do,
-    // so the pressed control is addressed by the retract label from here on.
+    // A Like records at once and is acknowledged; a recorded rating relabels
+    // the button to what the next click would do.
+    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')
 
-    // A rated message offers the note editor; an unrated one does not.
-    await page.getByRole('button', { name: 'Add a note' }).first().click()
-    const editor = page.getByRole('textbox', { name: 'Feedback note' })
-    await editor.fill(NOTE)
-    await page.getByRole('button', { name: 'Save', exact: true }).click()
-    await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0)
-    await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
+    // Dislike opens the Session's feedback dialog; its submission replaces
+    // the Like with a negative judgment carrying the category and note.
+    await page.getByRole('button', { name: 'Bad response' }).first().click()
+    const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Task result', exact: true }).click()
+    await dialog.getByRole('textbox', { name: 'Feedback 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')
+    await expect.poll(() => like.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false')
 
     // The durable assertion: a cold browser re-reads the sidecar over the wire.
     const warningStart = tripwire.warnings.length
@@ -103,15 +110,22 @@ describe('web e2e: durable per-message feedback', () => {
     await restored.scrollIntoViewIfNeeded()
     await restored.hover()
     await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true')
-    await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 })
+    // The retract label sits on the Dislike side: the Like stays unpressed.
+    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' })
 
     // Re-clicking the active rating retracts it, and the note goes with it.
     await restored.click()
     await expect.poll(
-      () => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'),
+      () => page.getByRole('button', { name: 'Bad response' }).first().getAttribute('aria-pressed'),
       { timeout: 10_000 },
     ).toBe('false')
-    await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    const last = agent.session.snapshotEvents().at(-1)
+    expect(last?.type).toBe('feedback/message-delete')
   }, 90_000)
 
   it.skipIf(MODE === 'record')('kept the console clean', () => {

+ 0 - 1
apps/web/tsconfig.json

@@ -70,7 +70,6 @@
     "tests/message-actions.e2e.ts",
     "tests/open-in-app-ssh.e2e.ts",
     "tests/message-feedback.e2e.ts",
-    "tests/message-feedback-layout.e2e.ts",
     "tests/markdown-images.e2e.ts",
     "tests/reference-composer.e2e.ts",
     "tests/markdown-wide-table.e2e.ts",

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: a47ec285e248c4d969e49bc941d5c93faef7b65e
-capability-seams.zh.md: 42e7105611f21f74ee0d8088e06e5c7007af9376
+capability-seams.md: 9cc7a6fc50b81e9f82d58600c84d1ff6494634c9
+capability-seams.zh.md: e898bdf3c2c728a1bae427bbb737eb5269a1c500

+ 4 - 0
docs/capability-seams.md

@@ -82,6 +82,8 @@ flowchart LR
   svc_storageDomain["ctx.storageDomain<br/>Domain data facility"]
   pkg_workspace["workspace"]
   svc_messageFeedback["ctx.messageFeedback<br/>Lifecycle-bound message feedback"]
+  pkg_command_feedback["command-feedback"]
+  svc_sessionFeedback["ctx.sessionFeedback<br/>Session-level feedback recorder"]
   svc_workspaceRegistry["ctx.workspaceRegistry<br/>Workspace entity registry"]
   svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
   pkg_session_reference["session-reference"]
@@ -244,6 +246,7 @@ flowchart LR
   pkg_client_modules --> svc_clientModules
   pkg_code_runtime --> svc_codeRuntime
   pkg_code_runtime_worker_thread --> svc_codeRuntime
+  pkg_command_feedback --> svc_sessionFeedback
   pkg_commands --> svc_commands
   pkg_compaction --> svc_compaction
   pkg_compaction_basic --> svc_compaction
@@ -498,6 +501,7 @@ flowchart LR
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
 | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy. |
+| `ctx.sessionFeedback` | `core` | [`command-feedback`](../packages/feedback/command-feedback) | - | - | - | Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer. |
 | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
 | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
 | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents. |

+ 4 - 0
docs/capability-seams.zh.md

@@ -84,6 +84,8 @@ flowchart LR
   svc_storageDomain["ctx.storageDomain<br/>Domain data facility"]
   pkg_workspace["workspace"]
   svc_messageFeedback["ctx.messageFeedback<br/>Lifecycle-bound message feedback"]
+  pkg_command_feedback["command-feedback"]
+  svc_sessionFeedback["ctx.sessionFeedback<br/>Session-level feedback recorder"]
   svc_workspaceRegistry["ctx.workspaceRegistry<br/>Workspace entity registry"]
   svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
   pkg_session_reference["session-reference"]
@@ -246,6 +248,7 @@ flowchart LR
   pkg_client_modules --> svc_clientModules
   pkg_code_runtime --> svc_codeRuntime
   pkg_code_runtime_worker_thread --> svc_codeRuntime
+  pkg_command_feedback --> svc_sessionFeedback
   pkg_commands --> svc_commands
   pkg_compaction --> svc_compaction
   pkg_compaction_basic --> svc_compaction
@@ -500,6 +503,7 @@ flowchart LR
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
 | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有权威 Session 日志中的逐 assistant 消息反馈、目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约。反馈不进入模型历史;日志导出遵循消费方策略。 |
+| `ctx.sessionFeedback` | `core` | [`command-feedback`](../packages/feedback/command-feedback) | - | - | - | 通过 Host 一元 Remote 契约在 live Session 上把一条带分类的 Session 级评价记录为仅写日志的 feedback/record 事件;/feedback 命令共用同一个生产方。 |
 | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 |
 | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 |
 | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | 该接口返回 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问与排序,但不读取文件内容。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: a1cc7d72898367cb4a752c10355d5c4808d1c6c7
-config-catalog.zh.md: 88e656f0ac0a8132638259e0ab089888b633d446
+config-catalog.md: d24d7a605e5ba2234285ebe56e6f3bd86541eaaa
+config-catalog.zh.md: e16d4f7b1206a274f566dd2b42c4eaa0344b2126

+ 1 - 1
docs/config-catalog.md

@@ -1590,7 +1590,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/feedback/message-feedback/src/index.ts:39`](../packages/feedback/message-feedback/src/index.ts)
+Source: [`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts)
 
 <a id="deepseek-aidsh-permission-presets"></a>
 

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

@@ -1592,7 +1592,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/feedback/message-feedback/src/index.ts:39`](../packages/feedback/message-feedback/src/index.ts)
+来源:[`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts)
 
 <a id="deepseek-aidsh-permission-presets"></a>
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 449b7d8f0fb55515e7f1f028e151ce1b26862e92
-event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2
+event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4
+event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b

+ 1 - 1
docs/event-producer-consumer.md

@@ -39,7 +39,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
 | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
-| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
+| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -41,7 +41,7 @@
 | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
 | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
-| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
+| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 45e3490e95c86b025fde3f89ef3243b98625d012
-module-graph.zh.md: aacce9fb513e243a1ec27955d34aa82a2a6749be
+module-graph.md: 24a53712e3956d2f701b0e0db27b65a6c1c1dd3e
+module-graph.zh.md: dc65c4516ed803216fbb3a1cd5a7369115fad498

+ 16 - 14
docs/module-graph.md

@@ -474,11 +474,6 @@ flowchart TD
   pkg_spill_local --> pkg_spill
   pkg_session_log_export --> pkg_session
   pkg_session_log_export --> pkg_session_persistence
-  pkg_message_feedback --> pkg_brand
-  pkg_message_feedback --> pkg_llm
-  pkg_message_feedback --> pkg_session
-  pkg_message_feedback --> pkg_session_persistence
-  pkg_message_feedback --> pkg_typert_protocol
   pkg_sandbox_local --> pkg_llm
   pkg_sandbox_local --> pkg_sandbox
   pkg_sandbox_local --> pkg_session
@@ -668,6 +663,7 @@ flowchart TD
   pkg_command_feedback --> pkg_anonymous_user_id
   pkg_command_feedback --> pkg_commands
   pkg_command_feedback --> pkg_session
+  pkg_command_feedback --> pkg_typert_protocol
   pkg_permission_presets --> pkg_commands
   pkg_permission_presets --> pkg_invariants
   pkg_permission_presets --> pkg_sandbox
@@ -803,6 +799,12 @@ flowchart TD
   pkg_cordis_host_runner --> pkg_session
   pkg_cordis_host_runner --> pkg_tools
   pkg_cordis_host_runner --> pkg_typert_protocol
+  pkg_message_feedback --> pkg_brand
+  pkg_message_feedback --> pkg_command_feedback
+  pkg_message_feedback --> pkg_llm
+  pkg_message_feedback --> pkg_session
+  pkg_message_feedback --> pkg_session_persistence
+  pkg_message_feedback --> pkg_typert_protocol
   pkg_repeat_tool_reminder --> pkg_agent
   pkg_repeat_tool_reminder --> pkg_tools
   pkg_tool_call_timeout_policy --> pkg_llm
@@ -852,12 +854,6 @@ flowchart TD
   pkg_session_checkpoint_policy --> pkg_session
   pkg_session_checkpoint_policy --> pkg_session_persistence
   pkg_session_checkpoint_policy --> pkg_tools
-  pkg_session_telemetry_otel --> pkg_anonymous_user_id
-  pkg_session_telemetry_otel --> pkg_command_feedback
-  pkg_session_telemetry_otel --> pkg_llm
-  pkg_session_telemetry_otel --> pkg_message_feedback
-  pkg_session_telemetry_otel --> pkg_session
-  pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_session_title_all_prompts_llm --> pkg_llm
   pkg_session_title_all_prompts_llm --> pkg_session
   pkg_session_title_all_prompts_llm --> pkg_session_title
@@ -937,6 +933,12 @@ flowchart TD
   pkg_host_plugin_inventory --> pkg_agent_presets
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_typert_protocol
+  pkg_session_telemetry_otel --> pkg_anonymous_user_id
+  pkg_session_telemetry_otel --> pkg_command_feedback
+  pkg_session_telemetry_otel --> pkg_llm
+  pkg_session_telemetry_otel --> pkg_message_feedback
+  pkg_session_telemetry_otel --> pkg_session
+  pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_tool_bash --> pkg_agent
   pkg_tool_bash --> pkg_jobs
   pkg_tool_bash --> pkg_llm
@@ -1321,7 +1323,6 @@ flowchart TD
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
@@ -1364,7 +1365,7 @@ flowchart TD
 | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session) |
+| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) |
 | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) |
 | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) |
@@ -1389,6 +1390,7 @@ flowchart TD
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) |
 | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
 | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
@@ -1398,7 +1400,6 @@ flowchart TD
 | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
@@ -1415,6 +1416,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 16 - 14
docs/module-graph.zh.md

@@ -476,11 +476,6 @@ flowchart TD
   pkg_spill_local --> pkg_spill
   pkg_session_log_export --> pkg_session
   pkg_session_log_export --> pkg_session_persistence
-  pkg_message_feedback --> pkg_brand
-  pkg_message_feedback --> pkg_llm
-  pkg_message_feedback --> pkg_session
-  pkg_message_feedback --> pkg_session_persistence
-  pkg_message_feedback --> pkg_typert_protocol
   pkg_sandbox_local --> pkg_llm
   pkg_sandbox_local --> pkg_sandbox
   pkg_sandbox_local --> pkg_session
@@ -670,6 +665,7 @@ flowchart TD
   pkg_command_feedback --> pkg_anonymous_user_id
   pkg_command_feedback --> pkg_commands
   pkg_command_feedback --> pkg_session
+  pkg_command_feedback --> pkg_typert_protocol
   pkg_permission_presets --> pkg_commands
   pkg_permission_presets --> pkg_invariants
   pkg_permission_presets --> pkg_sandbox
@@ -805,6 +801,12 @@ flowchart TD
   pkg_cordis_host_runner --> pkg_session
   pkg_cordis_host_runner --> pkg_tools
   pkg_cordis_host_runner --> pkg_typert_protocol
+  pkg_message_feedback --> pkg_brand
+  pkg_message_feedback --> pkg_command_feedback
+  pkg_message_feedback --> pkg_llm
+  pkg_message_feedback --> pkg_session
+  pkg_message_feedback --> pkg_session_persistence
+  pkg_message_feedback --> pkg_typert_protocol
   pkg_repeat_tool_reminder --> pkg_agent
   pkg_repeat_tool_reminder --> pkg_tools
   pkg_tool_call_timeout_policy --> pkg_llm
@@ -854,12 +856,6 @@ flowchart TD
   pkg_session_checkpoint_policy --> pkg_session
   pkg_session_checkpoint_policy --> pkg_session_persistence
   pkg_session_checkpoint_policy --> pkg_tools
-  pkg_session_telemetry_otel --> pkg_anonymous_user_id
-  pkg_session_telemetry_otel --> pkg_command_feedback
-  pkg_session_telemetry_otel --> pkg_llm
-  pkg_session_telemetry_otel --> pkg_message_feedback
-  pkg_session_telemetry_otel --> pkg_session
-  pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_session_title_all_prompts_llm --> pkg_llm
   pkg_session_title_all_prompts_llm --> pkg_session
   pkg_session_title_all_prompts_llm --> pkg_session_title
@@ -939,6 +935,12 @@ flowchart TD
   pkg_host_plugin_inventory --> pkg_agent_presets
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_typert_protocol
+  pkg_session_telemetry_otel --> pkg_anonymous_user_id
+  pkg_session_telemetry_otel --> pkg_command_feedback
+  pkg_session_telemetry_otel --> pkg_llm
+  pkg_session_telemetry_otel --> pkg_message_feedback
+  pkg_session_telemetry_otel --> pkg_session
+  pkg_session_telemetry_otel --> pkg_session_telemetry
   pkg_tool_bash --> pkg_agent
   pkg_tool_bash --> pkg_jobs
   pkg_tool_bash --> pkg_llm
@@ -1323,7 +1325,6 @@ flowchart TD
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
@@ -1366,7 +1367,7 @@ flowchart TD
 | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session) |
+| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) |
 | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) |
 | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) |
@@ -1391,6 +1392,7 @@ flowchart TD
 | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) |
 | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
 | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
@@ -1400,7 +1402,6 @@ flowchart TD
 | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
 | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
@@ -1417,6 +1418,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f
-persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b
+persistence-catalog.md: c83e98e2722430b29967dcb0cbc7052834c73ecc
+persistence-catalog.zh.md: 187c39797d94e6c38ca38833281fb3650fe6463e

+ 4 - 4
docs/persistence-catalog.md

@@ -422,7 +422,7 @@ Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present
 'feedback/message-delete': MessageFeedbackDelete
 ```
 
-Source: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedback/message-feedback/src/types.ts)
+Source: [`packages/feedback/message-feedback/src/types.ts:58`](../packages/feedback/message-feedback/src/types.ts)
 
 <a id="feedbackmessage-put--log-only"></a>
 
@@ -433,7 +433,7 @@ Source: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedb
 'feedback/message-put': MessageFeedbackPut
 ```
 
-Source: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedback/message-feedback/src/types.ts)
+Source: [`packages/feedback/message-feedback/src/types.ts:56`](../packages/feedback/message-feedback/src/types.ts)
 
 <a id="feedbackrecord--log-only"></a>
 
@@ -444,10 +444,10 @@ Source: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedb
  * One recorded human remark about this session. Log-only and independent
  * of its trigger; it never enters model context or derived history.
  */
-'feedback/record': { text: string }
+'feedback/record': FeedbackRecord
 ```
 
-Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts)
+Source: [`packages/feedback/command-feedback/src/types.ts:54`](../packages/feedback/command-feedback/src/types.ts)
 
 ### `goal/*`
 

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

@@ -446,10 +446,10 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
  * One recorded human remark about this session. Log-only and independent
  * of its trigger; it never enters model context or derived history.
  */
-'feedback/record': { text: string }
+'feedback/record': FeedbackRecord
 ```
 
-来源:[`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts)
+来源:[`packages/feedback/command-feedback/src/types.ts:54`](../packages/feedback/command-feedback/src/types.ts)
 
 ### `goal/*`
 

+ 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: c538fac50b0c75e43f761a3b58b385840b83e4ef
-feedback.zh.md: 4a77f9c0396f3635755c301a80ccaefe398d5ad6
+feedback.md: b3dec08a88a5c8373b06ac62cbbe23e18c6da12a
+feedback.zh.md: 4e38f6d951904abe253bfd160dc5d26c252e5ab0

+ 96 - 6
docs/subsystems/feedback.md

@@ -2,7 +2,7 @@
 
 English | [中文](feedback.zh.md)
 
-[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. The canonical Session log stores `feedback/message-put` and `feedback/message-delete`; the immutable Session-level remark remains `feedback/record`. All three are log-only events that never enter model context.
+[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. The canonical Session log stores `feedback/message-put` and `feedback/message-delete`; the immutable Session-level remark remains `feedback/record`, owned by [`@deepseek-ai/dsh-command-feedback`](../../packages/feedback/command-feedback) together with the `FeedbackCategory` taxonomy both kinds of feedback file under. All three are log-only events that never enter model context.
 
 Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
 
@@ -27,6 +27,8 @@ interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
+  /** Category the human filed a negative judgment under. */
+  readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
   /** Host-assigned creation time in Unix epoch milliseconds. */
@@ -83,6 +85,8 @@ interface MessageFeedbackPutRequest {
   readonly rating: MessageFeedbackRating
   /** Optional non-blank explanation. */
   readonly note?: string
+  /** Optional category; absent keeps the item uncategorized. */
+  readonly category?: FeedbackCategory
   /** Observed item version, or `null` to require that no item exists. */
   readonly ifVersion: MessageFeedbackVersion | null
 }
@@ -203,11 +207,76 @@ type MessageFeedbackDeleteResult =
   | MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
 ```
 
+## Session feedback types
+
+Source: [`packages/feedback/command-feedback/src/types.ts`](../../packages/feedback/command-feedback/src/types.ts)
+
+```ts type-equiv
+/** One of the fixed feedback categories; the ids are durable log vocabulary. */
+type FeedbackCategory =
+  | 'task-result'
+  | 'instruction-following'
+  | 'product-interaction'
+  | 'service-stability'
+  | 'resource-cost'
+  | 'security-privacy-permission'
+  | 'other'
+```
+
+```ts type-equiv
+/**
+ * One recorded human remark about a Session. Both members are optional: a
+ * submission with neither still records that the human asked for the
+ * Session to be reviewed, which is what authorizes log delivery.
+ */
+interface FeedbackRecord {
+  /** Free-text remark with surrounding whitespace removed; never empty when present. */
+  readonly text?: string
+  /** Category the human filed the remark under. */
+  readonly category?: FeedbackCategory
+}
+```
+
+```ts type-equiv
+/** Record one Session-level remark through the Host Remote. */
+interface SessionFeedbackRecordRequest {
+  /** Live Session the remark describes. */
+  readonly sessionId: SessionId
+  /** Free-text remark; blank text is recorded as absent. */
+  readonly text?: string
+  /** Category the human filed the remark under. */
+  readonly category?: FeedbackCategory
+}
+```
+
+```ts type-equiv
+/** Stable postcondition of a recorded remark. */
+interface SessionFeedbackRecordValue {
+  /** The remark is appended to the Session log; flushing follows the Session's own schedule. */
+  readonly recorded: true
+}
+```
+
+```ts type-equiv
+/** No live Session carries the requested id. */
+interface SessionFeedbackSessionNotFound {
+  readonly code: 'session-not-found'
+  readonly sessionId: SessionId
+}
+```
+
+```ts type-equiv
+/** Result returned by the `sessionFeedback.record` operation. */
+type SessionFeedbackRecordResult =
+  | { readonly ok: true; readonly value: SessionFeedbackRecordValue }
+  | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound }
+```
+
 ## Data and concurrency
 
-Current items are folded from canonical feedback events whose payload `sessionId` matches the owning Session. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them.
+Current items are folded from canonical feedback events whose payload `sessionId` matches the owning Session. Each item carries a positive or negative rating, an optional note, an optional category, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them.
 
-`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue serializes reads and mutations; cold mutations hold a persistence write handle across read, comparison, append, and flush. Matching no-ops append no event.
+`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op (a put repeating the stored rating, note, and category). A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue serializes reads and mutations; cold mutations hold a persistence write handle across read, comparison, append, and flush. Matching no-ops append no event.
 
 ## Target and lifecycle authority
 
@@ -217,7 +286,7 @@ Fork seeds can contain parent feedback events, but their payload retains the par
 
 ## Persistence and Remote contract
 
-Successful message-feedback mutations await canonical persistence: live operations append through the owning Session and require a participating `ctx.sessions.flush` listener; cold operations append and flush through their write handle. Persistence failures propagate rather than reporting success. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; the generated Cordis API below is the method-level authority.
+Successful message-feedback mutations await canonical persistence: live operations append through the owning Session and require a participating `ctx.sessions.flush` listener; cold operations append and flush through their write handle. Persistence failures propagate rather than reporting success. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; `command-feedback` publishes `sessionFeedback.record` the same way for Session-level remarks on live Sessions. The generated Cordis API below is the method-level authority.
 
 Plugin disposal closes operation admission and drains accepted per-Session queue work.
 
@@ -225,12 +294,14 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session
 
 ## Web surface
 
-[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` contribution, so the plugin calls `ctx.remote.messageFeedback` and never touches the transport.
+[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` and `sessionFeedback` contributions, so the plugin calls `ctx.remote.messageFeedback` and `ctx.remote.sessionFeedback` and never touches the transport.
 
 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.
 
+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.
+
 ## Boundaries and limitations
 
 - The operation queue is process-local; cold writer exclusion relies on the selected persistence provider.
@@ -240,7 +311,8 @@ One `MessageFeedbackController` per Session backs every message control in that
 - The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary.
 - The Web controls appear in the chat view only. The trajectory and waterfall views render no feedback entry even though their assistant nodes carry the same `messageId`.
 - The Web controller does not consume feedback log events, so a second tab's rating becomes visible on reconnect or on the next conflict reply rather than immediately.
-- The note editor does not pre-check `maxNoteBytes`; an oversized note fails on save with `note-too-large` rather than while typing.
+- The dialog does not pre-check `maxNoteBytes`; an oversized description fails on submit with `note-too-large` rather than while typing.
+- `sessionFeedback.record` serves live Sessions only and answers `session-not-found` otherwise; the dialog reports that failure when its Session retires while it is open.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -282,6 +354,24 @@ Session-log service; cold operations never construct a Session or Agent.
 
 Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts)
 
+<a id="ctxsessionfeedback--sessionfeedbackservice"></a>
+
+### `ctx.sessionFeedback` — `SessionFeedbackService`
+
+Host Remote through which a product surface records a Session-level remark.
+
+```ts cordis-catalog
+/**
+ * Record one remark on a live Session.
+ * @param request - target Session plus the optional text and category.
+ * @returns the recorded postcondition, or `session-not-found` when no live
+ * Session carries the id.
+ */
+@Remote('record') record(request: SessionFeedbackRecordRequest): Promise<SessionFeedbackRecordResult>
+```
+
+Source: [`packages/feedback/command-feedback/src/index.ts`](../../packages/feedback/command-feedback/src/index.ts)
+
 <a id="feedback-events"></a>
 
 ### `feedback/*` events

+ 96 - 6
docs/subsystems/feedback.zh.md

@@ -2,7 +2,7 @@
 
 [English](feedback.md) | 中文
 
-[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。权威 Session 日志保存 `feedback/message-put` 和 `feedback/message-delete`;不可变的 Session 级备注仍使用 `feedback/record`。三者都是仅写日志的事件,绝不进入模型上下文。
+[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。权威 Session 日志保存 `feedback/message-put` 和 `feedback/message-delete`;不可变的 Session 级备注仍使用 `feedback/record`,由 [`@deepseek-ai/dsh-command-feedback`](../../packages/feedback/command-feedback) 连同两种反馈共用的 `FeedbackCategory` 分类表一起拥有。三者都是仅写日志的事件,绝不进入模型上下文。
 
 来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts)
 
@@ -27,6 +27,8 @@ interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
+  /** Category the human filed a negative judgment under. */
+  readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
   /** Host-assigned creation time in Unix epoch milliseconds. */
@@ -83,6 +85,8 @@ interface MessageFeedbackPutRequest {
   readonly rating: MessageFeedbackRating
   /** Optional non-blank explanation. */
   readonly note?: string
+  /** Optional category; absent keeps the item uncategorized. */
+  readonly category?: FeedbackCategory
   /** Observed item version, or `null` to require that no item exists. */
   readonly ifVersion: MessageFeedbackVersion | null
 }
@@ -203,11 +207,76 @@ type MessageFeedbackDeleteResult =
   | MessageFeedbackRejected<MessageFeedbackSessionNotFound | MessageFeedbackVersionConflict>
 ```
 
+## Session 反馈类型
+
+来源:[`packages/feedback/command-feedback/src/types.ts`](../../packages/feedback/command-feedback/src/types.ts)
+
+```ts type-equiv
+/** One of the fixed feedback categories; the ids are durable log vocabulary. */
+type FeedbackCategory =
+  | 'task-result'
+  | 'instruction-following'
+  | 'product-interaction'
+  | 'service-stability'
+  | 'resource-cost'
+  | 'security-privacy-permission'
+  | 'other'
+```
+
+```ts type-equiv
+/**
+ * One recorded human remark about a Session. Both members are optional: a
+ * submission with neither still records that the human asked for the
+ * Session to be reviewed, which is what authorizes log delivery.
+ */
+interface FeedbackRecord {
+  /** Free-text remark with surrounding whitespace removed; never empty when present. */
+  readonly text?: string
+  /** Category the human filed the remark under. */
+  readonly category?: FeedbackCategory
+}
+```
+
+```ts type-equiv
+/** Record one Session-level remark through the Host Remote. */
+interface SessionFeedbackRecordRequest {
+  /** Live Session the remark describes. */
+  readonly sessionId: SessionId
+  /** Free-text remark; blank text is recorded as absent. */
+  readonly text?: string
+  /** Category the human filed the remark under. */
+  readonly category?: FeedbackCategory
+}
+```
+
+```ts type-equiv
+/** Stable postcondition of a recorded remark. */
+interface SessionFeedbackRecordValue {
+  /** The remark is appended to the Session log; flushing follows the Session's own schedule. */
+  readonly recorded: true
+}
+```
+
+```ts type-equiv
+/** No live Session carries the requested id. */
+interface SessionFeedbackSessionNotFound {
+  readonly code: 'session-not-found'
+  readonly sessionId: SessionId
+}
+```
+
+```ts type-equiv
+/** Result returned by the `sessionFeedback.record` operation. */
+type SessionFeedbackRecordResult =
+  | { readonly ok: true; readonly value: SessionFeedbackRecordValue }
+  | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound }
+```
+
 ## 数据与并发
 
-当前条目由 payload 中 `sessionId` 与所属 Session 匹配的权威反馈事件归约得到。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
+当前条目由 payload 中 `sessionId` 与所属 Session 匹配的权威反馈事件归约得到。每个条目携带好评或差评、可选备注、可选分类、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。
 
-`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列串行执行读取与变更;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄。匹配版本的无变更操作不追加事件。
+`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值(重复已存评分、备注与分类的 put)。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列串行执行读取与变更;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄。匹配版本的无变更操作不追加事件。
 
 ## 目标与生命周期权威
 
@@ -217,7 +286,7 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s
 
 ## 持久化与 Remote 约定
 
-成功的消息反馈变更会等待权威持久化完成:live 操作通过所属 Session 追加,并要求有 `ctx.sessions.flush` 监听器参与;cold 操作通过写句柄追加并 flush。持久化故障会原样传播,不会报告成功。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;下方生成的 Cordis API 是方法级权威。
+成功的消息反馈变更会等待权威持久化完成:live 操作通过所属 Session 追加,并要求有 `ctx.sessions.flush` 监听器参与;cold 操作通过写句柄追加并 flush。持久化故障会原样传播,不会报告成功。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;`command-feedback` 以同样方式发布面向 live Session 的 Session 级备注 `sessionFeedback.record`。下方生成的 Cordis API 是方法级权威。
 
 插件释放会关闭操作接纳,并排空已进入各 Session 队列的工作。
 
@@ -225,12 +294,14 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s
 
 ## Web 界面
 
-[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback`,不接触传输层。
+[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 与 `sessionFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback` 与 `ctx.remote.sessionFeedback`,不接触传输层。
 
 控件是 `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。
 
+点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback <text>` 仍走宿主命令路径。再次点击已记录的评分会撤回它。
+
 ## 边界与限制
 
 - 操作队列仅在进程内生效;cold 写入排他性依赖所选持久化提供方。
@@ -240,7 +311,8 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s
 - Host 约定不记录已认证的 actor 或审计身份,因此假设调用方边界可信。
 - Web 控件只出现在对话视图。trajectory 与 waterfall 视图不渲染反馈条目,尽管它们的助手节点携带相同的 `messageId`。
 - Web 控制器不消费反馈日志事件,因此另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现。
-- 备注编辑器不预先校验 `maxNoteBytes`;超长备注在保存时以 `note-too-large` 失败,而不是在输入过程中。
+- 弹窗不预先校验 `maxNoteBytes`;超长描述在提交时以 `note-too-large` 失败,而不是在输入过程中。
+- `sessionFeedback.record` 只服务 live Session,否则回答 `session-not-found`;弹窗打开期间 Session 退役时,弹窗会报告该失败。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -282,6 +354,24 @@ Session-log service; cold operations never construct a Session or Agent.
 
 Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts)
 
+<a id="ctxsessionfeedback--sessionfeedbackservice"></a>
+
+### `ctx.sessionFeedback` — `SessionFeedbackService`
+
+Host Remote through which a product surface records a Session-level remark.
+
+```ts cordis-catalog
+/**
+ * Record one remark on a live Session.
+ * @param request - target Session plus the optional text and category.
+ * @returns the recorded postcondition, or `session-not-found` when no live
+ * Session carries the id.
+ */
+@Remote('record') record(request: SessionFeedbackRecordRequest): Promise<SessionFeedbackRecordResult>
+```
+
+Source: [`packages/feedback/command-feedback/src/index.ts`](../../packages/feedback/command-feedback/src/index.ts)
+
 <a id="feedback-events"></a>
 
 ### `feedback/*` events

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

@@ -84,6 +84,7 @@
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
-    "zod": "^4.4.3"
+    "zod": "^4.4.3",
+    "@deepseek-ai/dsh-command-feedback": "workspace:^"
   }
 }

+ 3 - 1
packages/api/remotes/src/client/index.ts

@@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
+import sessionFeedbackRemote from '@deepseek-ai/dsh-command-feedback/remote'
 import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote'
 import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
 import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
@@ -26,6 +27,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote'
 export type {} from '@deepseek-ai/dsh-llm/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
+export type {} from '@deepseek-ai/dsh-command-feedback/remote'
 export type {} from '@deepseek-ai/dsh-client-file-upload/remote'
 export type {} from '@deepseek-ai/dsh-session-reference/remote'
 export type {} from '@deepseek-ai/dsh-subagent/remote'
@@ -150,7 +152,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
   try {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
-      pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
+      pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
       subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -79,6 +79,9 @@
     },
     {
       "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../feedback/command-feedback"
     }
   ]
 }

+ 3 - 2
packages/bundle/web-app/cordis.patch.yml

@@ -315,8 +315,9 @@
     - id: ui-goal
       name: '@deepseek-ai/dsh-client-ui-goal'
 
-    # Per-message feedback: Like/Dislike plus an optional note in the
-    # assistant-message action strip, over the messageFeedback Remote.
+    # The feedback surface: Like/Dislike in the assistant-message action
+    # strip, the feedback dialog behind Dislike and /feedback with its
+    # acknowledgement toast, over the messageFeedback and sessionFeedback Remotes.
     - id: ui-message-feedback
       name: '@deepseek-ai/dsh-client-ui-message-feedback'
 

+ 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: 6bb433b8411fa9db3d6de981a24895e3c7b674c4
-README.zh.md: c04292b86becba404e5dbb58ca924833ee88f58b
+README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969
+README.zh.md: 1f895de009e82688ede9b3a83807bae9689ccb80

+ 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) | Contributes per-message feedback controls to the assistant-message action strip | — |
+| [`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-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) | 向助手消息操作条贡献逐消息反馈控件 | — |
+| [`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) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — |

+ 2 - 2
packages/client/ui-commands/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-commands/README.md
-README.md: e1264d10bd8ff807d8f348339e0c055607579407
-README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea
+README.md: c5f3c55c08a6df8e6c3f122d0c5e341721c2b41a
+README.zh.md: f728c03e067f74ba5142464adc17531225034aad

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

@@ -1,5 +1,5 @@
 ---
-description: "Client command API for the Web GUI: the / command source, three dispatch kinds, the per-session command directory, and popupSelect registration for business packages; for users and maintainers of slash commands."
+description: "Client command API for the Web GUI: the / command source, three dispatch kinds, the per-session command directory, and popupSelect and action registration for business packages; for users and maintainers of slash commands."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`.
+Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`: a popupSelect spec (`/model`, `/permission`) or an action (`/feedback`), registered as a command or decorating an existing host command while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is its kind, and everything else is `execute`.
 
 ## Table of Contents
 

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

@@ -1,5 +1,5 @@
 ---
-description: "Web GUI 的客户端命令 API:/ 命令 source、三类派发、会话级命令目录,以及面向业务包的 popupSelect 注册;供斜杠命令的用户与维护者阅读。"
+description: "Web GUI 的客户端命令 API:/ 命令 source、三类派发、会话级命令目录,以及面向业务包的 popupSelect 与 action 注册;供斜杠命令的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`),或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。
+在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:popupSelect 贡献项(`/model`、`/permission`)或 action(`/feedback`),既可注册为命令,也可装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的按其种类派发,其余全部是 `execute`。
 
 ## 目录
 

+ 21 - 3
packages/client/ui-commands/src/client/contract.ts

@@ -31,12 +31,30 @@ export interface SelectOption {
  * The shell component is owned by ui-commands; business never sees it. Both
  * callbacks receive the ClientSessionContext captured at popup open.
  */
-export type CommandUiSpec = {
+export interface PopupSelectSpec {
   readonly kind: 'popupSelect'
   options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>
   onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>
 }
 
+/**
+ * Business registration for the action command kind: a bare invocation
+ * consumes the trigger token and runs one client-side callback (the Feedback
+ * row opens the feedback dialog). It submits nothing, so an
+ * attachment-carrying draft never refuses it.
+ */
+export interface ActionSpec {
+  readonly kind: 'action'
+  /**
+   * Run the action for one session.
+   * @param session - the ClientSessionContext captured at invocation.
+   */
+  run(session: ClientSessionContext): void
+}
+
+/** The UI behavior of a contribution or decoration. */
+export type CommandUiSpec = PopupSelectSpec | ActionSpec
+
 /**
  * One client-owned command contribution: a slash-menu entry whose behavior
  * lives entirely on the client (no host descriptor). Merged with the host
@@ -50,7 +68,7 @@ export interface CommandContribution {
   readonly description: () => string
   /** Capability filter, called with a fresh projection per candidate pass. */
   available(session: ClientSessionContext): boolean
-  /** The command's UI behavior (this phase: popupSelect only). */
+  /** The command's UI behavior. */
   readonly ui: CommandUiSpec
 }
 
@@ -68,7 +86,7 @@ export interface CommandDecoration {
   readonly name: string
   /** Capability filter, called with a fresh projection per bare invocation. */
   available(session: ClientSessionContext): boolean
-  /** The bare-invocation UI (this phase: popupSelect only). */
+  /** The bare-invocation UI. */
   readonly ui: CommandUiSpec
 }
 

+ 2 - 1
packages/client/ui-commands/src/client/index.ts

@@ -27,7 +27,8 @@ export { filterOptions, PopupSelectController } from './popup.ts'
 export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
 export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
 export type {
-  CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption,
+  ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec,
+  SelectConfirmation, SelectOption,
 } from './contract.ts'
 export type { CommandKey } from './locales.ts'
 

+ 26 - 17
packages/client/ui-commands/src/client/service.ts

@@ -228,22 +228,22 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     return key !== undefined && command.description === en[key] ? this.t(key) : command.description
   }
 
-  /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
+  /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */
   private dispatch(pick: InputTriggerPick): PickOutcome {
     const name = pick.candidate.name
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(pick.session)) {
-      this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
+      this.invoke(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
     const desc = this.directory.resolve(pick.session.sessionId, name)
     if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
-    // A decoration replaces the HOST row's bare invocation with its popup;
-    // it decorates only a resolvable host command (checked above), never
-    // manufactures one, and never touches the argument claim below.
+    // A decoration replaces the HOST row's bare invocation with its popup or
+    // action; it decorates only a resolvable host command (checked above),
+    // never manufactures one, and never touches the argument claim below.
     const decoration = this.live.decorations.get(name)
     if (decoration !== undefined && decoration.available(pick.session)) {
-      this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
+      this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
     if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
@@ -258,7 +258,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
   private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
     if (!token.startsWith('/')) return undefined
     const name = token.slice(1)
-    if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
+    if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space
     const desc = this.directory.resolve(session.sessionId, name)
     if (desc === undefined || desc.input === undefined) return undefined
     return { claim: this.leadingClaim(desc, session) }
@@ -271,10 +271,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * args-tolerant.
    *
    * Envelope policy: an enter submission carrying attachments resolves only
-   * through a command declaring attachment acceptance. Every other command route —
-   * popup, non-accepting claim, bare detached execute — throws the refusal
-   * so the machine surfaces one composer notice and the draft and attachments
-   * stay in place; nothing executes and nothing is dropped.
+   * through a command declaring attachment acceptance. Every other submitting
+   * route — popup, non-accepting claim, bare detached execute — throws the
+   * refusal so the machine surfaces one composer notice and the draft and
+   * attachments stay in place; nothing executes and nothing is dropped. An
+   * action submits nothing and runs regardless.
    */
   private async matchEnter(
     session: ClientSessionContext,
@@ -295,8 +296,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(session)) {
       if (!bare) return undefined
-      if (envelope.attachments > 0) refuseAttachments()
-      this.openPopup(name, contribution.ui, session, { via: 'enter', token })
+      if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments()
+      this.invoke(name, contribution.ui, session, { via: 'enter', token })
       return 'handled'
     }
     await this.directory.ensureReady(session.sessionId, signal)
@@ -307,8 +308,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     if (bare) {
       const decoration = this.live.decorations.get(name)
       if (decoration !== undefined && decoration.available(session)) {
-        if (envelope.attachments > 0) refuseAttachments()
-        this.openPopup(name, decoration.ui, session, { via: 'enter', token })
+        if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments()
+        this.invoke(name, decoration.ui, session, { via: 'enter', token })
         return 'handled'
       }
     }
@@ -323,13 +324,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     return 'handled'
   }
 
-  /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
-  private openPopup(
+  /**
+   * Invoke one contribution or decoration (menu pick / bare enter): open the
+   * session's popup, or consume the token and run the action.
+   */
+  private invoke(
     name: string,
     ui: CommandContribution['ui'],
     session: ClientSessionContext,
     segment: TokenSegment,
   ): void {
+    if (ui.kind === 'action') {
+      this.consumeVia(session.sessionId, segment)
+      ui.run(session)
+      return
+    }
     const actx = this.scopeFor(session.sessionId)
     if (actx === undefined) return
     this.popupFor(actx).open(name, ui, session, segment)

+ 39 - 2
packages/client/ui-commands/tests/service.client.spec.ts

@@ -14,7 +14,7 @@ import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/cl
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
-import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
+import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandUiRuntime } from '../src/client/service.ts'
 
@@ -157,7 +157,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi
   return source.onPick(pick)
 }
 
-const themeUi = (over: Partial<CommandUiSpec> = {}): CommandUiSpec => ({
+const themeUi = (over: Partial<PopupSelectSpec> = {}): PopupSelectSpec => ({
   kind: 'popupSelect',
   options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]),
   onSelect: () => undefined,
@@ -384,6 +384,43 @@ describe('dispatch (menu column)', () => {
     expect(executeCalls).toEqual([])
   })
 
+  it('action decoration: a menu pick consumes the span and runs the callback without executing', async () => {
+    const { command, source, mint, warm, executeCalls } = await bench()
+    const scope = mint('s1')
+    const consumes: ConsumeTokenRequest[] = []
+    scope.ctx.on('slash/input-consume-token', (r) => {
+      consumes.push(r)
+      return true
+    })
+    const run = vi.fn()
+    command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } })
+    await warm(proj('s1'))
+    expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
+    expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
+    expect(run).toHaveBeenCalledWith(proj('s1'))
+    expect(executeCalls).toEqual([])
+    expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
+  })
+
+  it('action decoration: a bare enter runs even with attachments; an argued line bypasses it', async () => {
+    const { command, source, mint, warm } = await bench()
+    const scope = mint('s1')
+    const consumes: ConsumeTokenRequest[] = []
+    scope.ctx.on('slash/input-consume-token', (r) => {
+      consumes.push(r)
+      return true
+    })
+    const run = vi.fn()
+    command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } })
+    await warm(proj('s1'))
+    await expect(source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 1 })).resolves.toBe('handled')
+    expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
+    expect(run).toHaveBeenCalledTimes(1)
+    // An argued line never consults the decoration.
+    await expect(source.matchEnter!(proj('s1'), '/plan later', new AbortController().signal, { attachments: 0 })).resolves.not.toBe('handled')
+    expect(run).toHaveBeenCalledTimes(1)
+  })
+
   it('host bare → consume-token span guard on the session scope + detached execute', async () => {
     const { source, mint, warm, executeCalls, executions } = await bench()
     const scope = mint('s1')

+ 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: 171a812acc836eec243eb9f596270359c0a3aa9c
-README.zh.md: e34eba9014779fc27bbd5a7ef071a086e8982f45
+README.md: 81a37bd8a62c49a17a8c70918ff3de1fd5fc4eec
+README.zh.md: c7015c62bba808459c348b3fb85c7cae433830d6

+ 15 - 10
packages/client/ui-message-feedback/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Per-message feedback for the Web GUI: the Like/Dislike pair and optional note in the finalized assistant message's action row; 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 Dislike and `/feedback`, and the acknowledgement toast; for users and maintainers of the feedback experience."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package adds per-message feedback to the Web GUI: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry of the finalized assistant message's action strip. It renders on the closing assistant message of each turn — earlier steps of a multi-step turn produce tool rows rather than a rateable body. One controller per Session backs every message control in that Session, so a single list read seeds the whole transcript. Ratings and notes are log-only Session events: they never enter model context. Deletion retracts the current item without erasing its earlier log entries.
+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.
 
 ## Table of Contents
 
@@ -25,11 +25,11 @@ This package adds per-message feedback to the Web GUI: a Like/Dislike pair plus
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin alongside `ui-conversation`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch. Clicking the recorded rating retracts the feedback; switching sides carries the existing note forward. The note editor is a dialog popover anchored under its trigger, so the row keeps its single line whether the editor is open or closed.
+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.
 
 ### Failures
 
-A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
+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.
 
 -----
 
@@ -39,7 +39,9 @@ A rating or list-load failure shows inline in the row; a note-save failure shows
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-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. One `MessageFeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript; the read is deferred to the first hover or focus rather than fired on mount. Mutations go through `ctx.remote.messageFeedback`; the Host owns per-item compare-and-set. Every `put` and `delete` carries the `version` this controller last observed, and a `version-conflict` reply carries the authoritative item, so a lost race reconciles from the reply itself instead of refetching. Mutations serialize per Session, so a queued operation always compares against the committed version.
+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.
 
 </details>
 
@@ -48,10 +50,12 @@ The package contributes the `feedback` entry (order 10) of `conversation.chat.as
 <a id="further-exploration"></a>
 ## Further Exploration
 
-Read these pages when the feedback surface is not enough. They move from the browser strip to the Session-log backend and the conversation shell.
+Read these pages when the feedback surface is not enough. They move from the browser strip to the Session-log backends and the conversation shell.
 
 - [dsh-message-feedback](../../feedback/message-feedback/README.md) — the Session-log backend that owns per-item compare-and-set and persistence.
-- [ui-conversation](../ui-conversation/README.md) — declares the assistant-actions strip and renders the action row.
+- [dsh-command-feedback](../../feedback/command-feedback/README.md) — the `/feedback` command, the `sessionFeedback` Remote, and the category taxonomy.
+- [ui-commands](../ui-commands/README.md) — the command decoration contract the `/feedback` row goes through.
+- [ui-conversation](../ui-conversation/README.md) — declares the assistant-actions strip and the composer overlay.
 - [Client package map](../README.md) — adjacent browser UI packages.
 
 -----
@@ -59,7 +63,7 @@ Read these pages when the feedback surface is not enough. They move from the bro
 <a id="model-experience"></a>
 ## Model Experience
 
-None, as ratings and notes are log-only events, not model input. Optional Session-log delivery uses request metadata rather than model context.
+None, as ratings, categories, and notes are log-only events, not model input. Optional Session-log delivery uses request metadata rather than model context.
 
 #### KV Cache effect
 
@@ -72,7 +76,8 @@ 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 editor does not pre-check the limit, so an oversized note fails on save rather than while typing.
+- **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 fails on submit rather than while typing.
+- **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`.
 
@@ -86,4 +91,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. The plugin owns one slot registration and one per-session controller map, both released by the same effect disposer. The lifecycle spec proves the registration is withdrawn and every controller is dropped when the owning fiber is disposed, so no second authority exists to check at runtime.
+**Runtime invariant:** No companion is published. The plugin owns two slot registrations, one command decoration, and one per-session controller-pair map, all released by the plugin fiber's effect disposers. The lifecycle spec proves the registrations are withdrawn and every controller pair is dropped when the owning fiber is disposed, so no second authority exists to check at runtime.

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

@@ -1,5 +1,5 @@
 ---
-description: "Web GUI 的逐消息反馈:已定稿助手消息动作行中的 Like/Dislike 对与可选备注;供反馈体验的用户与维护者阅读。"
+description: "Web 反馈界面:已定稿助手消息动作行中的 Like/Dislike 对、点踩与 `/feedback` 背后的反馈弹窗,以及确认 toast;供反馈体验的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包为 Web GUI 增加逐消息反馈:一对 Like/Dislike 按钮加一个可选备注,作为已定稿助手消息动作条的 `feedback` 条目贡献。它渲染在每个轮次的收尾助手消息上——多步骤轮次中较早的步骤产出工具行而非可评分正文。每个 Session 一个控制器支撑该 Session 内所有消息的控件,因此一次列表读取即可填充整段对话。评分与备注是仅写日志的 Session 事件:它们绝不进入模型上下文。删除会撤回当前条目,但不会抹除早先的日志记录
+本包是 Web GUI 的反馈界面:已定稿助手消息动作条中的 Like/Dislike 对、输入框浮层中的反馈弹窗及其确认 toast,以及让不带文本的 `/feedback` 打开弹窗的装饰。点赞立即记录并显示 toast;点踩打开弹窗,弹窗收集分类与可选描述。每个 Session 一个 surface 支撑所有条目,因此一次列表读取即可填充整段对话,一个弹窗同时服务 Session 与其消息。评分、分类与备注是仅写日志的 Session 事件,绝不进入模型上下文
 
 ## 目录
 
@@ -25,11 +25,11 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-与 `ui-conversation` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间。再次点击已记录的评分会撤回反馈;切换到另一侧会保留既有备注。备注编辑器是一个锚定在其触发按钮下方的对话框浮层,因此无论编辑器是否打开,该行都保持单行。
+与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞立即记录,toast 显示感谢反馈。点踩打开弹窗:七个分类标签和一个详情框,都可不填;提交会记录一条带上所填内容的差评,对话日志随每个反馈事件一起投递。再次点击已记录的评分会撤回它。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback <text>` 仍走宿主命令路径并显示确认行。
 
 ### 失败
 
-评分或列表加载失败在行内展示;备注保存失败在浮层内展示,面板保持打开以便修正草稿。只有已定稿的消息能到达该槽位——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
+评分或列表加载失败在行内展示;提交失败在弹窗内展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。
 
 -----
 
@@ -39,7 +39,9 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内。每个 Session 一个 `MessageFeedbackController` 支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话;该读取延迟到首次 hover 或 focus 才发起,而非挂载时触发。变更经 `ctx.remote.messageFeedback` 提交,按条目的比较并交换由宿主负责。每次 `put` 与 `delete` 都携带本控制器最后观察到的 `version`;`version-conflict` 响应带回权威条目,因此竞争失败时直接用该响应本身对账,无需重新拉取。变更按 Session 串行,排队中的操作总是与已提交的版本比较。
+本包贡献 `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、不关闭新草稿;失败让草稿保持打开并带上失败码。
 
 </details>
 
@@ -48,10 +50,12 @@ kind: "package-reference"
 <a id="further-exploration"></a>
 ## 进一步探索
 
-当反馈面不够用时阅读以下页面。它们从浏览器条带进入 Session 日志后端与会话外壳。
+当反馈面不够用时阅读以下页面。它们从浏览器条带进入 Session 日志后端与会话外壳。
 
 - [dsh-message-feedback](../../feedback/message-feedback/README.zh.md)——拥有按条目比较并交换与持久化的 Session 日志后端。
-- [ui-conversation](../ui-conversation/README.zh.md)——声明助手动作条并渲染动作行。
+- [dsh-command-feedback](../../feedback/command-feedback/README.zh.md)——`/feedback` 命令、`sessionFeedback` Remote 与分类表。
+- [ui-commands](../ui-commands/README.zh.md)——`/feedback` 行所经过的命令装饰约定。
+- [ui-conversation](../ui-conversation/README.zh.md)——声明助手动作条与输入框浮层。
 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。
 
 -----
@@ -59,7 +63,7 @@ kind: "package-reference"
 <a id="model-experience"></a>
 ## 模型体验
 
-无。评分与备注是仅写日志的事件,不是模型输入。可选的 Session 日志投递使用请求元数据,而非模型上下文。
+无。评分、分类与备注是仅写日志的事件,不是模型输入。可选的 Session 日志投递使用请求元数据,而非模型上下文。
 
 #### KV Cache 影响
 
@@ -70,9 +74,10 @@ kind: "package-reference"
 <a id="known-limitations-and-deferred-work"></a>
 
 
-这些限制界定了当前反馈面。它们是当前包约束,不是通用评分对比或任务积压。
+这些限制界定了当前反馈面。它们是当前包约束,不是通用评分对比或任务积压。
 
-- **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。
+- **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。弹窗不预先校验该上限,因此超长描述在提交时才失败,而不是在输入过程中。
+- **点赞不带备注**——只有点踩弹窗收集分类与描述;点赞只记录判断本身。
 - **无跨标签页推送**——另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;控制器不消费反馈日志事件。
 - **仅限对话视图**——trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点也带有相同的 `messageId`。
 
@@ -86,4 +91,4 @@ kind: "package-reference"
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。插件持有一个 slot 注册和按 Session 的 controller map,两者由同一 effect disposer 释放;生命周期测试已直接观察该关系
+**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰和一个按 Session 的控制器对 map,全部由插件 fiber 的 effect disposer 释放;生命周期测试已直接观察注册被撤销、每对控制器被丢弃,因此运行时没有第二个可核对的权威

+ 7 - 3
packages/client/ui-message-feedback/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-message-feedback",
-  "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
+  "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-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -31,7 +31,8 @@
         "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation",
-        "@deepseek-ai/dsh-client-ui-renderer"
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-commands"
       ],
       "platform": "web"
     }
@@ -63,7 +64,10 @@
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
+    "@deepseek-ai/dsh-command-feedback": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^"
   },
   "files": [
     "lib/index.js",

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

@@ -0,0 +1,93 @@
+/* The design's dialog is the shared Modal card at 640px; everything else
+   about the chrome (radius, header, close, footer) is the primitive's. */
+.dialog {
+  width: min(640px, 100%);
+}
+
+.categories {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 11px 10px;
+}
+
+.chip {
+  height: 34px;
+  padding: 0 14px;
+  border: 1px solid var(--dsw-alias-border-l3);
+  border-radius: 17px;
+  background: transparent;
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 13px;
+  line-height: 20px;
+  cursor: pointer;
+}
+
+.chip:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.chip:disabled {
+  cursor: default;
+  opacity: 0.4;
+}
+
+.chipActive,
+.chipActive:hover:not(:disabled) {
+  border-color: var(--dsw-alias-button-primary-fill);
+  background: var(--dsw-alias-button-primary-fill);
+  color: var(--dsw-alias-label-primary-foreground);
+}
+
+.detail {
+  display: block;
+  width: 100%;
+  height: 148px;
+  box-sizing: border-box;
+  margin-top: 28px;
+  padding: 14px;
+  border: 1px solid var(--dsw-alias-border-l4);
+  border-radius: 12px;
+  background: var(--dsw-alias-bg-layer-1);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 13px;
+  line-height: 20px;
+  resize: none;
+}
+
+.detail::placeholder {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.detail:focus {
+  outline: none;
+  border-color: 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: 56px;
+  margin-top: 36px;
+  border-radius: 28px;
+  font-size: 15px;
+}
+
+/* The design's ringed green check, not the warning tint the Toast icon seat defaults to. */
+.toastIcon {
+  display: grid;
+  place-items: center;
+  width: 18px;
+  height: 18px;
+  border: 1.5px solid var(--dsw-alias-state-success-primary);
+  border-radius: 50%;
+  color: var(--dsw-alias-state-success-primary);
+}

+ 110 - 0
packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx

@@ -0,0 +1,110 @@
+/**
+ * The feedback dialog and its acknowledgement toast, 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
+ * centers over.
+ * @module @deepseek-ai/dsh-client-ui-message-feedback/client/FeedbackDialog
+ */
+
+import { useCallback, useLayoutEffect, useRef, useState } from 'react'
+import { Button, IconCheckOutline16, 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'
+
+/**
+ * The chips in presentation order. A client bundle may not import a Host
+ * package's values, so the taxonomy is restated as a complete record of the
+ * `FeedbackCategory` union: a missing or foreign id is a compile error.
+ */
+const CATEGORY_CHIPS = {
+  'task-result': true,
+  'instruction-following': true,
+  'product-interaction': true,
+  'service-stability': true,
+  'resource-cost': true,
+  'security-privacy-permission': true,
+  'other': true,
+} satisfies Record<FeedbackCategory, true>
+const CATEGORIES = Object.keys(CATEGORY_CHIPS) as FeedbackCategory[]
+
+/** Failure codes with their own copy; every other code reads the generic line. */
+const FAILURE_COPY: Partial<Record<string, 'error.conflict' | 'error.noteTooLarge'>> = {
+  'version-conflict': 'error.conflict',
+  'note-too-large': 'error.noteTooLarge',
+}
+
+/**
+ * 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.
+ */
+export function FeedbackDialog({ useDialog, edit, submit, dismiss, 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)
+  const [card, setCard] = useState<HTMLElement | null>(null)
+  useLayoutEffect(() => {
+    setCard(probeRef.current?.closest<HTMLElement>('[data-composer-card]') ?? null)
+  }, [])
+  const toast = state.toast
+  const onToastDone = useCallback(() => { dismissToast(toast) }, [dismissToast, toast])
+  const failure = state.failure === null ? null : t(FAILURE_COPY[state.failure] ?? 'error.generic')
+
+  return (
+    <>
+      <span ref={probeRef} hidden />
+      {toast > 0 && (
+        <Toast
+          key={toast}
+          text={t('toast.recorded')}
+          icon={<span className={css.toastIcon}><IconCheckOutline16 size={12} /></span>}
+          anchor={card}
+          onDone={onToastDone}
+        />
+      )}
+      <Modal
+        open={state.target !== null}
+        title={t('dialog.title')}
+        closeLabel={t('close')}
+        onClose={dismiss}
+        className={css.dialog as string}
+        footer={(
+          <Button
+            variant="primary"
+            className={css.submit}
+            disabled={state.submitting}
+            onClick={() => { void submit() }}
+          >
+            {state.submitting ? t('submitting') : t('submit')}
+          </Button>
+        )}
+      >
+        <div className={css.categories} role="group" aria-label={t('dialog.categories')}>
+          {CATEGORIES.map(category => (
+            <button
+              key={category}
+              type="button"
+              className={state.category === category ? `${css.chip} ${css.chipActive}` : css.chip}
+              aria-pressed={state.category === category}
+              disabled={state.submitting}
+              onClick={() => { edit({ category: state.category === category ? null : category }) }}
+            >
+              {t(`category.${category}`)}
+            </button>
+          ))}
+        </div>
+        <textarea
+          className={css.detail}
+          aria-label={t('dialog.detail')}
+          placeholder={t('dialog.hint')}
+          value={state.text}
+          readOnly={state.submitting}
+          onChange={(event) => { edit({ text: event.target.value }) }}
+        />
+        {failure !== null && <span className={css.failure} role="status">{failure}</span>}
+      </Modal>
+    </>
+  )
+}

+ 0 - 102
packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css

@@ -38,108 +38,6 @@
   color: var(--dsw-alias-label-primary);
 }
 
-.noteOpen {
-  max-width: 220px;
-  overflow: hidden;
-  padding: 0 8px;
-  border: none;
-  border-radius: 14px;
-  background: transparent;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  line-height: calc(28px + var(--dsh-content-font-delta, 0px));
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: pointer;
-}
-
-.noteOpen:hover,
-.noteOpen[aria-expanded='true'] {
-  background: var(--dsw-alias-interactive-bg-hover);
-  color: var(--dsw-alias-label-secondary);
-}
-
-/* Portal surface: fixed in the viewport, left/top supplied inline from the
-   trigger rect. Portaled panels must layer above modal overlays (z 1000). */
-.notePanel {
-  position: fixed;
-  z-index: 1100;
-  box-sizing: border-box;
-  width: 320px;
-  max-width: min(360px, calc(100vw - 24px));
-  /* The width bound's counterpart. `resize: vertical` on the textarea lets the
-     panel be dragged taller, and a panel taller than the viewport would push
-     the placement clamp's upper bound below its own margin, so `top` would go
-     negative and cut off the panel's head. Both bounds keep the 12px margin
-     the clamp uses. */
-  max-height: calc(100vh - 24px);
-  overflow-y: auto;
-  padding: 8px;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  border: 0;
-  border-radius: 12px;
-  background: var(--dsw-specific-menu);
-  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
-  box-shadow: var(--dsw-elevation-prominent);
-  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
-  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
-}
-
-.noteInput {
-  width: 100%;
-  box-sizing: border-box;
-  padding: 6px 8px;
-  border: 0.5px solid var(--dsw-alias-border-l4);
-  border-radius: 8px;
-  background: var(--dsw-alias-bg-layer-1);
-  color: var(--dsw-alias-label-primary);
-  font: inherit;
-  font-size: 13px;
-  resize: vertical;
-}
-
-.noteActions {
-  display: flex;
-  justify-content: flex-end;
-  gap: 6px;
-}
-
-.noteSave,
-.noteCancel {
-  height: 28px;
-  padding: 0 10px;
-  border: none;
-  border-radius: 14px;
-  font-size: 13px;
-  cursor: pointer;
-}
-
-.noteSave {
-  background: var(--dsw-alias-button-primary-fill);
-  color: var(--dsw-alias-label-primary-foreground);
-}
-
-.noteSave:hover:not(:disabled) {
-  background: var(--dsw-alias-button-primary-hover);
-}
-
-.noteSave:disabled {
-  cursor: default;
-  opacity: 0.4;
-}
-
-.noteCancel {
-  background: transparent;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.noteCancel:hover {
-  background: var(--dsw-alias-interactive-bg-hover);
-  color: var(--dsw-alias-label-secondary);
-}
-
 .failure {
   padding-left: 4px;
   color: var(--dsw-alias-label-tertiary);

+ 53 - 248
packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx

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

+ 37 - 54
packages/client/ui-message-feedback/src/client/controller.ts

@@ -11,6 +11,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
 import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
 import type {
   MessageFeedbackItem,
   MessageFeedbackRating,
@@ -28,10 +29,19 @@ export interface MessageFeedbackView {
   error: string | null
 }
 
+/** Rejected branch shared by every settled action. */
+export interface MessageFeedbackActionFailure {
+  ok: false
+  error: { code: string; message: string }
+}
+
 /** Settled action shape rendered by the message-level controls. */
-export type MessageFeedbackActionResult =
-  | { ok: true }
-  | { ok: false; error: { code: string; message: string } }
+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
@@ -47,7 +57,7 @@ const INITIAL_VIEW: MessageFeedbackView = Object.freeze({
 
 const OK: MessageFeedbackActionResult = Object.freeze({ ok: true })
 
-const DISPOSED: MessageFeedbackActionResult = Object.freeze({
+const DISPOSED: MessageFeedbackActionFailure = Object.freeze({
   ok: false,
   error: Object.freeze({ code: 'disposed', message: 'feedback controller is disposed' }),
 })
@@ -65,12 +75,12 @@ function describe(code: string): string {
 }
 
 /** Build the rejected branch for one business failure code. */
-function fail(code: string): MessageFeedbackActionResult {
+function fail(code: string): MessageFeedbackActionFailure {
   return { ok: false, error: { code, message: describe(code) } }
 }
 
 /** Carrier failure rendered with the Host-supplied code and message. */
-function carrierFailure(error: { code: string; message: string }): MessageFeedbackActionResult {
+function carrierFailure(error: { code: string; message: string }): MessageFeedbackActionFailure {
   return { ok: false, error: { code: error.code, message: error.message } }
 }
 
@@ -143,26 +153,21 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
 
   /**
    * Create or replace feedback for one message, comparing against the version
-   * this controller last observed.
-   *
-   * The note is resolved here rather than by the caller: `mutate` awaits the
-   * one list read first, so this body always sees the committed item, while a
-   * control that rendered before that read completed would still be holding
-   * `undefined`. Omitting `note` therefore keeps whatever is stored; only
-   * {@link clearNote} removes one.
+   * this controller last observed. The item stores exactly `entry`: an entry
+   * without a note or category replaces whatever the stored item carried.
    * @param messageId - target assistant message.
    * @param rating - desired judgment.
-   * @param note - replacement explanation; omitted keeps the stored note.
+   * @param entry - explanation and category to store with the judgment.
    * @returns the settled mutation result.
    */
   rate(
     messageId: MessageId,
     rating: MessageFeedbackRating,
-    note?: string,
+    entry: FeedbackRecord = {},
   ): Promise<MessageFeedbackActionResult> {
     return this.mutate(async () => {
       const observed = this.view.items.get(messageId)
-      return await this.putCommitted(messageId, rating, note ?? observed?.note, observed)
+      return await this.putCommitted(messageId, rating, entry, observed)
     })
   }
 
@@ -171,43 +176,20 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
    * 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.
+   * cold control rendered. A replacement stores the bare judgment; the note
+   * and category of the judgment it replaces do not carry over.
    * @param messageId - target assistant message.
    * @param rating - the judgment the human asked for.
-   * @returns the settled mutation result.
-   */
-  toggle(messageId: MessageId, rating: MessageFeedbackRating): Promise<MessageFeedbackActionResult> {
-    return this.mutate(async () => {
-      const observed = this.view.items.get(messageId)
-      if (observed?.rating === rating) return await this.deleteCommitted(messageId, observed)
-      return await this.putCommitted(messageId, rating, observed?.note, observed)
-    })
-  }
-
-  /**
-   * Drop the note while keeping the rating. Absent feedback needs no call.
-   * @param messageId - target assistant message.
-   * @returns the settled mutation result.
-   */
-  clearNote(messageId: MessageId): Promise<MessageFeedbackActionResult> {
-    return this.mutate(async () => {
-      const observed = this.view.items.get(messageId)
-      if (observed === undefined || observed.note === undefined) return OK
-      return await this.putCommitted(messageId, observed.rating, undefined, observed)
-    })
-  }
-
-  /**
-   * Remove feedback for one message. A message with no known item is already
-   * in the requested state, so no call is made.
-   * @param messageId - target assistant message.
-   * @returns the settled mutation result.
+   * @returns the settled mutation result with the rating now committed.
    */
-  clear(messageId: MessageId): Promise<MessageFeedbackActionResult> {
+  toggle(messageId: MessageId, rating: MessageFeedbackRating): Promise<MessageFeedbackToggleResult> {
     return this.mutate(async () => {
       const observed = this.view.items.get(messageId)
-      if (observed === undefined) return OK
-      return await this.deleteCommitted(messageId, observed)
+      const retract = observed?.rating === rating
+      const result = retract
+        ? await this.deleteCommitted(messageId, observed)
+        : await this.putCommitted(messageId, rating, {}, observed)
+      return result.ok ? { ok: true, rating: retract ? null : rating } : result
     })
   }
 
@@ -215,14 +197,15 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
   private async putCommitted(
     messageId: MessageId,
     rating: MessageFeedbackRating,
-    note: string | undefined,
+    entry: FeedbackRecord,
     observed: MessageFeedbackItem | undefined,
   ): Promise<MessageFeedbackActionResult> {
     const carried = await this.ctx.remote.messageFeedback.put({
       sessionId: this.sessionId,
       messageId,
       rating,
-      ...(note === undefined ? {} : { note }),
+      ...(entry.text === undefined ? {} : { note: entry.text }),
+      ...(entry.category === undefined ? {} : { category: entry.category }),
       ifVersion: observed?.version ?? null,
     })
     if (!carried.ok) return carrierFailure(carried.error)
@@ -284,11 +267,11 @@ export class MessageFeedbackController implements HostObservable<MessageFeedback
    * Serialize one mutation behind this Session's prior mutation so queued
    * operations always compare against the committed version.
    */
-  private mutate(
-    operation: () => Promise<MessageFeedbackActionResult>,
+  private mutate<T>(
+    operation: () => Promise<T>,
     options: { readonly seed?: boolean } = {},
-  ): Promise<MessageFeedbackActionResult> {
-    const guarded = async (): Promise<MessageFeedbackActionResult> => {
+  ): Promise<T | MessageFeedbackActionFailure> {
+    const guarded = async (): Promise<T | MessageFeedbackActionFailure> => {
       if (this.disposed) return DISPOSED
       if (options.seed !== false) {
         const loaded = await this.ensure()

+ 129 - 0
packages/client/ui-message-feedback/src/client/dialog.ts

@@ -0,0 +1,129 @@
+/**
+ * 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}.
+ * @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 { MessageFeedbackActionResult } from './controller.ts'
+
+/** What one open dialog submits to. */
+export type FeedbackDialogTarget =
+  | { readonly kind: 'session' }
+  | { readonly kind: 'message'; readonly messageId: MessageId }
+
+/** Dialog and toast state the overlay view renders from. */
+export interface FeedbackDialogState {
+  /** Target the dialog is open for; null while closed. */
+  readonly target: FeedbackDialogTarget | null
+  readonly category: FeedbackCategory | null
+  readonly text: string
+  /** A submission is in flight; the form ignores edits and submits until it settles. */
+  readonly submitting: boolean
+  /** Failure code of the last submission from this open; null when none. */
+  readonly failure: string | null
+  /** Sequence of the acknowledgement toast on screen; 0 while none. */
+  readonly toast: number
+}
+
+/**
+ * Record one submission against its target.
+ * @param target - the open target.
+ * @param entry - the trimmed text and the category, each present only when given.
+ */
+export type FeedbackSubmit = (target: FeedbackDialogTarget, entry: FeedbackRecord) => Promise<MessageFeedbackActionResult>
+
+const CLOSED: Omit<FeedbackDialogState, 'toast'> = {
+  target: null, category: null, text: '', submitting: false, failure: null,
+}
+
+/** Per-session dialog controller; one instance backs the overlay entry and every message control. */
+export class FeedbackDialogController {
+  /** Dialog state store (the overlay entry subscribes here). */
+  readonly state: SnapshotStore<FeedbackDialogState> = createSnapshotStore<FeedbackDialogState>({ ...CLOSED, toast: 0 })
+  /** Bumped by every open, dismiss, and dispose so a late settlement can tell its draft is gone. */
+  private generation = 0
+  private toastSeq = 0
+
+  /**
+   * @param submit - records one submission; the owner routes it by target.
+   */
+  constructor(private readonly submit: FeedbackSubmit) {}
+
+  /**
+   * Open the dialog with an empty draft, replacing any open draft.
+   * @param target - what the submission records against.
+   */
+  open(target: FeedbackDialogTarget): void {
+    this.generation += 1
+    this.state.set({ ...CLOSED, target, toast: this.state.getSnapshot().toast })
+  }
+
+  /** Close the dialog and discard the draft; a toast on screen stays. */
+  dismiss(): void {
+    this.generation += 1
+    this.state.set({ ...CLOSED, toast: this.state.getSnapshot().toast })
+  }
+
+  /**
+   * Replace part of the draft while it is editable.
+   * @param draft - the category (null clears it) or the text as typed.
+   */
+  edit(draft: Partial<Pick<FeedbackDialogState, 'category' | 'text'>>): void {
+    const s = this.state.getSnapshot()
+    if (s.target === null || s.submitting) return
+    this.state.set({ ...s, ...draft })
+  }
+
+  /**
+   * 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.
+   * @returns after the submission settles.
+   */
+  async submitDraft(): Promise<void> {
+    const s = this.state.getSnapshot()
+    if (s.target === null || s.submitting) return
+    const generation = this.generation
+    this.state.set({ ...s, submitting: true, failure: null })
+    const text = s.text.trim()
+    const result = await this.submit(s.target, {
+      ...(text.length === 0 ? {} : { text }),
+      ...(s.category === null ? {} : { category: s.category }),
+    })
+    if (result.ok) {
+      // The remark is recorded whichever draft is on screen now, so the toast
+      // always shows; only the draft that produced it closes.
+      if (generation === this.generation) this.dismiss()
+      this.acknowledge()
+      return
+    }
+    if (generation !== this.generation) return
+    this.state.set({ ...this.state.getSnapshot(), submitting: false, failure: result.error.code })
+  }
+
+  /** Show the acknowledgement toast; a toast already on screen restarts. */
+  acknowledge(): void {
+    this.toastSeq += 1
+    this.state.set({ ...this.state.getSnapshot(), toast: this.toastSeq })
+  }
+
+  /**
+   * Retire one toast after its fade; a newer toast is left alone.
+   * @param seq - the toast sequence the view finished showing.
+   */
+  dismissToast(seq: number): void {
+    const s = this.state.getSnapshot()
+    if (s.toast === seq) this.state.set({ ...s, toast: 0 })
+  }
+
+  /** Scope-teardown disposer: drop the draft and the toast, orphan in-flight work. */
+  dispose(): void {
+    this.generation += 1
+    this.state.set({ ...CLOSED, toast: 0 })
+  }
+}

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

@@ -1,9 +1,9 @@
 /**
- * Message feedback plugin, browser half: the Like/Dislike entry in the
- * conversation.chat.assistant-actions strip. One MessageFeedbackController per
- * Session backs every message control in that Session, so a single list read
- * seeds the whole transcript. Mutations go through the generated
- * messageFeedback Remote; the Host owns per-item compare-and-set.
+ * 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`
+ * 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
  */
 
@@ -11,79 +11,110 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
-// Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry).
+// Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions and overlay entries).
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+// Type-only: pulls the command UI's Context merge (ctx.commandUi).
+import type {} from '@deepseek-ai/dsh-client-ui-commands/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the SlotRegistry service merge (ctx.slots).
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
-import { MessageFeedbackController } from './controller.ts'
+import { FeedbackDialog } from './FeedbackDialog.tsx'
 import { MessageFeedbackActions } from './MessageFeedbackActions.tsx'
-import type { MessageFeedbackInjected } from './slots.ts'
+import type { FeedbackDialogInjected, MessageFeedbackInjected } from './slots.ts'
+import { FeedbackSurface } from './surface.ts'
 import { en, zh } from './locales.ts'
 
 export type {
-  MessageFeedbackActionResult, MessageFeedbackStatus, MessageFeedbackView,
+  MessageFeedbackActionFailure, MessageFeedbackActionResult, MessageFeedbackStatus,
+  MessageFeedbackToggleResult, MessageFeedbackView,
 } from './controller.ts'
-export type { MessageFeedbackActionProps, MessageFeedbackInjected } from './slots.ts'
+export type { FeedbackDialogState, FeedbackDialogTarget, FeedbackSubmit } from './dialog.ts'
+export type {
+  FeedbackDialogInjected, FeedbackDialogProps, MessageFeedbackActionProps, MessageFeedbackInjected,
+} from './slots.ts'
 export type { MessageFeedbackKey } from './locales.ts'
 
 /** Dictionary namespace owned by this plugin. */
 const NS = 'feedback'
 
-/** Required services: the slot registry, the Remote namespace, and the copy. */
-export const inject = ['slots', 'remote', 'remote.messageFeedback', 'locale']
+/** Required services: the slot registry, the two Remote namespaces, and the copy. */
+export const inject = ['slots', 'remote', 'remote.messageFeedback', 'remote.sessionFeedback', 'locale']
 
 /**
- * Client plugin body: the per-message feedback entry and its per-session
- * object layer.
+ * Client plugin body: the per-message feedback entry, the Session's dialog
+ * entry, the `/feedback` decoration, and their per-session surfaces.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-message-feedback: dictionaries')
 
-  const controllers = new Map<SessionId, MessageFeedbackController>()
-  const controllerFor = (sessionId: SessionId): MessageFeedbackController => {
-    let controller = controllers.get(sessionId)
-    if (controller === undefined) {
-      controller = new MessageFeedbackController(ctx, sessionId)
-      controllers.set(sessionId, controller)
+  const surfaces = new Map<SessionId, FeedbackSurface>()
+  const surfaceFor = (sessionId: SessionId): FeedbackSurface => {
+    let surface = surfaces.get(sessionId)
+    if (surface === undefined) {
+      surface = new FeedbackSurface(ctx, sessionId)
+      surfaces.set(sessionId, surface)
     }
-    return controller
+    return surface
   }
+  ctx.effect(() => () => {
+    for (const surface of surfaces.values()) surface.dispose()
+    surfaces.clear()
+  }, 'ui-message-feedback: per-session surfaces')
 
   // A reconnect can only invalidate what was already read; a cold Session
   // stays cold until something asks for it.
   ctx.on('connection/reset', () => {
-    for (const controller of controllers.values()) {
-      if (controller.getSnapshot().status !== 'cold') void controller.resync()
+    for (const { feedback } of surfaces.values()) {
+      if (feedback.getSnapshot().status !== 'cold') void feedback.resync()
     }
   })
 
-  ctx.slots.inject('conversation.chat.assistant-actions', () => {
-    const dispose = ctx.slots.register({
-      name: 'conversation.chat.assistant-actions',
-      id: 'feedback',
-      order: 10,
-      locale: NS,
-      inject: (sessionId): MessageFeedbackInjected => {
-        const controller = controllerFor(sessionId)
-        return {
-          hooks: { feedback: controller },
-          ensure: () => controller.ensure(),
-          rate: (messageId, rating, note) => controller.rate(messageId, rating, note),
-          toggle: (messageId, rating) => controller.toggle(messageId, rating),
-          clearNote: messageId => controller.clearNote(messageId),
-          clear: messageId => controller.clear(messageId),
-        }
-      },
-    }, MessageFeedbackActions)
-    return () => {
-      dispose()
-      for (const controller of controllers.values()) controller.dispose()
-      controllers.clear()
-    }
+  ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
+    name: 'conversation.chat.assistant-actions',
+    id: 'feedback',
+    order: 10,
+    locale: NS,
+    inject: (sessionId): MessageFeedbackInjected => {
+      const { feedback, dialog } = surfaceFor(sessionId)
+      return {
+        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() },
+      }
+    },
+  }, MessageFeedbackActions))
+
+  ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
+    name: 'conversation.input.overlay',
+    id: 'feedback-dialog',
+    order: 2,
+    locale: NS,
+    inject: (sessionId): FeedbackDialogInjected => {
+      const { dialog } = surfaceFor(sessionId)
+      return {
+        hooks: { dialog: dialog.state },
+        edit: (draft) => { dialog.edit(draft) },
+        submit: () => dialog.submitDraft(),
+        dismiss: () => { dialog.dismiss() },
+        dismissToast: (seq) => { dialog.dismissToast(seq) },
+      }
+    },
+  }, FeedbackDialog))
+
+  // The Host keeps `/feedback <text>` for a typed remark; a bare invocation
+  // from the menu or the composer opens the dialog instead.
+  ctx.inject(['commandUi'], (scope: ClientContext) => {
+    scope.effect(() => scope.commandUi.decorate({
+      name: 'feedback',
+      available: () => true,
+      ui: { kind: 'action', run: (session) => { surfaceFor(session.sessionId).dialog.open({ kind: 'session' }) } },
+    }), 'ui-message-feedback: /feedback decoration')
   })
 }

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

@@ -6,15 +6,22 @@ export const zh = {
   'action.likeActive': '取消标记',
   'action.dislike': '有问题的回答',
   'action.dislikeActive': '取消标记',
-  'note.open': '补充说明',
-  'note.dialog': '反馈',
-  'note.placeholder': '这条回答哪里好,或哪里有问题?(可选)',
-  'note.save': '保存',
-  'note.cancel': '取消',
-  'note.aria': '反馈说明',
+  'dialog.title': '提交反馈',
+  'dialog.categories': '反馈分类',
+  'dialog.detail': '反馈详情',
+  'dialog.hint': '填写详情以帮助我们改进体验',
+  'category.task-result': '任务结果',
+  'category.instruction-following': '指令理解与遵循',
+  'category.product-interaction': '产品功能与交互',
+  'category.service-stability': '服务稳定性',
+  'category.resource-cost': '资源使用与费用',
+  'category.security-privacy-permission': '安全隐私与权限',
+  'category.other': '其他',
+  'toast.recorded': '感谢你的反馈',
   'error.conflict': '这条反馈已在别处改动,已显示最新状态',
   'error.load': '反馈状态加载失败',
   'error.generic': '反馈保存失败',
+  'error.noteTooLarge': '描述太长,请缩短后再提交',
 } satisfies Record<string, string>
 
 /** The feedback namespace key union. */
@@ -22,7 +29,7 @@ export type MessageFeedbackKey = keyof typeof zh
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** The per-message feedback controls' copy. */
+    /** The feedback surface's copy: the message controls, the dialog, and the acknowledgement. */
     feedback: MessageFeedbackKey
   }
 }
@@ -33,13 +40,20 @@ export const en = {
   'action.likeActive': 'Remove rating',
   'action.dislike': 'Bad response',
   'action.dislikeActive': 'Remove rating',
-  'note.open': 'Add a note',
-  'note.dialog': 'Feedback',
-  'note.placeholder': 'What was good, or what went wrong? (optional)',
-  'note.save': 'Save',
-  'note.cancel': 'Cancel',
-  'note.aria': 'Feedback note',
+  'dialog.title': 'Submit feedback',
+  'dialog.categories': 'Feedback category',
+  'dialog.detail': 'Feedback details',
+  'dialog.hint': 'Add details to help us improve',
+  '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.resource-cost': 'Resource usage and cost',
+  'category.security-privacy-permission': 'Security, privacy, and permissions',
+  'category.other': 'Other',
+  'toast.recorded': 'Thanks for your feedback',
   'error.conflict': 'This feedback changed elsewhere; the latest state is shown',
   'error.load': 'Could not load feedback',
   'error.generic': 'Could not save feedback',
+  'error.noteTooLarge': 'The description is too long; shorten it and submit again',
 } satisfies Record<MessageFeedbackKey, string>

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

@@ -1,10 +1,10 @@
 /**
- * The feedback entry's injected face. The target
- * 'conversation.chat.assistant-actions' slot is declared and typed by
- * ui-conversation; this package only contributes the entry, so no SlotMap
- * merge lives here. Live per-message state arrives through the `feedback`
- * hook (the framework standard kit binds it into `useFeedback`); inject
- * carries the two mutation verbs plus the lazy loader.
+ * The injected faces of this package's two entries. The
+ * 'conversation.chat.assistant-actions' and 'conversation.input.overlay'
+ * slots are declared and typed by ui-chat and ui-conversation; this package
+ * only contributes entries, so no SlotMap merge lives here. Live state
+ * arrives through the `hooks` compartment (the framework standard kit binds
+ * `feedback` into `useFeedback` and `dialog` into `useDialog`).
  * @module @deepseek-ai/dsh-client-ui-message-feedback/client/slots
  */
 
@@ -13,10 +13,11 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-slots'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
+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, MessageFeedbackView } from './controller.ts'
+import type { MessageFeedbackActionResult, MessageFeedbackToggleResult, MessageFeedbackView } from './controller.ts'
+import type { FeedbackDialogState } from './dialog.ts'
 
 /** Injected business face of one assistant-message feedback entry. */
 export interface MessageFeedbackInjected {
@@ -27,16 +28,10 @@ export interface MessageFeedbackInjected {
   /** Load the Session's feedback once, on first interaction. */
   ensure: () => Promise<MessageFeedbackActionResult>
   /**
-   * Create or replace this Session's feedback for one message.
+   * The committed item as this Session's controller last observed it.
    * @param messageId - target assistant message.
-   * @param rating - desired judgment.
-   * @param note - optional explanation.
    */
-  rate: (
-    messageId: MessageId,
-    rating: MessageFeedbackRating,
-    note?: string,
-  ) => Promise<MessageFeedbackActionResult>
+  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
@@ -44,17 +39,15 @@ export interface MessageFeedbackInjected {
    * @param messageId - target assistant message.
    * @param rating - the judgment the human asked for.
    */
-  toggle: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<MessageFeedbackActionResult>
+  toggle: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<MessageFeedbackToggleResult>
   /**
-   * Drop the note while keeping the rating.
+   * Open the Session's feedback dialog for one message; its submission
+   * records a negative judgment with the dialog's category and text.
    * @param messageId - target assistant message.
    */
-  clearNote: (messageId: MessageId) => Promise<MessageFeedbackActionResult>
-  /**
-   * Remove this Session's feedback for one message.
-   * @param messageId - target assistant message.
-   */
-  clear: (messageId: MessageId) => Promise<MessageFeedbackActionResult>
+  openDialog: (messageId: MessageId) => void
+  /** Show the Session's acknowledgement toast. */
+  acknowledge: () => void
 }
 
 /** Full props of one assistant-message feedback entry. */
@@ -62,3 +55,30 @@ export type MessageFeedbackActionProps =
   PropsRuntime<'conversation.chat.assistant-actions'>
   & InjectFace<MessageFeedbackInjected>
   & PropsLocale<'feedback'>
+
+/** Injected business face of the Session's feedback dialog entry. */
+export interface FeedbackDialogInjected {
+  hooks: {
+    /** The Session's dialog and toast state. */
+    dialog: HostObservable<FeedbackDialogState>
+  }
+  /**
+   * Replace part of the draft: the category (null clears it) or the text.
+   * @param draft - the members to replace.
+   */
+  edit: (draft: Partial<Pick<FeedbackDialogState, 'category' | 'text'>>) => void
+  /** Submit the draft to the open target. */
+  submit: () => Promise<void>
+  /** Close the dialog and discard the draft. */
+  dismiss: () => void
+  /**
+   * Retire the toast the view finished showing.
+   * @param seq - the toast sequence.
+   */
+  dismissToast: (seq: number) => void
+}
+
+/** Full props of the feedback dialog overlay entry. */
+export type FeedbackDialogProps =
+  InjectFace<FeedbackDialogInjected>
+  & PropsLocale<'feedback'>

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

@@ -0,0 +1,44 @@
+/**
+ * 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.
+ * @module @deepseek-ai/dsh-client-ui-message-feedback/client/surface
+ */
+
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
+import { MessageFeedbackController, type MessageFeedbackActionResult } from './controller.ts'
+import { FeedbackDialogController } from './dialog.ts'
+
+/** The per-session pair behind every entry of one Session. */
+export class FeedbackSurface {
+  readonly feedback: MessageFeedbackController
+  readonly dialog: FeedbackDialogController
+
+  /**
+   * @param ctx - the browser plugin context carrying both feedback Remotes.
+   * @param sessionId - Session owning the transcript and the remark.
+   */
+  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.recordSession(entry))
+  }
+
+  /** Record one Session-level remark through the sessionFeedback Remote. */
+  private async recordSession(entry: FeedbackRecord): Promise<MessageFeedbackActionResult> {
+    const carried = await this.ctx.remote.sessionFeedback.record({ sessionId: this.sessionId, ...entry })
+    if (!carried.ok) return { ok: false, error: { code: carried.error.code, message: carried.error.message } }
+    if (carried.value.ok) return { ok: true }
+    return { ok: false, error: { code: carried.value.error.code, message: 'this session is no longer live' } }
+  }
+
+  /** Drop both controllers when the owning fiber unloads. */
+  dispose(): void {
+    this.feedback.dispose()
+    this.dialog.dispose()
+  }
+}

+ 141 - 32
packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx

@@ -2,10 +2,12 @@
 /**
  * ui-message-feedback browser half on a real cordis Context with fake slots/remote
  * faces: the plugin registers the feedback entry at
- * conversation.chat.assistant-actions, one controller per Session backs every
- * message in that Session, a reconnect refreshes only Sessions that were
- * already read, and registration plus controller disposal ride the plugin
- * fiber (HMR safety). The node half stays inert.
+ * conversation.chat.assistant-actions and the dialog entry at
+ * conversation.input.overlay, decorates the Host's /feedback command with an
+ * action that opens the dialog, one surface per Session backs every entry in
+ * that Session, a reconnect refreshes only Sessions that were already read,
+ * and registration plus surface disposal ride the plugin fiber (HMR safety).
+ * The node half stays inert.
  */
 import { Context, Service } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
@@ -15,7 +17,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
-import type { MessageFeedbackInjected } from '../src/client/slots.ts'
+import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
+import type { FeedbackDialogInjected, MessageFeedbackInjected } from '../src/client/slots.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as nodeApply } from '../src/index.ts'
 
@@ -32,8 +35,8 @@ const seeded: MessageFeedbackItem = {
   updatedAt: 1,
 }
 
-/** Boot the plugin over fake faces; the Remote namespace records every call. */
-async function bench() {
+/** Boot the plugin over fake faces; the Remote namespaces record every call. */
+async function bench(options: { recordResult?: unknown; recordCarrier?: unknown } = {}) {
   const ctx = new Context()
   const calls: { method: string; request: unknown }[] = []
   // The generated face wraps every business result in the carrier envelope.
@@ -57,19 +60,38 @@ async function bench() {
       super(serviceCtx, 'remote')
     }
   }
+  const sessionFeedback = {
+    record: (request: unknown) => {
+      calls.push({ method: 'record', request })
+      if (options.recordCarrier !== undefined) return Promise.resolve(options.recordCarrier)
+      return carried(options.recordResult ?? { ok: true as const, value: { recorded: true as const } })
+    },
+  }
   new RemoteService(ctx)
   ctx.provide('remote.messageFeedback', messageFeedback)
+  ctx.provide('remote.sessionFeedback', sessionFeedback)
   await ctx.plugin(SlotRegistry).await()
   ctx.slots.register({
     name: 'root',
-    children: { 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' } },
+    children: {
+      'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
+      'conversation.input.overlay': { kind: 'list', scope: 'session' },
+    },
   } as never, (() => null) as never)
   ctx.provide('locale', new LocaleRuntime(ctx))
+  const decorations = new Map<string, CommandDecoration>()
+  ctx.provide('commandUi', {
+    decorate: (decoration: CommandDecoration) => {
+      decorations.set(decoration.name, decoration)
+      return () => { decorations.delete(decoration.name) }
+    },
+  })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   return {
     ctx,
     fiber,
     calls,
+    decorations,
     entry: () => {
       const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
       if (entry === undefined) return undefined
@@ -79,6 +101,15 @@ async function bench() {
         inject: entry.inject as unknown as ((sessionId: SessionId) => MessageFeedbackInjected) | undefined,
       }
     },
+    dialogEntry: () => {
+      const entry = ctx.slots.entries('conversation.input.overlay')[0]
+      if (entry === undefined) return undefined
+      return {
+        ...entry.options,
+        locale: entry.locale,
+        inject: entry.inject as unknown as ((sessionId: SessionId) => FeedbackDialogInjected) | undefined,
+      }
+    },
   }
 }
 
@@ -91,15 +122,101 @@ describe('ui-message-feedback browser plugin', () => {
     expect(b.entry()?.inject).toBeTypeOf('function')
   })
 
-  it('exposes the feedback hook plus the ensure/rate/clear verbs', async () => {
+  it('exposes the feedback hook plus the ensure/toggle/openDialog/acknowledge verbs', async () => {
     const b = await bench()
     await b.fiber.await()
 
     const face = b.entry()!.inject!(sid('s1'))
     expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
     expect(face.ensure).toBeTypeOf('function')
-    expect(face.rate).toBeTypeOf('function')
-    expect(face.clear).toBeTypeOf('function')
+    expect(face.toggle).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 () => {
+    const b = await bench()
+    await b.fiber.await()
+
+    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 })
+  })
+
+  it('opens one dialog per Session from the message entry, the decoration, and acknowledges from Like', 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 })
+
+    const decoration = b.decorations.get('feedback')
+    expect(decoration?.available({ sessionId: sid('s1') })).toBe(true)
+    if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
+    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 })
+  })
+
+  it('records a Session remark through the sessionFeedback Remote and a message judgment through put', async () => {
+    const b = await bench()
+    await b.fiber.await()
+
+    const message = b.entry()!.inject!(sid('s1'))
+    const dialog = b.dialogEntry()!.inject!(sid('s1'))
+    const decoration = b.decorations.get('feedback')
+    if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
+    decoration.ui.run({ sessionId: sid('s1') })
+    dialog.edit({ category: 'service-stability', text: 'timed out' })
+    await dialog.submit()
+    expect(b.calls.filter(call => call.method === 'record')[0]?.request)
+      .toEqual({ sessionId: 's1', text: 'timed out', category: 'service-stability' })
+    expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 1 })
+
+    message.openDialog(MSG)
+    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',
+    })
+    expect(dialog.hooks.dialog.getSnapshot().toast).toBe(2)
+    dialog.dismissToast(2)
+    expect(dialog.hooks.dialog.getSnapshot().toast).toBe(0)
+  })
+
+  it('keeps the dialog open with the carrier code when the record call itself fails', async () => {
+    const b = await bench({ recordCarrier: { ok: false as const, error: { code: 'gateway/internal', message: 'socket closed', details: {} } } })
+    await b.fiber.await()
+    const dialog = b.dialogEntry()!.inject!(sid('s1'))
+    const decoration = b.decorations.get('feedback')
+    if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
+    decoration.ui.run({ sessionId: sid('s1') })
+
+    await dialog.submit()
+
+    expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: { kind: 'session' }, failure: 'gateway/internal', toast: 0 })
+  })
+
+  it('keeps the dialog open with the failure code when the Host rejects the remark', async () => {
+    const b = await bench({
+      recordResult: { ok: false as const, error: { code: 'session-not-found', sessionId: 'gone' } },
+    })
+    await b.fiber.await()
+    const dialog = b.dialogEntry()!.inject!(sid('gone'))
+    const decoration = b.decorations.get('feedback')
+    if (decoration?.ui.kind !== 'action') throw new Error('the /feedback decoration is not an action')
+    decoration.ui.run({ sessionId: sid('gone') })
+
+    await dialog.submit()
+
+    expect(dialog.hooks.dialog.getSnapshot()).toMatchObject({ target: { kind: 'session' }, failure: 'session-not-found' })
+    dialog.dismiss()
+    expect(dialog.hooks.dialog.getSnapshot().target).toBeNull()
   })
 
   it('shares one controller across every message in the same Session', async () => {
@@ -131,37 +248,24 @@ describe('ui-message-feedback browser plugin', () => {
     ])
   })
 
-  it('routes rate and clear to the Remote with the addressed message', async () => {
+  it('routes toggle to the Remote with the addressed message', async () => {
     const b = await bench()
     await b.fiber.await()
 
     const face = b.entry()!.inject!(sid('s1'))
-    expect(await face.rate(MSG, 'negative', 'wrong answer')).toEqual({ ok: true })
-    expect(await face.clear(MSG)).toEqual({ ok: true })
+    // 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 })
 
     expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
-      sessionId: 's1', messageId: MSG, rating: 'negative', note: 'wrong answer',
+      sessionId: 's1', messageId: MSG, rating: 'negative',
     })
     expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
       sessionId: 's1', messageId: MSG,
     })
   })
 
-  it('routes toggle and clearNote to the controller', async () => {
-    const b = await bench()
-    await b.fiber.await()
-
-    const face = b.entry()!.inject!(sid('s1'))
-    expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
-    expect(await face.clearNote(MSG)).toEqual({ ok: true })
-
-    // The seeded item is positive with no note, so a negative toggle replaces it
-    // through put, and clearNote has nothing to drop and touches no wire.
-    const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
-    expect(puts).toHaveLength(1)
-    expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
-  })
-
   it('refreshes only Sessions already read when the connection resets', async () => {
     const b = await bench()
     await b.fiber.await()
@@ -179,18 +283,23 @@ describe('ui-message-feedback browser plugin', () => {
     expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
   })
 
-  it('withdraws the registration and disposes controllers with the plugin fiber', async () => {
+  it('withdraws the registrations and disposes surfaces with the plugin fiber', async () => {
     const b = await bench()
     await b.fiber.await()
     const face = b.entry()!.inject!(sid('s1'))
+    const dialog = b.dialogEntry()!.inject!(sid('s1'))
     await face.ensure()
+    face.openDialog(MSG)
 
     await b.fiber.dispose()
 
     expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
+    expect(b.ctx.slots.entries('conversation.input.overlay')).toHaveLength(0)
+    expect(b.decorations.size).toBe(0)
+    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.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
+    expect(await face.toggle(MSG, 'negative')).toMatchObject({ ok: false, error: { code: 'disposed' } })
     expect(b.calls).toHaveLength(before)
   })
 

+ 30 - 99
packages/client/ui-message-feedback/tests/controller.client.spec.ts

@@ -113,16 +113,37 @@ describe('MessageFeedbackController', () => {
     expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
   })
 
-  it('forwards an optional note and omits the field when absent', async () => {
+  it('forwards the entry as note and category and omits absent members', async () => {
     const { ctx, calls } = fakeRemote()
     const controller = new MessageFeedbackController(ctx, SESSION)
 
-    await controller.rate(MSG, 'positive', 'helpful')
+    await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' })
     await controller.rate(OTHER, 'negative')
 
     const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
-    expect(puts[0]?.note).toBe('helpful')
+    expect(puts[0]).toMatchObject({ note: 'helpful', category: 'task-result' })
     expect(puts[1]).not.toHaveProperty('note')
+    expect(puts[1]).not.toHaveProperty('category')
+  })
+
+  it('toggle retracts a matching rating, records an absent one, and reports the committed rating', 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(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('reconciles a version conflict from the authoritative item without refetching', async () => {
@@ -155,7 +176,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
+    expect(await controller.toggle(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
     expect(controller.getSnapshot().items.has(MSG)).toBe(false)
   })
 
@@ -166,21 +187,13 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.clear(MSG)).toEqual({ ok: true })
+    expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true, rating: null })
 
     expect(calls.filter(call => call.method === 'delete')[0]?.request)
       .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
     expect(controller.getSnapshot().items.has(MSG)).toBe(false)
   })
 
-  it('treats clearing an unrated message as already satisfied without a call', async () => {
-    const { ctx, calls } = fakeRemote()
-    const controller = new MessageFeedbackController(ctx, SESSION)
-
-    expect(await controller.clear(MSG)).toEqual({ ok: true })
-    expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
-  })
-
   it('serializes mutations so each one compares against the committed version', async () => {
     let inFlight = 0
     let overlapped = false
@@ -389,7 +402,7 @@ describe('MessageFeedbackController', () => {
     })
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
-    const pending = controller.clear(MSG)
+    const pending = controller.toggle(MSG, 'positive')
 
     const listener = vi.fn()
     controller.subscribe(listener)
@@ -410,7 +423,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({
+    expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({
       ok: false,
       error: { code: 'note-too-large' },
     })
@@ -426,95 +439,13 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.clear(MSG)).toMatchObject({
+    expect(await controller.toggle(MSG, 'positive')).toMatchObject({
       ok: false,
       error: { code: 'session-not-found' },
     })
     expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
   })
 
-  it('preserves a stored note when a rating switch omits one', async () => {
-    // Regression: a control that rendered before the first list read holds no
-    // item, so it passes note=undefined; that must not erase the stored note.
-    const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
-
-    expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
-
-    const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
-    expect(put.note).toBe('keep me')
-    expect(put.rating).toBe('negative')
-  })
-
-  it('toggle retracts when the committed rating already matches', async () => {
-    const stored = item({ version: version('v1'), rating: 'positive' })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
-
-    expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
-
-    expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
-    expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
-    expect(controller.getSnapshot().items.has(MSG)).toBe(false)
-  })
-
-  it('toggle decides from the committed item, not a cold view', async () => {
-    // The click lands before any list read: the cold view knows no item, yet the
-    // stored rating matches, so the toggle must retract rather than re-put.
-    const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
-    expect(controller.getSnapshot().status).toBe('cold')
-
-    expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
-
-    expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
-  })
-
-  it('toggle replaces the opposite rating and carries the note forward', async () => {
-    const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
-
-    expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true })
-
-    const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
-    expect(put).toMatchObject({ rating: 'negative', note: 'kept', ifVersion: version('v1') })
-  })
-
-  it('clearNote drops the note and keeps the rating', async () => {
-    const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' })
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
-
-    expect(await controller.clearNote(MSG)).toEqual({ ok: true })
-
-    const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
-    expect(put.rating).toBe('negative')
-    expect(put).not.toHaveProperty('note')
-  })
-
-  it('clearNote is a no-op when there is no note to drop', async () => {
-    const { ctx, calls } = fakeRemote({
-      list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
-    })
-    const controller = new MessageFeedbackController(ctx, SESSION)
-
-    expect(await controller.clearNote(MSG)).toEqual({ ok: true })
-    expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
-  })
-
   it('resync serializes behind an in-flight mutation', async () => {
     // Regression: an unserialized reconnect read could land after a newer put
     // and resurrect the version that put had already replaced.
@@ -616,7 +547,7 @@ describe('MessageFeedbackController', () => {
     const controller = new MessageFeedbackController(ctx, SESSION)
     await controller.ensure()
 
-    expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
+    expect(await controller.toggle(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
     expect(controller.getSnapshot().items.has(MSG)).toBe(true)
   })
 })

+ 182 - 0
packages/client/ui-message-feedback/tests/dialog.client.spec.ts

@@ -0,0 +1,182 @@
+/**
+ * FeedbackDialogController: one draft per open, submission routed by target,
+ * success closes and acknowledges, failure keeps the draft with its code, a
+ * settlement from a superseded open closes nothing, and the toast sequence
+ * retires only the toast the view finished showing.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
+import type { MessageFeedbackActionResult } from '../src/client/controller.ts'
+import { FeedbackDialogController, type FeedbackSubmit } from '../src/client/dialog.ts'
+
+const MSG = 'm-1' as MessageId
+
+function bench(result: () => Promise<MessageFeedbackActionResult> = () => Promise.resolve({ ok: true })) {
+  const submit = vi.fn<FeedbackSubmit>(() => result())
+  return { submit, controller: new FeedbackDialogController(submit) }
+}
+
+describe('FeedbackDialogController', () => {
+  it('starts closed with no toast', () => {
+    const { controller } = bench()
+
+    expect(controller.state.getSnapshot()).toEqual({
+      target: null, category: null, text: '', submitting: false, failure: null, toast: 0,
+    })
+  })
+
+  it('opens with an empty draft and drops the draft on dismiss', () => {
+    const { controller } = bench()
+
+    controller.open({ kind: 'session' })
+    controller.edit({ category: 'task-result' })
+    controller.edit({ text: 'slow' })
+    expect(controller.state.getSnapshot()).toMatchObject({
+      target: { kind: 'session' }, category: 'task-result', text: 'slow',
+    })
+
+    controller.dismiss()
+    controller.open({ kind: 'message', messageId: MSG })
+    expect(controller.state.getSnapshot()).toMatchObject({
+      target: { kind: 'message', messageId: MSG }, category: null, text: '',
+    })
+  })
+
+  it('ignores draft edits and submits while closed', async () => {
+    const { controller, submit } = bench()
+
+    controller.edit({ category: 'other' })
+    controller.edit({ text: 'x' })
+    await controller.submitDraft()
+
+    expect(controller.state.getSnapshot()).toMatchObject({ category: null, text: '', toast: 0 })
+    expect(submit).not.toHaveBeenCalled()
+  })
+
+  it('submits the Session target with the trimmed text and the category', async () => {
+    const { controller, submit } = bench()
+    controller.open({ kind: 'session' })
+    controller.edit({ category: 'service-stability' })
+    controller.edit({ text: '  timed out twice  ' })
+
+    await controller.submitDraft()
+
+    expect(submit).toHaveBeenCalledWith({ kind: 'session' }, { text: 'timed out twice', category: 'service-stability' })
+    expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
+  })
+
+  it('submits an empty draft as an entry with neither text nor category', async () => {
+    const { controller, submit } = bench()
+    controller.open({ kind: 'session' })
+    controller.edit({ text: '   ' })
+
+    await controller.submitDraft()
+
+    expect(submit).toHaveBeenCalledWith({ kind: 'session' }, {})
+  })
+
+  it('submits the message target with the entry', async () => {
+    const { controller, submit } = bench()
+    controller.open({ kind: 'message', messageId: MSG })
+    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(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.edit({ text: 'draft' })
+
+    await controller.submitDraft()
+
+    expect(controller.state.getSnapshot()).toMatchObject({
+      target: { kind: 'message', messageId: MSG }, text: 'draft', submitting: false,
+      failure: 'version-conflict', toast: 0,
+    })
+  })
+
+  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 }) } })
+    const { controller, submit } = bench(() => gate)
+    controller.open({ kind: 'session' })
+    controller.edit({ text: 'first' })
+
+    const first = controller.submitDraft()
+    controller.edit({ text: 'second' })
+    controller.edit({ category: 'other' })
+    await controller.submitDraft()
+    expect(controller.state.getSnapshot()).toMatchObject({ submitting: true, text: 'first', category: null })
+
+    release()
+    await first
+    expect(submit).toHaveBeenCalledTimes(1)
+    expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
+  })
+
+  it('acknowledges a success that lands after a reopen without closing the new draft', async () => {
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => { release = () => { resolve({ ok: true }) } })
+    const { controller } = bench(() => gate)
+    controller.open({ kind: 'session' })
+    const pending = controller.submitDraft()
+    controller.open({ kind: 'message', messageId: MSG })
+    controller.edit({ text: 'new draft' })
+
+    release()
+    await pending
+
+    expect(controller.state.getSnapshot()).toMatchObject({
+      target: { kind: 'message', messageId: MSG }, text: 'new draft', toast: 1,
+    })
+  })
+
+  it('drops a failure that lands after the draft was dismissed', async () => {
+    let release = (): void => {}
+    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+      release = () => { resolve({ ok: false, error: { code: 'gateway/internal', message: 'boom' } }) }
+    })
+    const { controller } = bench(() => gate)
+    controller.open({ kind: 'session' })
+    const pending = controller.submitDraft()
+    controller.dismiss()
+
+    release()
+    await pending
+
+    expect(controller.state.getSnapshot()).toMatchObject({ target: null, failure: null, submitting: false, toast: 0 })
+  })
+
+  it('retires only the toast the view finished showing', () => {
+    const { controller } = bench()
+
+    controller.acknowledge()
+    controller.acknowledge()
+    expect(controller.state.getSnapshot().toast).toBe(2)
+
+    controller.dismissToast(1)
+    expect(controller.state.getSnapshot().toast).toBe(2)
+    controller.dismissToast(2)
+    expect(controller.state.getSnapshot().toast).toBe(0)
+  })
+
+  it('keeps a toast on screen across a dismiss and drops everything on dispose', () => {
+    const { controller } = bench()
+    controller.acknowledge()
+    controller.open({ kind: 'session' })
+
+    controller.dismiss()
+    expect(controller.state.getSnapshot()).toMatchObject({ target: null, toast: 1 })
+
+    controller.open({ kind: 'session' })
+    controller.dispose()
+    expect(controller.state.getSnapshot()).toEqual({
+      target: null, category: null, text: '', submitting: false, failure: null, toast: 0,
+    })
+  })
+})

+ 122 - 0
packages/client/ui-message-feedback/tests/feedback-dialog.client.spec.tsx

@@ -0,0 +1,122 @@
+// @vitest-environment jsdom
+/**
+ * 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.
+ */
+import { useSyncExternalStore } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { FEEDBACK_CATEGORIES } from '@deepseek-ai/dsh-command-feedback/types'
+import { FeedbackDialog } from '../src/client/FeedbackDialog.tsx'
+import type { FeedbackDialogState } from '../src/client/dialog.ts'
+import { zh } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t = makeTranslate(zh, commonZh)
+
+/** Render the entry over a fixed state and recording verbs. */
+function mount(overrides: Partial<FeedbackDialogState> = {}) {
+  const state: FeedbackDialogState = {
+    target: { kind: 'session' }, category: null, text: '', submitting: false, failure: null, toast: 0,
+    ...overrides,
+  }
+  const verbs = {
+    edit: vi.fn(),
+    submit: vi.fn(() => Promise.resolve()),
+    dismiss: vi.fn(),
+    dismissToast: vi.fn(),
+  }
+  const useDialog = (<T,>(select: (v: FeedbackDialogState) => T): T =>
+    useSyncExternalStore(() => () => {}, () => select(state))) as never
+  const props = { useDialog, ...verbs, t } as unknown as Parameters<typeof FeedbackDialog>[0]
+  return { ...render(<FeedbackDialog {...props} />), ...verbs }
+}
+
+describe('FeedbackDialog', () => {
+  it('renders nothing but the probe while closed with no toast', () => {
+    const ui = mount({ target: null })
+
+    expect(ui.queryByRole('dialog')).toBeNull()
+    expect(ui.queryByRole('alert')).toBeNull()
+  })
+
+  it('shows every category, the detail box with the hint, and an enabled Submit for an empty draft', () => {
+    const ui = mount()
+
+    const dialog = ui.getByRole('dialog', { name: zh['dialog.title'] })
+    expect(dialog).toBeTruthy()
+    const chips = ui.getByRole('group', { name: zh['dialog.categories'] }).querySelectorAll('button')
+    expect([...chips].map(chip => chip.textContent)).toEqual(
+      FEEDBACK_CATEGORIES.map(category => zh[`category.${category}`]),
+    )
+    expect(ui.getByLabelText(zh['dialog.detail']).getAttribute('placeholder')).toBe(zh['dialog.hint'])
+    expect(ui.getByRole('button', { name: commonZh.submit }).hasAttribute('disabled')).toBe(false)
+  })
+
+  it('selects a chip, and clears it when the selected chip is clicked again', () => {
+    const ui = mount({ category: 'task-result' })
+
+    const chips = ui.getByRole('group', { name: zh['dialog.categories'] }).querySelectorAll('button')
+    expect(chips[0]?.getAttribute('aria-pressed')).toBe('true')
+    expect(chips[1]?.getAttribute('aria-pressed')).toBe('false')
+
+    fireEvent.click(chips[1]!)
+    expect(ui.edit).toHaveBeenLastCalledWith({ category: 'instruction-following' })
+    fireEvent.click(chips[0]!)
+    expect(ui.edit).toHaveBeenLastCalledWith({ category: null })
+  })
+
+  it('forwards typing, submits, and closes through the injected verbs', () => {
+    const ui = mount({ text: 'draft' })
+
+    fireEvent.change(ui.getByLabelText(zh['dialog.detail']), { target: { value: 'draft more' } })
+    expect(ui.edit).toHaveBeenCalledWith({ text: 'draft more' })
+
+    fireEvent.click(ui.getByRole('button', { name: commonZh.submit }))
+    expect(ui.submit).toHaveBeenCalledTimes(1)
+
+    fireEvent.click(ui.getByRole('button', { name: commonZh.close }))
+    expect(ui.dismiss).toHaveBeenCalledTimes(1)
+  })
+
+  it('disables Submit and the chips while a submission is in flight', () => {
+    const ui = mount({ submitting: true })
+
+    expect(ui.getByRole('button', { name: commonZh.submitting }).hasAttribute('disabled')).toBe(true)
+    const chips = ui.getByRole('group', { name: zh['dialog.categories'] }).querySelectorAll('button')
+    expect([...chips].every(chip => chip.hasAttribute('disabled'))).toBe(true)
+  })
+
+  it('renders the conflict and size copy for their codes and the generic copy otherwise', () => {
+    const conflict = mount({ failure: 'version-conflict' })
+    expect(conflict.getByRole('status').textContent).toBe(zh['error.conflict'])
+    cleanup()
+
+    const oversized = mount({ failure: 'note-too-large' })
+    expect(oversized.getByRole('status').textContent).toBe(zh['error.noteTooLarge'])
+    cleanup()
+
+    const other = mount({ failure: 'session-not-found' })
+    expect(other.getByRole('status').textContent).toBe(zh['error.generic'])
+  })
+
+  it('shows the acknowledgement toast and retires it after the fade', () => {
+    vi.useFakeTimers()
+    try {
+      const ui = mount({ target: null, toast: 3 })
+
+      expect(ui.getByRole('alert').textContent).toBe(zh['toast.recorded'])
+      act(() => { vi.advanceTimersByTime(4000) })
+      expect(ui.dismissToast).toHaveBeenCalledWith(3)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+})

+ 84 - 547
packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx

@@ -1,14 +1,15 @@
 // @vitest-environment jsdom
 /**
  * MessageFeedbackActions rendering and gestures: the rating buttons reflect the
- * shared view, re-clicking the active rating retracts it, the note editor
- * saves through the same rate verb, the Session's feedback is read on first
- * interaction rather than on mount, and a rejected mutation surfaces inline
- * without losing the authoritative state.
+ * shared view with the filled glyph for a recorded rating, Like records at once
+ * and raises the acknowledgement only when a rating was recorded, Dislike opens
+ * the Session's dialog unless it retracts a recorded Dislike, the Session's
+ * feedback is read on first interaction rather than on mount, and a rejected
+ * mutation surfaces inline without losing the authoritative state.
  */
 import { useSyncExternalStore } from 'react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { MessageId } from '@deepseek-ai/dsh-api-remotes/client'
@@ -16,7 +17,9 @@ import type {
   MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion,
 } from '@deepseek-ai/dsh-message-feedback/types'
 import { MessageFeedbackActions } from '../src/client/MessageFeedbackActions.tsx'
-import type { MessageFeedbackActionResult, MessageFeedbackView } from '../src/client/controller.ts'
+import type {
+  MessageFeedbackActionResult, MessageFeedbackToggleResult, MessageFeedbackView,
+} from '../src/client/controller.ts'
 import { zh } from '../src/client/locales.ts'
 
 afterEach(cleanup)
@@ -38,8 +41,9 @@ function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem
 /** Render the controls over a fixed view and recording verbs. */
 function mount(options: {
   current?: MessageFeedbackItem | undefined
-  rateResult?: MessageFeedbackActionResult
-  clearResult?: MessageFeedbackActionResult
+  /** The controller's committed item when it differs from the rendered view (a cold row). */
+  committed?: MessageFeedbackItem
+  toggleResult?: MessageFeedbackToggleResult
   status?: MessageFeedbackView['status']
 } = {}) {
   const view: MessageFeedbackView = {
@@ -48,21 +52,19 @@ function mount(options: {
     error: null,
   }
   const ensure = vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true }))
-  const rate = vi.fn((_id: MessageId, _rating: MessageFeedbackRating, _note?: string) =>
-    Promise.resolve(options.rateResult ?? { ok: true as const }))
-  const clear = vi.fn((_id: MessageId) =>
-    Promise.resolve(options.clearResult ?? { ok: true as const }))
-  // The controller owns retract-vs-replace, so the double stands in for it:
-  // matching the shown rating retracts, anything else replaces.
-  const toggle = vi.fn((id: MessageId, next: MessageFeedbackRating) =>
-    (options.current?.rating === next ? clear(id) : rate(id, next)))
-  const clearNote = vi.fn((_id: MessageId) =>
-    Promise.resolve(options.rateResult ?? { ok: true as const }))
+  // The controller owns record-vs-retract, so the double stands in for it:
+  // matching the shown rating retracts, anything else records.
+  const toggle = vi.fn((_id: MessageId, next: MessageFeedbackRating) =>
+    Promise.resolve<MessageFeedbackToggleResult>(options.toggleResult
+      ?? { ok: true, rating: options.current?.rating === next ? null : next }))
+  const openDialog = vi.fn((_id: MessageId) => {})
+  const acknowledge = vi.fn(() => {})
+  const current = vi.fn((_id: MessageId) => options.committed ?? options.current)
   const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
     useSyncExternalStore(() => () => {}, () => select(view))) as never
-  const props = { messageId: MSG, ensure, rate, toggle, clearNote, clear, useFeedback, t } as unknown as
+  const props = { messageId: MSG, ensure, current, toggle, openDialog, acknowledge, useFeedback, t } as unknown as
     Parameters<typeof MessageFeedbackActions>[0]
-  return { ...render(<MessageFeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
+  return { ...render(<MessageFeedbackActions {...props} />), ensure, toggle, openDialog, acknowledge }
 }
 
 describe('MessageFeedbackActions', () => {
@@ -73,11 +75,14 @@ describe('MessageFeedbackActions', () => {
     expect(ui.getByLabelText(zh['action.dislike']).getAttribute('aria-pressed')).toBe('false')
   })
 
-  it('marks the recorded rating pressed and offers to retract it', () => {
+  it('marks the recorded rating pressed, fills its glyph, and offers to retract it', () => {
     const ui = mount({ current: item({ rating: 'negative' }) })
 
-    expect(ui.getByLabelText(zh['action.dislikeActive']).getAttribute('aria-pressed')).toBe('true')
+    const dislike = ui.getByLabelText(zh['action.dislikeActive'])
+    expect(dislike.getAttribute('aria-pressed')).toBe('true')
+    expect(dislike.hasAttribute('data-active')).toBe(true)
     expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
+    expect(ui.getByLabelText(zh['action.like']).hasAttribute('data-active')).toBe(false)
   })
 
   it('reads the Session feedback on first interaction, once', () => {
@@ -97,84 +102,77 @@ describe('MessageFeedbackActions', () => {
     expect(ui.ensure).not.toHaveBeenCalled()
   })
 
-  it('rates a message that has no feedback yet', async () => {
+  it('records a Like at once and acknowledges it', async () => {
     const ui = mount()
 
     fireEvent.click(ui.getByLabelText(zh['action.like']))
 
     await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
-    expect(ui.clear).not.toHaveBeenCalled()
-  })
-
-  it('replaces the opposite rating and carries the existing note forward', async () => {
-    const ui = mount({ current: item({ rating: 'positive', note: 'keep me' }) })
-
-    fireEvent.click(ui.getByLabelText(zh['action.dislike']))
-
-    await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
+    await waitFor(() => { expect(ui.acknowledge).toHaveBeenCalledTimes(1) })
+    expect(ui.openDialog).not.toHaveBeenCalled()
   })
 
-  it('retracts the feedback when the active rating is clicked again', async () => {
+  it('retracts a recorded Like on click without acknowledging', async () => {
     const ui = mount({ current: item({ rating: 'positive' }) })
 
     fireEvent.click(ui.getByLabelText(zh['action.likeActive']))
 
     await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'positive') })
-    // The double routes a matching rating to clear(), mirroring the controller.
-    await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
+    await waitFor(() => { expect(ui.getByLabelText(zh['action.likeActive']).hasAttribute('disabled')).toBe(false) })
+    expect(ui.acknowledge).not.toHaveBeenCalled()
   })
 
-  it('saves a typed note through the rate verb and closes the editor', async () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
+  it('opens the dialog for a Dislike instead of recording it', async () => {
+    const ui = mount()
 
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: '  precise and short  ' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
+    fireEvent.click(ui.getByLabelText(zh['action.dislike']))
 
-    await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', 'precise and short') })
-    await waitFor(() => { expect(ui.queryByLabelText(zh['note.aria'])).toBeNull() })
+    await waitFor(() => { expect(ui.openDialog).toHaveBeenCalledWith(MSG) })
+    expect(ui.toggle).not.toHaveBeenCalled()
   })
 
-  it('clears the note when the editor is emptied', async () => {
-    const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
+  it('opens the dialog for a Dislike that replaces a recorded Like', async () => {
+    const ui = mount({ current: item({ rating: 'positive' }) })
 
-    fireEvent.click(ui.getByText('old note'))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: '   ' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
+    fireEvent.click(ui.getByLabelText(zh['action.dislike']))
 
-    await waitFor(() => { expect(ui.clearNote).toHaveBeenCalledWith(MSG) })
+    await waitFor(() => { expect(ui.openDialog).toHaveBeenCalledWith(MSG) })
+    expect(ui.toggle).not.toHaveBeenCalled()
   })
 
-  it('seeds the editor with the recorded note and abandons it on cancel', () => {
-    const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
+  it('decides a Dislike from the committed item, so a cold row retracts a stored Dislike', async () => {
+    const ui = mount({ committed: item({ rating: 'negative' }) })
 
-    fireEvent.click(ui.getByText('old note'))
-    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old note')
+    fireEvent.click(ui.getByLabelText(zh['action.dislike']))
 
-    fireEvent.click(ui.getByText(zh['note.cancel']))
-    expect(ui.queryByLabelText(zh['note.aria'])).toBeNull()
-    expect(ui.rate).not.toHaveBeenCalled()
+    await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
+    expect(ui.openDialog).not.toHaveBeenCalled()
   })
 
-  it('offers no note editor before a rating is recorded', () => {
-    const ui = mount()
+  it('retracts a recorded Dislike on click without the dialog', async () => {
+    const ui = mount({ current: item({ rating: 'negative' }) })
 
-    expect(ui.queryByText(zh['note.open'])).toBeNull()
+    fireEvent.click(ui.getByLabelText(zh['action.dislikeActive']))
+
+    await waitFor(() => { expect(ui.toggle).toHaveBeenCalledWith(MSG, 'negative') })
+    expect(ui.openDialog).not.toHaveBeenCalled()
+    expect(ui.acknowledge).not.toHaveBeenCalled()
   })
 
   it('reports a lost race with the conflict copy', async () => {
     const ui = mount({
-      rateResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
+      toggleResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
     })
 
     fireEvent.click(ui.getByLabelText(zh['action.like']))
 
     await waitFor(() => { expect(ui.getByText(zh['error.conflict'])).toBeTruthy() })
+    expect(ui.acknowledge).not.toHaveBeenCalled()
   })
 
   it('reports any other failure with the generic copy', async () => {
     const ui = mount({
-      rateResult: { ok: false, error: { code: 'target-not-found', message: 'no such message' } },
+      toggleResult: { ok: false, error: { code: 'target-not-found', message: 'no such message' } },
     })
 
     fireEvent.click(ui.getByLabelText(zh['action.like']))
@@ -182,24 +180,20 @@ describe('MessageFeedbackActions', () => {
     await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
   })
 
-  it('keeps the editor open when the note fails to save', async () => {
+  it('reports a failed retraction of a recorded Dislike', async () => {
     const ui = mount({
-      current: item({ rating: 'positive' }),
-      rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
+      current: item({ rating: 'negative' }),
+      toggleResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
     })
 
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
-    fireEvent.click(ui.getByText(zh['note.save']))
+    fireEvent.click(ui.getByLabelText(zh['action.dislikeActive']))
 
-    await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
-    // The draft survives so the human can shorten it instead of retyping.
-    expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
+    await waitFor(() => { expect(ui.getByText(zh['error.conflict'])).toBeTruthy() })
   })
 
   it('publishes no state after the row unmounts mid-flight', async () => {
     let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
+    const gate = new Promise<MessageFeedbackToggleResult>((resolve) => {
       release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
     })
     const view: MessageFeedbackView = { status: 'ready', items: new Map(), error: null }
@@ -208,10 +202,9 @@ describe('MessageFeedbackActions', () => {
     const props = {
       messageId: MSG,
       ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
       toggle: vi.fn(() => gate),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      openDialog: vi.fn(),
+      acknowledge: vi.fn(),
       useFeedback,
       t,
     } as unknown as Parameters<typeof MessageFeedbackActions>[0]
@@ -229,126 +222,23 @@ describe('MessageFeedbackActions', () => {
     expect(errors).toEqual([])
   })
 
-  it('surfaces a failed list load next to the controls', async () => {
-    const ui = mount({ status: 'error' })
-
-    expect(ui.getByText(zh['error.load'])).toBeTruthy()
-  })
-
-  it('prefers the action failure over the load notice', async () => {
-    const ui = mount({
-      status: 'error',
-      rateResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
-    })
-
-    fireEvent.click(ui.getByLabelText(zh['action.like']))
-
-    await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
-    expect(ui.queryByText(zh['error.load'])).toBeNull()
-  })
-
-  it('portals the note editor to the document body, not into the actions row', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-
-    // The editor must float above the transcript (escaping the conversation
-    // column's overflow clip), so it renders through a portal to document.body
-    // rather than inline inside the component's own container.
-    const panel = ui.getByRole('dialog')
-    expect(panel).toBeTruthy()
-    expect(ui.container.querySelector('[role="dialog"]')).toBeNull()
-    expect(document.body.contains(panel)).toBe(true)
-  })
-
-  it('closes the note popover on Escape', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    fireEvent.keyDown(document, { key: 'Escape' })
-
-    expect(ui.queryByRole('dialog')).toBeNull()
-  })
-
-  it('closes the note popover on an outside pointer-down', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    fireEvent.pointerDown(document.body)
-
-    expect(ui.queryByRole('dialog')).toBeNull()
-  })
-
-  it('keeps the note popover open on a pointer-down inside it', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    const panel = ui.getByRole('dialog')
-    expect(panel).toBeTruthy()
-
-    fireEvent.pointerDown(panel)
-
-    expect(ui.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('does not close the note popover on a pointer-down on its trigger', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    // The trigger is inside the panel's own region, so pressing it must not be
-    // treated as an outside click; the toggle click below then closes it.
-    fireEvent.pointerDown(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('toggles the note popover closed and open from its trigger', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-    expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.queryByRole('dialog')).toBeNull()
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('ignores keys other than Escape while the popover is open', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    fireEvent.keyDown(document, { key: 'Enter' })
-
-    expect(ui.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('publishes no rating-state after the row unmounts mid-flight', async () => {
-    // Directly exercise the early-return of a rating settle once the control has
-    // unmounted: the promise resolution must not touch React state.
+  it('publishes no state after the row unmounts mid-retraction', async () => {
     let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => { resolve({ ok: true as const }) }
+    const gate = new Promise<MessageFeedbackToggleResult>((resolve) => {
+      release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
     })
-    const view: MessageFeedbackView = { status: 'ready', items: new Map(), error: null }
+    const view: MessageFeedbackView = {
+      status: 'ready', items: new Map([[MSG, item({ rating: 'negative' })]]), error: null,
+    }
     const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
       useSyncExternalStore(() => () => {}, () => select(view))) as never
     const props = {
       messageId: MSG,
       ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      current: () => item({ rating: 'negative' }),
       toggle: vi.fn(() => gate),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
+      openDialog: vi.fn(),
+      acknowledge: vi.fn(),
       useFeedback,
       t,
     } as unknown as Parameters<typeof MessageFeedbackActions>[0]
@@ -357,43 +247,7 @@ describe('MessageFeedbackActions', () => {
     const onError = (event: ErrorEvent): void => { errors.push(event.error) }
     window.addEventListener('error', onError)
 
-    fireEvent.click(ui.getByLabelText(zh['action.like']))
-    ui.unmount()
-    release()
-    await gate
-
-    window.removeEventListener('error', onError)
-    expect(errors).toEqual([])
-  })
-
-  it('publishes no note-state after the row unmounts mid-save', async () => {
-    // Same unmount early-return for the note-save settle path: resolving the
-    // save promise after unmount must not touch React state.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => { resolve({ ok: true as const }) }
-    })
-    const view: MessageFeedbackView = { status: 'ready', items: new Map([[MSG, item({ rating: 'positive' })]]), error: null }
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore(() => () => {}, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const errors: unknown[] = []
-    const onError = (event: ErrorEvent): void => { errors.push(event.error) }
-    window.addEventListener('error', onError)
-
-    const ui = render(<MessageFeedbackActions {...props} />)
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
+    fireEvent.click(ui.getByLabelText(zh['action.dislikeActive']))
     ui.unmount()
     release()
     await gate
@@ -402,338 +256,21 @@ describe('MessageFeedbackActions', () => {
     expect(errors).toEqual([])
   })
 
-  it('ignores a pointer-down whose target is not a DOM node', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    // The outside-click guard returns without closing when the event target is
-    // not a DOM node. `document.dispatchEvent` delivers straight to the
-    // document listener, and a non-Node target is not `instanceof Node`.
-    const event = new MouseEvent('pointerdown', { bubbles: true })
-    Object.defineProperty(event, 'target', { configurable: true, value: { notANode: true } })
-    document.dispatchEvent(event)
-
-    expect(ui.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('returns focus to the trigger when the popover closes', () => {
-    const ui = mount({ current: item({ rating: 'positive' }) })
-    const trigger = ui.getByText(zh['note.open'])
-
-    fireEvent.click(trigger)
-    expect(ui.getByRole('dialog')).toBeTruthy()
-    fireEvent.keyDown(document, { key: 'Escape' })
-
-    // Closing hands focus back, so a keyboard user resumes on the row they
-    // came from rather than at the document root.
-    expect(ui.queryByRole('dialog')).toBeNull()
-    expect(document.activeElement).toBe(trigger)
-  })
-
-  it('does not pull focus when an already-rated message mounts', () => {
-    // The `wasOpen` guard exists for this: a transcript of already-rated
-    // messages must not drag focus into an action row as each one mounts.
-    // Only a real open-then-close returns focus.
-    const elsewhere = document.createElement('button')
-    document.body.append(elsewhere)
-    elsewhere.focus()
-
-    mount({ current: item({ rating: 'positive' }) })
+  it('surfaces a failed list load next to the controls', () => {
+    const ui = mount({ status: 'error' })
 
-    expect(document.activeElement).toBe(elsewhere)
-    elsewhere.remove()
+    expect(ui.getByText(zh['error.load'])).toBeTruthy()
   })
 
-  it('drops a stale save failure when the popover is reopened', async () => {
-    // The failure belongs to the editing session that produced it: reopening
-    // reseeds the draft from the stored note, so a carried-over error would
-    // describe an attempt the new draft never made.
+  it('prefers the action failure over the load notice', async () => {
     const ui = mount({
-      current: item({ rating: 'positive' }),
-      rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
-    })
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
-    fireEvent.click(ui.getByText(zh['note.save']))
-    await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
-
-    fireEvent.keyDown(document, { key: 'Escape' })
-    fireEvent.click(ui.getByText(zh['note.open']))
-
-    expect(ui.queryByText(zh['error.generic'])).toBeNull()
-  })
-
-  it('keeps a save failure visible when the rating disappears underneath it', async () => {
-    // Another client retracts the feedback while the editor is open: the
-    // controller commits `current: null`, the item goes away, and the panel
-    // unmounts. The failure must not vanish with it, so it falls back to the row.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
+      status: 'error',
+      toggleResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
     })
-    const view: MessageFeedbackView = {
-      status: 'ready',
-      items: new Map([[MSG, item({ rating: 'positive' })]]),
-      error: null,
-    }
-    let notify = (): void => {}
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore((cb) => { notify = cb; return () => {} }, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const ui = render(<MessageFeedbackActions {...props} />)
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
 
-    // The retract lands first, then the save rejects.
-    view.items = new Map()
-    notify()
-    release()
-    await gate
+    fireEvent.click(ui.getByLabelText(zh['action.like']))
 
     await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
-    expect(ui.queryByRole('dialog')).toBeNull()
-  })
-
-  it('ignores a save that resolves after its editing session ended', async () => {
-    // Closing and reopening starts a new session. A late success from the old
-    // one must not shut the panel the human just opened.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => { resolve({ ok: true as const }) }
-    })
-    const view: MessageFeedbackView = {
-      status: 'ready',
-      items: new Map([[MSG, item({ rating: 'positive' })]]),
-      error: null,
-    }
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore(() => () => {}, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const ui = render(<MessageFeedbackActions {...props} />)
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
-
-    // Abandon that session and start another before the save lands.
-    fireEvent.keyDown(document, { key: 'Escape' })
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    release()
-    await gate
-    // Flush the `.then` continuation and the render it would cause. Asserted
-    // directly rather than through `waitFor`, which would retry past a panel
-    // that the stale result closed.
-    await act(async () => { await Promise.resolve() })
-
-    expect(ui.getByRole('dialog')).toBeTruthy()
-    // The reply is discarded, but the request is no longer in flight, so the
-    // controls must not stay disabled: `pending` gates the rating buttons and
-    // Save, and leaving it set locks this message's row until it remounts.
-    expect(ui.getByLabelText(zh['action.likeActive']).hasAttribute('disabled')).toBe(false)
-    expect(ui.getByLabelText(zh['action.dislike']).hasAttribute('disabled')).toBe(false)
-    expect(ui.getByText(zh['note.save']).hasAttribute('disabled')).toBe(false)
-  })
-
-  it('reports a save that fails after the human closed the panel', async () => {
-    // A slow save that rejects once the panel is gone must not be swallowed:
-    // the human would otherwise believe the note was stored. With no panel to
-    // show it in, the row carries the notice.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => {
-        resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
-      }
-    })
-    const view: MessageFeedbackView = {
-      status: 'ready',
-      items: new Map([[MSG, item({ rating: 'positive' })]]),
-      error: null,
-    }
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore(() => () => {}, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const ui = render(<MessageFeedbackActions {...props} />)
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'hi' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
-
-    // Walk away before the reply lands, and leave it closed.
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(ui.queryByRole('dialog')).toBeNull()
-
-    release()
-    await gate
-    await act(async () => { await Promise.resolve() })
-
-    expect(ui.getByText(zh['error.generic'])).toBeTruthy()
-  })
-
-  it('does not write an abandoned session\'s failure into a reopened panel', async () => {
-    // The old request rejects after the panel was closed and reopened, so the
-    // new session owns the panel. Its draft was not the one that failed, so the
-    // stale error must not be shown there; it belongs to the abandoned session.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => {
-        resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } })
-      }
-    })
-    const view: MessageFeedbackView = {
-      status: 'ready',
-      items: new Map([[MSG, item({ rating: 'positive' })]]),
-      error: null,
-    }
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore(() => () => {}, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const ui = render(<MessageFeedbackActions {...props} />)
-
-    fireEvent.click(ui.getByText(zh['note.open']))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'first' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
-
-    // Abandon that session and start another before the save rejects; unlike
-    // the closed-and-left case, a new panel is now on screen.
-    fireEvent.keyDown(document, { key: 'Escape' })
-    fireEvent.click(ui.getByText(zh['note.open']))
-    expect(ui.getByRole('dialog')).toBeTruthy()
-
-    release()
-    await gate
-    await act(async () => { await Promise.resolve() })
-
-    // The stale failure names a draft the new session never sent, so it stays
-    // out of the reopened panel's status area.
-    expect(ui.queryByText(zh['error.generic'])).toBeNull()
-    expect(ui.getByRole('dialog')).toBeTruthy()
-  })
-
-  it('resyncs an untouched reopened draft to the note that just committed', async () => {
-    // The reopened session seeded from the note as it read before the save
-    // committed, so an untouched draft would show stale text and the next save
-    // could overwrite what just landed.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => { resolve({ ok: true as const }) }
-    })
-    const view: MessageFeedbackView = {
-      status: 'ready',
-      items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
-      error: null,
-    }
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore(() => () => {}, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const ui = render(<MessageFeedbackActions {...props} />)
-
-    fireEvent.click(ui.getByText('old'))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
-
-    // Close and reopen before the save lands: the new draft is seeded from the
-    // still-stale stored note.
-    fireEvent.keyDown(document, { key: 'Escape' })
-    fireEvent.click(ui.getByText('old'))
-    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old')
-
-    release()
-    await gate
-    await act(async () => { await Promise.resolve() })
-
-    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('saved text')
-  })
-
-  it('leaves a reopened draft alone once the human has edited it', async () => {
-    // The opposite arm: an edited draft belongs to the human, so a late save
-    // must not overwrite what they are typing.
-    let release = (): void => {}
-    const gate = new Promise<MessageFeedbackActionResult>((resolve) => {
-      release = () => { resolve({ ok: true as const }) }
-    })
-    const view: MessageFeedbackView = {
-      status: 'ready',
-      items: new Map([[MSG, item({ rating: 'positive', note: 'old' })]]),
-      error: null,
-    }
-    const useFeedback = (<T,>(select: (v: MessageFeedbackView) => T): T =>
-      useSyncExternalStore(() => () => {}, () => select(view))) as never
-    const props = {
-      messageId: MSG,
-      ensure: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      rate: vi.fn(() => gate),
-      toggle: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clearNote: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      clear: vi.fn(() => Promise.resolve<MessageFeedbackActionResult>({ ok: true })),
-      useFeedback,
-      t,
-    } as unknown as Parameters<typeof MessageFeedbackActions>[0]
-    const ui = render(<MessageFeedbackActions {...props} />)
-
-    fireEvent.click(ui.getByText('old'))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'saved text' } })
-    fireEvent.click(ui.getByText(zh['note.save']))
-
-    fireEvent.keyDown(document, { key: 'Escape' })
-    fireEvent.click(ui.getByText('old'))
-    fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'my new words' } })
-
-    release()
-    await gate
-    await act(async () => { await Promise.resolve() })
-
-    expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('my new words')
+    expect(ui.queryByText(zh['error.load'])).toBeNull()
   })
 })

+ 28 - 42
packages/client/ui-message-feedback/tests/styles.client.spec.ts

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

+ 9 - 0
packages/client/ui-message-feedback/tsconfig.json

@@ -43,6 +43,15 @@
     },
     {
       "path": "../ui-slots"
+    },
+    {
+      "path": "../../feedback/command-feedback"
+    },
+    {
+      "path": "../ui-commands"
+    },
+    {
+      "path": "../store"
     }
   ]
 }

+ 13 - 8
packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts

@@ -16,7 +16,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ModelSelection, ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
-import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
+import type { CommandContribution, PopupSelectSpec, SelectOption } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { ModelSelectInjected } from '../src/client/slots.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { zh } from '../src/client/locales.ts'
@@ -157,6 +157,11 @@ async function bench() {
   return {
     ctx, fiber, mint, calls, remote,
     contribution: () => contribution!,
+    popup: (): PopupSelectSpec => {
+      const ui = contribution!.ui
+      if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind')
+      return ui
+    },
     seat: () => seats.get('conversation.input.model')!,
     hostCurrent: () => selected,
     setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection },
@@ -182,7 +187,7 @@ describe('ui-model-selection dual entry', () => {
   it('popup options mark the host current active with the provider group in the detail', async () => {
     const b = await bench()
     b.mint('s1')
-    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
     expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro'])
     expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' })
     expect(options[1]?.active).toBeUndefined()
@@ -209,7 +214,7 @@ describe('ui-model-selection dual entry', () => {
       reasoningEffort: 'max',
     })
     // The POPUP's next options pass reflects it without a seat-side reload.
-    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
     expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true })
   })
 
@@ -217,9 +222,9 @@ describe('ui-model-selection dual entry', () => {
     const b = await bench()
     b.mint('s1')
     const seatFace = b.seat().inject!(sid('s1'))
-    const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal)
+    const options = await b.popup().options(projection('s1'), new AbortController().signal)
     const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')!
-    await b.contribution().ui.onSelect(pro, projection('s1'))
+    await b.popup().onSelect(pro, projection('s1'))
     expect(seatFace.directory.getSnapshot().current).toEqual({
       provider: 'deepseek-official',
       model: 'deepseek-v4-pro',
@@ -239,8 +244,8 @@ describe('ui-model-selection dual entry', () => {
     // The service face resolves the same instance the seat inject handed out.
     expect(b.ctx.modelDirectories.directoryFor(sid('a')).store).toBe(faceA.directory)
     await Promise.all([
-      b.contribution().ui.options(projection('a'), new AbortController().signal),
-      b.contribution().ui.options(projection('b'), new AbortController().signal),
+      b.popup().options(projection('a'), new AbortController().signal),
+      b.popup().options(projection('b'), new AbortController().signal),
     ])
     expect(b.calls.models).toBe(1)
   })
@@ -369,7 +374,7 @@ describe('ui-model-selection dual entry', () => {
     b.address(sid('child'))
 
     expect(b.contribution().available(projection('child'))).toBe(false)
-    await expect(b.contribution().ui.options(
+    await expect(b.popup().options(
       projection('child'),
       new AbortController().signal,
     )).rejects.toThrow(/unavailable for addressed subagent/)

+ 15 - 11
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -15,7 +15,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
-import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-commands/client'
+import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client'
 import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client'
 import {
   PermissionRow, type PermissionRowInjected,
@@ -82,6 +82,11 @@ async function bench() {
     ctx, fiber, locale, values, commands, remote,
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     decoration: () => decoration,
+    popup: (): PopupSelectSpec => {
+      const ui = decoration!.ui
+      if (ui.kind !== 'popupSelect') throw new Error('expected the popupSelect kind')
+      return ui
+    },
     permissionRow: () => ctx.slots.entries('settings.general.item')
       .find(entry => entry.component === PermissionRow),
   }
@@ -110,11 +115,11 @@ describe('ui-permission browser plugin', () => {
     expect(c.available(proj)).toBe(false)
     b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
     expect(c.available(proj)).toBe(true)
-    const options = await c.ui.options(proj, new AbortController().signal)
+    const options = await b.popup().options(proj, new AbortController().signal)
     expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
     expect(options.every(option => option.active !== true)).toBe(true)
     b.values.set(sid('s1'), SELECT)
-    const again = await c.ui.options(proj, new AbortController().signal)
+    const again = await b.popup().options(proj, new AbortController().signal)
     expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
     // English built-ins use product labels; other kebab-case names title-case.
@@ -127,7 +132,7 @@ describe('ui-permission browser plugin', () => {
       confirmLabel: 'Enable Full access',
     })
     b.locale.setLocale('zh')
-    const localized = await c.ui.options(proj, new AbortController().signal)
+    const localized = await b.popup().options(proj, new AbortController().signal)
     expect(localized.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限'])
     expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
       title: '确认启用完全权限?',
@@ -143,28 +148,27 @@ describe('ui-permission browser plugin', () => {
       { value: '__proto__', name: '__proto__' },
       { value: 'plain', name: 'Ask Every Time' },
     ] })
-    const passthrough = await c.ui.options(proj, new AbortController().signal)
+    const passthrough = await b.popup().options(proj, new AbortController().signal)
     expect(passthrough.map(option => option.label)).toEqual([
       'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
     ])
     // A projection that vanished between availability and open throws.
-    expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
+    expect(() => b.popup().options({ sessionId: sid('ghost') }, new AbortController().signal))
       .toThrow(/not available on this host/)
   })
 
   it('a pick submits the /permission line; rejection and unmatched throw', async () => {
     const b = await bench()
-    const c = b.decoration()!
     const proj = { sessionId: sid('s1') }
     b.values.set(sid('s1'), SELECT)
-    await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
+    await b.popup().onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
     expect(b.commands).toEqual(['/permission danger-full-access'])
     b.setResult({ ok: false })
-    await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
+    await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
     b.setResult({ ok: true, matched: false })
-    await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
+    await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
     // An unmaterialized session throws before any submit.
-    await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
+    await expect(b.popup().onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
       .rejects.toThrow(/not materialized/)
   })
 

+ 2 - 3
packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx

@@ -3,9 +3,8 @@
  * `useAnchoredPosition` wiring: a floating panel is placed from its anchor and
  * keeps tracking it while open.
  *
- * The geometry itself needs real layout, which jsdom does not provide — the
- * browser layout scenario in `apps/web/tests/message-feedback-layout.e2e.ts`
- * owns that. What is asserted here is the wiring the clamp depends on: the
+ * The geometry itself needs real layout, which jsdom does not provide. What
+ * is asserted here is the wiring the clamp depends on: the
  * listeners and the panel-size observer are attached while open and released on
  * close, a size change replays the placement, and the hook still works where
  * `ResizeObserver` does not exist.

+ 1 - 0
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -847,6 +847,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [
       'client-ui-commands PopupSelectView id \'command-popup\'',
       'client-ui-input-trigger MenuView id \'slash-menu\'',
+      'client-ui-message-feedback FeedbackDialog id \'feedback-dialog\'',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n      { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',

+ 35 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1556,6 +1556,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'sessionFeedback',
+    summary: 'Host Remote through which a product surface records a Session-level remark.',
+    description: 'Host Remote through which a product surface records a Session-level remark.',
+    methods: [
+      {
+        signature: '@Remote(\'record\') record(request: SessionFeedbackRecordRequest): Promise<SessionFeedbackRecordResult>',
+        description: 'Record one remark on a live Session.',
+        parameters: [{ name: 'request', description: 'target Session plus the optional text and category.' }],
+        returns: 'the recorded postcondition, or `session-not-found` when no live Session carries the id.',
+      },
+    ],
+  },
   {
     key: 'sessionFileReferences',
     summary: 'Host Remote adapter over the composed file-reference provider.',
@@ -4167,6 +4180,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'EpochHeader',
     declaration: 'export interface EpochHeader {\n    config: LlmCallConfig;\n    adapterDefaults?: LlmCallConfigAdapterDefaults;\n    tools?: ToolSchema[];\n}',
   },
+  {
+    name: 'FeedbackCategory',
+    declaration: 'export type FeedbackCategory = \'task-result\' | \'instruction-following\' | \'product-interaction\' | \'service-stability\' | \'resource-cost\' | \'security-privacy-permission\' | \'other\';',
+  },
   {
     name: 'FiberState',
     declaration: 'export type FiberState = FiberStateEnum;',
@@ -4593,7 +4610,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'MessageFeedbackItem',
-    declaration: 'export interface MessageFeedbackItem {\n    readonly messageId: MessageId;\n    readonly rating: MessageFeedbackRating;\n    readonly note?: string;\n    readonly version: MessageFeedbackVersion;\n    readonly createdAt: number;\n    readonly updatedAt: number;\n}',
+    declaration: 'export interface MessageFeedbackItem {\n    readonly messageId: MessageId;\n    readonly rating: MessageFeedbackRating;\n    readonly note?: string;\n    readonly category?: FeedbackCategory;\n    readonly version: MessageFeedbackVersion;\n    readonly createdAt: number;\n    readonly updatedAt: number;\n}',
   },
   {
     name: 'MessageFeedbackListRequest',
@@ -4617,7 +4634,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'MessageFeedbackPutRequest',
-    declaration: 'export interface MessageFeedbackPutRequest {\n    readonly sessionId: SessionId;\n    readonly messageId: MessageId;\n    readonly rating: MessageFeedbackRating;\n    readonly note?: string;\n    readonly ifVersion: MessageFeedbackVersion | null;\n}',
+    declaration: 'export interface MessageFeedbackPutRequest {\n    readonly sessionId: SessionId;\n    readonly messageId: MessageId;\n    readonly rating: MessageFeedbackRating;\n    readonly note?: string;\n    readonly category?: FeedbackCategory;\n    readonly ifVersion: MessageFeedbackVersion | null;\n}',
   },
   {
     name: 'MessageFeedbackPutResult',
@@ -5127,6 +5144,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionEventWindow',
     declaration: 'export interface SessionEventWindow {\n    session: SessionHeader;\n    inheritedEventCount: SessionLogOffset;\n    target: SessionEvent;\n    events: SessionEvent[];\n    startSeq: SessionSeq;\n    endSeq: SessionSeq;\n}',
   },
+  {
+    name: 'SessionFeedbackRecordRequest',
+    declaration: 'export interface SessionFeedbackRecordRequest {\n    readonly sessionId: SessionId;\n    readonly text?: string;\n    readonly category?: FeedbackCategory;\n}',
+  },
+  {
+    name: 'SessionFeedbackRecordResult',
+    declaration: 'export type SessionFeedbackRecordResult = {\n    readonly ok: true;\n    readonly value: SessionFeedbackRecordValue;\n} | {\n    readonly ok: false;\n    readonly error: SessionFeedbackSessionNotFound;\n};',
+  },
+  {
+    name: 'SessionFeedbackRecordValue',
+    declaration: 'export interface SessionFeedbackRecordValue {\n    readonly recorded: true;\n}',
+  },
+  {
+    name: 'SessionFeedbackSessionNotFound',
+    declaration: 'export interface SessionFeedbackSessionNotFound {\n    readonly code: \'session-not-found\';\n    readonly sessionId: SessionId;\n}',
+  },
   {
     name: 'SessionFollowFrame',
     declaration: 'export type SessionFollowFrame = {\n    readonly type: \'snapshot\';\n    readonly header: SessionWireHeader;\n    readonly cursor: number;\n    readonly records: readonly SessionHistoryRecord[];\n    readonly hasMore: boolean;\n    readonly projections: SessionProjectionBaseline;\n    readonly assistantStream?: SessionAssistantStreamBaseline;\n} | SessionEventEntry | {\n    readonly type: \'assistant-stream\';\n    readonly frame: SessionAssistantStreamFrame;\n};',

+ 2 - 2
packages/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/feedback/README.md
-README.md: ee813fea0350ec6cddfbf66ee21807ed543960d3
-README.zh.md: 736ee5b77e5033f550c29eea14e1d429556da8e4
+README.md: e4b538f2a498cdfa4e265226283c923a19b8b69e
+README.zh.md: 83208f63a9857cc5db37822332e5b7acb4d56007

+ 2 - 2
packages/feedback/README.md

@@ -22,8 +22,8 @@ The feedback group collects human opinions about the harness's work: users can s
 
 | Package | Role |
 |---|---|
-| [`command-feedback`](command-feedback/README.md) | A `/feedback` command that records a free-text session remark with one command, without a model turn |
-| [`message-feedback`](message-feedback/README.md) | Per-message ratings and notes, served to product surfaces through the `messageFeedback` service |
+| [`command-feedback`](command-feedback/README.md) | Session-level feedback: the `/feedback` command, the `sessionFeedback` Remote behind the Web dialog, and the fixed category taxonomy, all without a model turn |
+| [`message-feedback`](message-feedback/README.md) | Per-message ratings, categories, and notes, served to product surfaces through the `messageFeedback` service |
 
 Session remarks are a one-way signal: recording one is safe at any point in a conversation and never changes what the model sees. With a feedback-gated sharing policy, recording a session remark is what releases the session for sharing.
 

+ 2 - 2
packages/feedback/README.zh.md

@@ -22,8 +22,8 @@ feedback 组收集用户对 harness 工作成果的意见:用户可以提交
 
 | 包 | 职责 |
 |---|---|
-| [`command-feedback`](command-feedback/README.zh.md) | 一条命令即可记录自由文本会话评价的 `/feedback` 命令,无需模型轮次 |
-| [`message-feedback`](message-feedback/README.zh.md) | 逐消息评分与备注,通过 `messageFeedback` 服务提供给产品界面 |
+| [`command-feedback`](command-feedback/README.zh.md) | 会话级反馈:`/feedback` 命令、Web 弹窗背后的 `sessionFeedback` Remote,以及固定分类表,均无需模型轮次 |
+| [`message-feedback`](message-feedback/README.zh.md) | 逐消息评分、分类与备注,通过 `messageFeedback` 服务提供给产品界面 |
 
 会话评价是单向信号:在对话的任何时刻记录它都是安全的,且绝不会改变模型看到的内容。在 feedback-gated 共享策略下,记录会话评价正是释放会话共享的动作。
 

+ 2 - 2
packages/feedback/command-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/feedback/command-feedback/README.md
-README.md: 83cb5b759431607a1702b2cc3038532d47db2d32
-README.zh.md: 493ddf72b3b22454773dedcfbdd7d6910bf41e02
+README.md: ed920f1551734513929425c8a2c415c1e86cb487
+README.zh.md: d6d67e2825c00454a611073e3f59af6323d8170b

+ 33 - 13
packages/feedback/command-feedback/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Free-text session feedback through a `/feedback` command, for users and maintainers choosing, composing, or debugging feedback capture."
+description: "Session feedback: the `/feedback` command, the `sessionFeedback` Host Remote behind the Web feedback dialog, and the fixed category taxonomy; for users and maintainers choosing, composing, or debugging feedback capture."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-command-feedback` lets a user tell the harness what they think of a session: type `/feedback` plus a remark, and the remark is recorded and acknowledged. Recording is immediate and never starts model work, so it is safe at any point in a conversation — the model neither sees the remark nor is interrupted by it. The acknowledgement names the session and the anonymous user. The command ships with the Web client and needs no configuration; headless, ACP, and JSON-RPC entry points do not provide slash commands and cannot run it.
+`dsh-command-feedback` lets a user tell the harness what they think of a session. Typing `/feedback` plus a remark records it and acknowledges the session and anonymous user ids; the Web feedback dialog records a category and an optional description through the `sessionFeedback` Host Remote. Recording is immediate and never starts model work: the model neither sees the remark nor is interrupted by it. The package also owns the fixed category taxonomy every feedback surface files under. It ships with the standard `dsh` base and needs no configuration; headless, ACP, and JSON-RPC entry points provide no slash commands.
 
 ## Table of Contents
 
@@ -38,9 +38,26 @@ Type `/feedback` followed by your remark and send it. A successful entry is ackn
 
 Surrounding whitespace is trimmed, but the remark is otherwise kept exactly as typed: no truncation, case folding, or command parsing — `/feedback /plan felt slow` records that literal text. Each command records its own entry; nothing is merged or replaced.
 
+<a id="the-web-feedback-dialog"></a>
+### The Web feedback dialog
+
+In the Web client a bare `/feedback` — picked from the composer menu or typed and sent without text — opens the feedback dialog instead of the usage error. The dialog offers the seven categories below and a free-text box; every field is optional and an empty submission is accepted, and the conversation log travels with the recorded event as with every feedback event. It records through `sessionFeedback.record`, which appends the same `feedback/record` event without command bookkeeping and without an acknowledgement row; the dialog shows a toast instead.
+
+| Category id | Meaning |
+|---|---|
+| `task-result` | The outcome of the task |
+| `instruction-following` | Understanding and following instructions |
+| `product-interaction` | Product features and interaction |
+| `service-stability` | Service stability |
+| `resource-cost` | Resource usage and cost |
+| `security-privacy-permission` | Security, privacy, and permissions |
+| `other` | Anything else |
+
+The ids are durable log vocabulary shared with per-message feedback; each surface owns its localized labels.
+
 ### Recording feedback from your own UI
 
-Feedback does not have to come from the slash command: any UI, hook, or host integration can record a remark directly, with the same guarantees and without a model turn. A custom app that wants the slash command mounts the command registry plus this plugin:
+Feedback does not have to come from the slash command or the dialog: any UI, hook, or host integration can record a remark directly through `recordFeedback` or the `sessionFeedback` Remote, with the same guarantees and without a model turn. A custom app that wants the slash command mounts the command registry plus this plugin:
 
 ```yaml
 - id: commands
@@ -61,17 +78,18 @@ The Web client ships the command. Headless mode, ACP automation, and JSON-RPC pr
 
 ### Design concept
 
-The remark is one append-only fact in the session log, owned by the event rather than by the command that produced it: feedback can arrive from any trigger, so the fact must not depend on the slash command. The command keeps its own bookkeeping payload-free, so the remark text exists in exactly one place in the log, and the event never surfaces to the model.
+The remark is one append-only fact in the session log, owned by the event rather than by the trigger that produced it: feedback can arrive from the command, the dialog, or any integration, so the fact must not depend on the slash command. The command keeps its own bookkeeping payload-free, so the remark text exists in exactly one place in the log, and the event never surfaces to the model.
 
 ### How a remark is recorded
 
-The producer trims the text, rejects empty input, and writes one event into the session log; the `/feedback` handler is a thin wrapper over that same producer and starts no model work. The write is eager but not flushed: the acknowledgement means the entry reached the log, not the disk. The first accepted remark for a harness home also mints the anonymous user id the acknowledgement reports. The exact producer contract and event payload live in [`src/index.ts`](src/index.ts).
+The producer trims the text, records blank text as absent, and writes one event into the session log even when the entry carries neither text nor category; the `/feedback` handler rejects empty input itself and is otherwise a thin wrapper over that same producer, and the `sessionFeedback.record` Remote resolves the live Session by id and calls it too, answering `session-not-found` when no live owner carries the id. Neither path starts model work. The write is eager but not flushed: the acknowledgement means the entry reached the log, not the disk. The first accepted command remark for a harness home also mints the anonymous user id the acknowledgement reports. The exact producer contract lives in [`src/index.ts`](src/index.ts); the event payload, the taxonomy, and the Remote vocabulary live in [`src/types.ts`](src/types.ts).
 
 ### Source map
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Plugin entry: `feedback/record` event declaration, `recordFeedback` producer, `/feedback` command registration |
+| [`src/index.ts`](src/index.ts) | Plugin entry: `recordFeedback` producer, the `sessionFeedback` Remote service, `/feedback` command registration |
+| [`src/types.ts`](src/types.ts) | `feedback/record` event declaration, the category taxonomy, and the Remote request and result types |
 | — | No runtime invariant companion is published; each `feedback/record` is an independent append-only fact with no cross-event or mutable-data relationship. |
 
 </details>
@@ -86,6 +104,7 @@ Read these pages when the package-level contract is not enough. They cover the c
 - [dsh-commands](../../interaction/commands/README.md) — the registry that discovers the global command and its `recordInput` semantics.
 - [Session persistence subsystem](../../../docs/subsystems/persistence.md) — how appended events become durable and what a flush barrier means.
 - [Anonymous user identity](../../identity/anonymous-user-id/README.md) — the id the acknowledgement reports.
+- [ui-message-feedback](../../client/ui-message-feedback/README.md) — the Web feedback dialog that records through the `sessionFeedback` Remote.
 - [Feedback package map](../README.md) — where log-only capture sits next to per-message feedback.
 
 -----
@@ -97,7 +116,7 @@ Read these pages when the package-level contract is not enough. They cover the c
 
 #### What the model sees
 
-Nothing. The slash input, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests.
+Nothing. The slash input, the dialog, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests.
 
 #### Token effect
 
@@ -112,13 +131,14 @@ Independent of the model request path. Recording appends to the session log only
 <a id="known-limitations-and-deferred-work"></a>
 
 
-These limits define where `/feedback` is a poor fit or behaves differently than a user might expect. They are current package constraints, not a task backlog.
+These limits define where session feedback is a poor fit or behaves differently than a user might expect. They are current package constraints, not a task backlog.
 
-- **No feedback retrieval or management surface** — there is no retrieval, aggregation, categorization, or model-facing tool for `feedback/record`.
-- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
+- **No feedback retrieval or management surface** — there is no retrieval, aggregation, or model-facing tool for `feedback/record`.
+- **Category and text only** — an entry carries at most one category and one free-text string, with no severity or referenced-event link.
+- **Live Sessions only through the Remote** — `sessionFeedback.record` answers `session-not-found` for a Session no live owner carries; the Web dialog reports that failure when its Session retires while it is open.
 - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
 - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. A consumer that needs a barrier awaits `ctx.sessions.flush(session)`.
-- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally.
+- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so a typed `/feedback <text>` on a still-blank session records the event but shows no acknowledgement row; the dialog's toast does not depend on the transcript.
 - **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC provide no command adapter, so `/feedback` is unavailable there.
 
 <a id="dev-note"></a>
@@ -129,7 +149,7 @@ These limits define where `/feedback` is a poor fit or behaves differently than
 
 This Dev Note is working context for maintainers; it is explicitly non-authoritative. Shipped behavior, limits, and rationale live in the sections above and the package code.
 
-- The acknowledgement sentences are pinned by [`tests/command-feedback.spec.ts`](tests/command-feedback.spec.ts); changing them changes user-visible copy.
-- Structured fields and a retrieval surface remain the open direction behind the first two limitations; nothing in the current contract reserves a format for them.
+- The acknowledgement sentences and the category order are pinned by [`tests/command-feedback.spec.ts`](tests/command-feedback.spec.ts); changing them changes user-visible copy.
+- A retrieval surface remains the open direction behind the first limitation; nothing in the current contract reserves a format for it.
 
 </details>

+ 33 - 13
packages/feedback/command-feedback/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "通过 `/feedback` 命令记录自由文本会话反馈,供用户与维护者选择、组合或排查反馈采集。"
+description: "会话反馈:`/feedback` 命令、Web 反馈弹窗背后的 `sessionFeedback` Host Remote,以及固定的分类表;供用户与维护者选择、组合或排查反馈采集。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-command-feedback` 让用户告诉 harness 他们对会话的看法:输入 `/feedback` 加一条评价,评价即被记录并得到确认。记录是即时的,绝不会启动模型工作,因此在对话的任何时刻都是安全的——模型既看不到这条评价,也不会被打断。确认文本会点名会话与匿名用户。命令随 Web 客户端交付,无需任何配置;无头模式、ACP(Agent Client Protocol)与 JSON-RPC 入口不提供斜杠命令,因此无法运行它
+`dsh-command-feedback` 让用户告诉 harness 他们对会话的看法。输入 `/feedback` 加一条评价,评价即被记录,并以会话 id 与匿名用户 id 确认;Web 反馈弹窗通过 `sessionFeedback` Host Remote 记录分类与可选描述。记录是即时的,绝不会启动模型工作:模型既看不到这条评价,也不会被打断。本包同时拥有所有反馈界面共用的固定分类表。它随标准 `dsh` 基础组合交付,无需任何配置;无头模式、ACP(Agent Client Protocol)与 JSON-RPC 入口不提供斜杠命令。
 
 ## 目录
 
@@ -38,9 +38,26 @@ kind: "package-reference"
 
 前后空白会被去除,但除此之外,评价会按输入原样保留:不进行截断、大小写折叠或命令解析——`/feedback /plan felt slow` 记录的就是这段字面文本。每次执行命令都会记录自己的条目;不会发生合并或替换。
 
+<a id="the-web-feedback-dialog"></a>
+### Web 反馈弹窗
+
+在 Web 客户端中,不带文本的 `/feedback`(从输入框菜单选中,或直接输入后发送)会打开反馈弹窗,而不是返回用法错误。弹窗提供下表的七个分类和一个自由文本框;每一项都可不填,空提交也会被接受,对话日志和其他反馈事件一样随记录的事件一起投递。弹窗通过 `sessionFeedback.record` 记录,追加的是同一个 `feedback/record` 事件,但没有命令簿记,也没有确认行;弹窗改用 toast 提示。
+
+| 分类 id | 含义 |
+|---|---|
+| `task-result` | 任务结果 |
+| `instruction-following` | 指令理解与遵循 |
+| `product-interaction` | 产品功能与交互 |
+| `service-stability` | 服务稳定性 |
+| `resource-cost` | 资源使用与费用 |
+| `security-privacy-permission` | 安全隐私与权限 |
+| `other` | 其他 |
+
+这些 id 是日志中的持久词汇,与逐消息反馈共用;各界面自行拥有本地化标签。
+
 ### 从自己的 UI 记录反馈
 
-反馈不一定来自斜杠命令:任何 UI、钩子或 host 集成都可以直接记录评价,享有同样的保证且无需模型轮次。想要斜杠命令的自定义应用,把命令注册表与本插件组合在一起即可:
+反馈不一定来自斜杠命令或弹窗:任何 UI、钩子或 host 集成都可以通过 `recordFeedback` 或 `sessionFeedback` Remote 直接记录评价,享有同样的保证且无需模型轮次。想要斜杠命令的自定义应用,把命令注册表与本插件组合在一起即可:
 
 ```yaml
 - id: commands
@@ -61,17 +78,18 @@ Web 客户端随附该命令。无头模式、ACP 自动化和 JSON-RPC 不提
 
 ### 设计理念
 
-评价是会话日志中一个仅追加的事实,由事件而非产生它的命令拥有:反馈可能来自任何触发方式,因此事实绝不能依赖斜杠命令。命令自身的簿记不携带载荷,所以评价文本在日志中只存在于一个地方,且该事件绝不会浮出到模型。
+评价是会话日志中一个仅追加的事实,由事件而非产生它的触发方式拥有:反馈可能来自命令、弹窗或任何集成,因此事实绝不能依赖斜杠命令。命令自身的簿记不携带载荷,所以评价文本在日志中只存在于一个地方,且该事件绝不会浮出到模型。
 
 ### 评价如何被记录
 
-生产方去除文本空白、拒绝空输入,并向会话日志写入一个事件;`/feedback` 处理器是该生产方的薄包装,不启动任何模型工作。写入是即时但未 flush 的:确认文本表示条目已到达日志,而不是已落盘。某个 harness home 首次接受的评价还会铸造确认文本所报告的匿名用户 id。精确的生产方约定与事件载荷见 [`src/index.ts`](src/index.ts)。
+生产方去除文本空白,把空白文本记为缺省,并向会话日志写入一个事件,即使条目既无文本也无分类;`/feedback` 处理器自行拒绝空输入,其余部分是该生产方的薄包装,`sessionFeedback.record` Remote 则按 id 找到 live Session 后同样调用它,没有 live 持有者时回答 `session-not-found`。两条路径都不启动模型工作。写入是即时但未 flush 的:确认文本表示条目已到达日志,而不是已落盘。某个 harness home 首次接受的命令评价还会铸造确认文本所报告的匿名用户 id。精确的生产方约定见 [`src/index.ts`](src/index.ts);事件载荷、分类表与 Remote 词汇见 [`src/types.ts`](src/types.ts)。
 
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | 插件入口:`feedback/record` 事件声明、`recordFeedback` 生产方、`/feedback` 命令注册 |
+| [`src/index.ts`](src/index.ts) | 插件入口:`recordFeedback` 生产方、`sessionFeedback` Remote 服务、`/feedback` 命令注册 |
+| [`src/types.ts`](src/types.ts) | `feedback/record` 事件声明、分类表,以及 Remote 请求与结果类型 |
 | — | 不发布运行时不变式伴生入口;每个事件都是独立的仅追加事实。 |
 
 </details>
@@ -86,6 +104,7 @@ Web 客户端随附该命令。无头模式、ACP 自动化和 JSON-RPC 不提
 - [dsh-commands](../../interaction/commands/README.zh.md)——发现全局命令并定义 `recordInput` 语义的注册表。
 - [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——追加事件如何持久化、flush 屏障的含义。
 - [匿名用户身份](../../identity/anonymous-user-id/README.zh.md)——确认文本报告的 id。
+- [ui-message-feedback](../../client/ui-message-feedback/README.zh.md)——通过 `sessionFeedback` Remote 记录的 Web 反馈弹窗。
 - [反馈包映射](../README.zh.md)——仅写入日志的采集与逐消息反馈并存的组。
 
 -----
@@ -97,7 +116,7 @@ Web 客户端随附该命令。无头模式、ACP 自动化和 JSON-RPC 不提
 
 #### 模型看到什么
 
-无。斜杠输入、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。
+无。斜杠输入、弹窗、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。
 
 #### Token 影响
 
@@ -112,13 +131,14 @@ Web 客户端随附该命令。无头模式、ACP 自动化和 JSON-RPC 不提
 <a id="known-limitations-and-deferred-work"></a>
 
 
-这些限制说明 `/feedback` 何时不合适,或何时行为与用户预期不同。它们是当前包约束,不是任务积压。
+这些限制说明会话反馈何时不合适,或何时行为与用户预期不同。它们是当前包约束,不是任务积压。
 
-- **没有反馈检索或管理 surface**——本包不为 `feedback/record` 提供检索、聚合、分类或面向模型的工具。
-- **没有结构化字段**——一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
+- **没有反馈检索或管理 surface**——本包不为 `feedback/record` 提供检索、聚合或面向模型的工具。
+- **只有分类与文本**——一条条目至多携带一个分类和一个自由文本字符串,没有严重程度或关联事件链接。
+- **Remote 只服务 live Session**——没有 live 持有者的 Session,`sessionFeedback.record` 回答 `session-not-found`;弹窗打开期间 Session 退役时,Web 弹窗会报告该失败。
 - **不支持修改或撤回**——会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。
 - **没有显式持久化屏障**——确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。
-- **新会话上没有可见的确认**——Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染
+- **新会话上没有可见的确认**——Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上输入 `/feedback <text>` 会记录事件但不会显示确认行;弹窗的 toast 不依赖转录
 - **随附的产品入口中只有 Web 使用此命令**——无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。
 
 <a id="dev-note"></a>
@@ -129,7 +149,7 @@ Web 客户端随附该命令。无头模式、ACP 自动化和 JSON-RPC 不提
 
 本开发备注是维护者的工作上下文,明确不具权威性。已交付的行为、限制与理由以上文与包代码为准。
 
-- 确认文本句子由 [`tests/command-feedback.spec.ts`](tests/command-feedback.spec.ts) 固定;修改它们会改变用户可见文案。
-- 结构化字段与检索 surface 仍是前两条限制背后的开放方向;当前约定没有为它预留任何格式。
+- 确认文本句子与分类顺序由 [`tests/command-feedback.spec.ts`](tests/command-feedback.spec.ts) 固定;修改它们会改变用户可见文案。
+- 检索 surface 仍是第一条限制背后的开放方向;当前约定没有为它预留任何格式。
 
 </details>

+ 26 - 4
packages/feedback/command-feedback/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-command-feedback",
-  "description": "Log-only session feedback producer and human-facing slash command",
+  "description": "Log-only session feedback: the record event, the sessionFeedback Host Remote, and the human-facing slash command",
   "version": "0.1.5-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -18,19 +18,40 @@
       "types": "./lib/types/index.d.ts",
       "default": "./lib/index.js"
     },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
-    "lib/types/**/*.d.ts"
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
   ],
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+  },
+  "dependencies": {
+    "zod": "^4.4.3"
   },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
@@ -41,6 +62,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   }
 }

+ 63 - 20
packages/feedback/command-feedback/src/index.ts

@@ -1,8 +1,10 @@
 /**
- * Session feedback event plus the human-facing `/feedback` producer. Recording
- * appends one authoritative log-only event and does not start model work. The
- * append is eager but unflushed, so acknowledgement reports that the entry is
- * logged, not that it reached disk.
+ * Session feedback: the `feedback/record` event, its command-independent
+ * producer, the `sessionFeedback` Host Remote a product surface records
+ * through, and the human-facing `/feedback` command. Recording appends one
+ * authoritative log-only event and does not start model work. The append is
+ * eager but unflushed, so acknowledgement reports that the entry is logged,
+ * not that it reached disk.
  * @module @deepseek-ai/dsh-command-feedback
  */
 
@@ -10,32 +12,40 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
+import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
+import type {
+  FeedbackRecord,
+  SessionFeedbackRecordRequest,
+  SessionFeedbackRecordResult,
+} from './types.ts'
+
+export { FEEDBACK_CATEGORIES } from './types.ts'
+export type * from './types.ts'
 
 export const name = 'command-feedback'
 export const inject = ['commands']
 
 const USAGE = 'Usage: /feedback <text>'
 
-declare module '@deepseek-ai/dsh-session/types' {
-  interface SessionEventMap {
-    /**
-     * One recorded human remark about this session. Log-only and independent
-     * of its trigger; it never enters model context or derived history.
-     */
-    'feedback/record': { text: string }
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    sessionFeedback: SessionFeedbackService
   }
 }
 
 /**
- * Record feedback independently of any UI trigger.
+ * Record feedback independently of any UI trigger. Surrounding whitespace is
+ * discarded and a blank text is recorded as absent; an entry with neither
+ * text nor category is still recorded.
  * @param session - session the feedback describes.
- * @param text - human-authored feedback; surrounding whitespace is discarded.
- * @throws {TypeError} when the normalized text is empty.
+ * @param entry - human-authored remark and its category.
  */
-export function recordFeedback(session: Session, text: string): void {
-  const normalized = text.trim()
-  if (normalized.length === 0) throw new TypeError('feedback text must not be empty')
-  session.append('feedback/record', { text: normalized })
+export function recordFeedback(session: Session, entry: FeedbackRecord): void {
+  const text = entry.text?.trim() ?? ''
+  session.append('feedback/record', {
+    ...(text.length === 0 ? {} : { text }),
+    ...(entry.category === undefined ? {} : { category: entry.category }),
+  })
 }
 
 /**
@@ -49,15 +59,48 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
   if (invocation.rawInput.trim().length === 0) {
     return { kind: 'error', text: `Feedback text is required. ${USAGE}` }
   }
-  recordFeedback(invocation.agent.session, invocation.rawInput)
+  recordFeedback(invocation.agent.session, { text: invocation.rawInput })
   return {
     kind: 'success',
     text: `Feedback recorded for session ${invocation.agent.session.id}\nAnonymous user: ${getOrCreateAnonymousUserId()}.`,
   }
 }
 
-/** Register the global `/feedback` command for every composed command adapter. */
+/** Host Remote through which a product surface records a Session-level remark. */
+export class SessionFeedbackService extends TypertRemoteService {
+  static inject = ['sessions']
+
+  /**
+   * @param ctx - Host context carrying the live Session store.
+   */
+  constructor(ctx: Context) {
+    super(ctx, 'sessionFeedback')
+  }
+
+  /**
+   * Record one remark on a live Session.
+   * @param request - target Session plus the optional text and category.
+   * @returns the recorded postcondition, or `session-not-found` when no live
+   * Session carries the id.
+   */
+  @Remote('record')
+  record(request: SessionFeedbackRecordRequest): Promise<SessionFeedbackRecordResult> {
+    const session = this.ctx.sessions.get(request.sessionId)
+    if (session === undefined) {
+      return Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: request.sessionId } })
+    }
+    recordFeedback(session, request)
+    return Promise.resolve({ ok: true, value: { recorded: true } })
+  }
+}
+
+/**
+ * Register the global `/feedback` command for every composed command adapter
+ * and mount the `sessionFeedback` Remote.
+ * @param ctx - Host context.
+ */
 export function apply(ctx: Context): void {
+  ctx.plugin(SessionFeedbackService)
   ctx.commands.register({
     name: 'feedback',
     description: 'record feedback about this session',

+ 83 - 0
packages/feedback/command-feedback/src/types.ts

@@ -0,0 +1,83 @@
+/**
+ * Public vocabulary of Session-level feedback: the fixed category taxonomy,
+ * the `feedback/record` event payload, and the `sessionFeedback.record`
+ * Remote request and result types. Host packages (message-feedback), the
+ * generated Remote client, and tests import the tuple; a browser plugin
+ * imports types only, because its bundle may not carry Host values.
+ * @module @deepseek-ai/dsh-command-feedback/types
+ */
+
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
+/** One of the fixed feedback categories; the ids are durable log vocabulary. */
+export type FeedbackCategory =
+  | 'task-result'
+  | 'instruction-following'
+  | 'product-interaction'
+  | 'service-stability'
+  | 'resource-cost'
+  | 'security-privacy-permission'
+  | 'other'
+
+/**
+ * Every feedback category in the order product surfaces present them; each
+ * surface owns its localized labels.
+ */
+export const FEEDBACK_CATEGORIES = [
+  'task-result',
+  'instruction-following',
+  'product-interaction',
+  'service-stability',
+  'resource-cost',
+  'security-privacy-permission',
+  'other',
+] as const satisfies readonly FeedbackCategory[]
+
+/**
+ * One recorded human remark about a Session. Both members are optional: a
+ * submission with neither still records that the human asked for the
+ * Session to be reviewed, which is what authorizes log delivery.
+ */
+export interface FeedbackRecord {
+  /** Free-text remark with surrounding whitespace removed; never empty when present. */
+  readonly text?: string
+  /** Category the human filed the remark under. */
+  readonly category?: FeedbackCategory
+}
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /**
+     * One recorded human remark about this session. Log-only and independent
+     * of its trigger; it never enters model context or derived history.
+     */
+    'feedback/record': FeedbackRecord
+  }
+}
+
+/** Record one Session-level remark through the Host Remote. */
+export interface SessionFeedbackRecordRequest {
+  /** Live Session the remark describes. */
+  readonly sessionId: SessionId
+  /** Free-text remark; blank text is recorded as absent. */
+  readonly text?: string
+  /** Category the human filed the remark under. */
+  readonly category?: FeedbackCategory
+}
+
+/** Stable postcondition of a recorded remark. */
+export interface SessionFeedbackRecordValue {
+  /** The remark is appended to the Session log; flushing follows the Session's own schedule. */
+  readonly recorded: true
+}
+
+/** No live Session carries the requested id. */
+export interface SessionFeedbackSessionNotFound {
+  readonly code: 'session-not-found'
+  readonly sessionId: SessionId
+}
+
+/** Result returned by the `sessionFeedback.record` operation. */
+export type SessionFeedbackRecordResult =
+  | { readonly ok: true; readonly value: SessionFeedbackRecordValue }
+  | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound }

+ 69 - 8
packages/feedback/command-feedback/tests/command-feedback.spec.ts

@@ -6,6 +6,8 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import CommandRuntime from '@deepseek-ai/dsh-commands'
 import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
 import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
+import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
+import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
 
 const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
@@ -72,10 +74,15 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s
 }
 
 /** Authoritative feedback payloads in log order. */
-function feedbackTexts(session: Session): string[] {
+function feedbackRecords(session: Session): FeedbackRecord[] {
   return session.snapshotEvents()
     .filter(event => event.type === 'feedback/record')
-    .map(event => event.data.text)
+    .map(event => event.data)
+}
+
+/** The text of each authoritative feedback payload in log order. */
+function feedbackTexts(session: Session): (string | undefined)[] {
+  return feedbackRecords(session).map(record => record.text)
 }
 
 describe('@deepseek-ai/dsh-command-feedback registration', () => {
@@ -99,6 +106,48 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => {
   })
 })
 
+describe('sessionFeedback Host Remote', () => {
+  it('publishes the exact Gateway namespace and Remote method name', async () => {
+    const test = await harness()
+    const binding = test.ctx.sessionFeedback.typertRemote
+    expect(binding.serviceKey).toBe('sessionFeedback')
+    expect(binding.namespace).toBe('sessionFeedback')
+    expect(remoteMethods(test.ctx.sessionFeedback)).toEqual([
+      { method: 'record', invocation: { kind: 'direct' } },
+    ])
+  })
+
+  it('records a remark on the live Session without command bookkeeping', async () => {
+    const test = await harness()
+    await expect(test.ctx.sessionFeedback.record({
+      sessionId: test.session.id, text: '  the diff view is unreadable ', category: 'product-interaction',
+    })).resolves.toEqual({ ok: true, value: { recorded: true } })
+    await expect(test.ctx.sessionFeedback.record({ sessionId: test.session.id }))
+      .resolves.toEqual({ ok: true, value: { recorded: true } })
+    expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record', 'feedback/record'])
+    expect(feedbackRecords(test.session)).toEqual([
+      { text: 'the diff view is unreadable', category: 'product-interaction' },
+      {},
+    ])
+    expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
+  })
+
+  it('reports session-not-found for a Session no live owner carries', async () => {
+    const test = await harness()
+    const missing = SessionId('no-such-session')
+    await expect(test.ctx.sessionFeedback.record({ sessionId: missing, text: 'lost' }))
+      .resolves.toEqual({ ok: false, error: { code: 'session-not-found', sessionId: missing } })
+    expect(test.session.snapshotEvents()).toEqual([])
+  })
+
+  it('is mounted and unmounted with the plugin', async () => {
+    const test = await harness()
+    expect(test.ctx.get('sessionFeedback')).toBeDefined()
+    await test.plugin.dispose()
+    expect(test.ctx.get('sessionFeedback')).toBeUndefined()
+  })
+})
+
 describe('/feedback human command', () => {
   it('acknowledges feedback and records its payload exactly once in the domain event', async () => {
     const test = await harness()
@@ -114,12 +163,24 @@ describe('/feedback human command', () => {
 
   it('exports a command-independent feedback producer', async () => {
     const test = await harness()
-    commandFeedback.recordFeedback(test.session, '  recorded outside a command  ')
-    expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record'])
-    expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
-    expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') })
-      .toThrow('feedback text must not be empty')
-    expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
+    commandFeedback.recordFeedback(test.session, { text: '  recorded outside a command  ' })
+    commandFeedback.recordFeedback(test.session, { text: ' \n\t ', category: 'service-stability' })
+    commandFeedback.recordFeedback(test.session, {})
+    expect(test.session.snapshotEvents().map(event => event.type))
+      .toEqual(['feedback/record', 'feedback/record', 'feedback/record'])
+    // Blank text is recorded as absent; an entry with neither member still records.
+    expect(feedbackRecords(test.session)).toEqual([
+      { text: 'recorded outside a command' },
+      { category: 'service-stability' },
+      {},
+    ])
+  })
+
+  it('publishes the fixed category taxonomy in presentation order', () => {
+    expect(commandFeedback.FEEDBACK_CATEGORIES).toEqual([
+      'task-result', 'instruction-following', 'product-interaction', 'service-stability',
+      'resource-cost', 'security-privacy-permission', 'other',
+    ])
   })
 
   it('keeps command bookkeeping around the authoritative feedback event', async () => {

+ 3 - 0
packages/feedback/command-feedback/tsconfig.json

@@ -22,6 +22,9 @@
     },
     {
       "path": "../../identity/anonymous-user-id"
+    },
+    {
+      "path": "../../typert/protocol"
     }
   ]
 }

+ 2 - 2
packages/feedback/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/feedback/message-feedback/README.md
-README.md: e1defa8d8fd6149f124267ffa0c382f4a82cf2ab
-README.zh.md: d16f8f4416288e5373e3221e8f43a8b2d84e5f80
+README.md: 4e75d167fe39c96002b32def24f447efdbc2be70
+README.zh.md: 0c21e26f9a4ffa40ce29412c652029d3af4e9fe0

+ 5 - 5
packages/feedback/message-feedback/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Canonical Session-log ratings and notes for finalized assistant messages."
+description: "Canonical Session-log ratings, categories, and notes for finalized assistant messages."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This service records positive or negative ratings and optional verbatim notes for finalized assistant messages. The canonical Session log owns every creation, edit, and deletion; `list`, `put`, and `delete` expose current feedback without constructing or waking an Agent. Feedback is log-only and does not enter model history.
+This service records positive or negative ratings, an optional category from the fixed feedback taxonomy, and optional verbatim notes for finalized assistant messages. The canonical Session log owns every creation, edit, and deletion; `list`, `put`, and `delete` expose current feedback without constructing or waking an Agent. Feedback is log-only and does not enter model history.
 
 ## Table of Contents
 
@@ -30,17 +30,17 @@ Mount `dsh-message-feedback` alongside `sessions` and `sessionPersistence`. It n
 |---|---|---|
 | `maxNoteBytes` | required | Positive safe-integer maximum UTF-8 bytes in one optional note. |
 
-A supplied note must contain a non-whitespace character and fit the configured byte limit. Blank notes return `note-blank`; oversized notes return `note-too-large`. Accepted text is preserved exactly, including surrounding whitespace. Omitting a note clears it. Note validation precedes Session lookup.
+A supplied note must contain a non-whitespace character and fit the configured byte limit. Blank notes return `note-blank`; oversized notes return `note-too-large`. Accepted text is preserved exactly, including surrounding whitespace. Omitting a note clears it. Note validation precedes Session lookup. A supplied category must be one of the [fixed feedback categories](../command-feedback/README.md#the-web-feedback-dialog); the Remote schema rejects any other value, and omitting the category clears it.
 
 ### Reading and changing feedback
 
 | Operation | Request | Success | Business failures |
 |---|---|---|---|
 | `list` | Session id | Current items in creation order | Session not found |
-| `put` | Session, message, rating, optional note, expected version | Current item | Session or target not found, version conflict, invalid note |
+| `put` | Session, message, rating, optional note, optional category, expected version | Current item | Session or target not found, version conflict, invalid note |
 | `delete` | Session, message, expected version | Item absent | Session not found, version conflict |
 
-Create with `ifVersion: null`; edit or delete with the returned version. Stale mutations return `version-conflict` and the current item. Each material put mints a fresh token and preserves the original creation time. A matching no-op put returns the same item without appending an event. Deleting an absent item succeeds regardless of the supplied version, without appending an event. Recreating a deleted item starts a new creation time and ordering position.
+Create with `ifVersion: null`; edit or delete with the returned version. Stale mutations return `version-conflict` and the current item. Each material put mints a fresh token and preserves the original creation time. A put that repeats the stored rating, note, and category is a no-op: it returns the same item without appending an event. Deleting an absent item succeeds regardless of the supplied version, without appending an event. Recreating a deleted item starts a new creation time and ordering position.
 
 Targets must be non-empty assistant messages produced by append-origin events. User messages, empty assistant placeholders, and replacement-origin messages return `target-not-found`. Feedback survives restart; a fork starts without owned feedback even when its inherited prefix contains parent feedback.
 

+ 5 - 5
packages/feedback/message-feedback/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "在权威 Session 日志中保存已完成 assistant 消息的评分与备注。"
+description: "在权威 Session 日志中保存已完成 assistant 消息的评分、分类与备注。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本服务为已完成的 assistant 消息记录好评、差评及可选的原样备注。每次创建、编辑和删除都由权威 Session 日志保存;`list`、`put` 和 `delete` 提供当前反馈,不会构造或唤醒 Agent。反馈仅写入日志,不进入模型历史。
+本服务为已完成的 assistant 消息记录好评、差评、固定反馈分类表中的可选分类,以及可选的原样备注。每次创建、编辑和删除都由权威 Session 日志保存;`list`、`put` 和 `delete` 提供当前反馈,不会构造或唤醒 Agent。反馈仅写入日志,不进入模型历史。
 
 ## 目录
 
@@ -30,17 +30,17 @@ kind: "package-reference"
 |---|---|---|
 | `maxNoteBytes` | 必填 | 单条可选备注的 UTF-8 字节上限,必须为正安全整数。 |
 
-提交的备注必须包含非空白字符,且不超过配置的字节上限。空白备注返回 `note-blank`;过长备注返回 `note-too-large`。通过校验的文本会完整保留,包括首尾空白。省略备注会清除它。备注校验先于 Session 查找。
+提交的备注必须包含非空白字符,且不超过配置的字节上限。空白备注返回 `note-blank`;过长备注返回 `note-too-large`。通过校验的文本会完整保留,包括首尾空白。省略备注会清除它。备注校验先于 Session 查找。提交的分类必须是[固定反馈分类](../command-feedback/README.zh.md#the-web-feedback-dialog)之一;Remote schema 拒绝其他值,省略分类会清除它。
 
 ### 读取与修改反馈
 
 | 操作 | 请求 | 成功 | 业务失败 |
 |---|---|---|---|
 | `list` | Session id | 按创建顺序返回当前条目 | Session 不存在 |
-| `put` | Session、消息、评分、可选备注、预期版本 | 当前条目 | Session 或目标不存在、版本冲突、备注无效 |
+| `put` | Session、消息、评分、可选备注、可选分类、预期版本 | 当前条目 | Session 或目标不存在、版本冲突、备注无效 |
 | `delete` | Session、消息、预期版本 | 条目不存在 | Session 不存在、版本冲突 |
 
-创建时传入 `ifVersion: null`;编辑或删除时使用返回的版本。陈旧修改返回 `version-conflict` 及当前条目。每次实质 put 都生成新 token,并保留原始创建时间。匹配的无变化 put 返回相同条目,不追加事件。删除不存在的条目始终成功,不受所传版本影响,也不追加事件。重新创建已删除条目会产生新的创建时间和排序位置。
+创建时传入 `ifVersion: null`;编辑或删除时使用返回的版本。陈旧修改返回 `version-conflict` 及当前条目。每次实质 put 都生成新 token,并保留原始创建时间。重复已存评分、备注与分类的 put 是无变化操作:返回相同条目,不追加事件。删除不存在的条目始终成功,不受所传版本影响,也不追加事件。重新创建已删除条目会产生新的创建时间和排序位置。
 
 目标必须是由 append 来源事件产生的非空 assistant 消息。用户消息、空 assistant 占位及 replacement 来源消息返回 `target-not-found`。反馈跨重启保留;fork 即使继承了包含父会话反馈的前缀,也从没有自有反馈开始。
 

+ 4 - 2
packages/feedback/message-feedback/package.json

@@ -49,7 +49,8 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-command-feedback": "workspace:^"
   },
   "dependencies": {
     "@deepseek-ai/schemastery": "workspace:^",
@@ -64,6 +65,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-command-feedback": "workspace:^"
   }
 }

+ 5 - 1
packages/feedback/message-feedback/src/index.ts

@@ -9,6 +9,7 @@ import { isDeepStrictEqual } from 'node:util'
 import { Context, Service } from '@deepseek-ai/cordis'
 import s from '@deepseek-ai/schemastery'
 import { z } from 'zod'
+import { FEEDBACK_CATEGORIES } from '@deepseek-ai/dsh-command-feedback/types'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
@@ -63,6 +64,7 @@ const itemSchema = z.object({
   messageId: z.string().min(1),
   rating: z.enum(['positive', 'negative']),
   note: z.string().refine(note => note.trim().length > 0).optional(),
+  category: z.enum(FEEDBACK_CATEGORIES).optional(),
   version: z.uuid(),
   createdAt: timestamp,
   updatedAt: timestamp,
@@ -176,7 +178,8 @@ export class MessageFeedbackService extends TypertRemoteService {
       if (request.ifVersion !== (existing?.version ?? null)) {
         return rejected(this.versionConflict(existing ?? null))
       }
-      if (existing !== undefined && existing.rating === request.rating && existing.note === note.value) {
+      if (existing !== undefined && existing.rating === request.rating && existing.note === note.value
+        && existing.category === request.category) {
         await append()
         return success(snapshotItem(existing))
       }
@@ -185,6 +188,7 @@ export class MessageFeedbackService extends TypertRemoteService {
         messageId: request.messageId,
         rating: request.rating,
         ...(note.value === undefined ? {} : { note: note.value }),
+        ...(request.category === undefined ? {} : { category: request.category }),
         version: randomUUID() as MessageFeedbackVersion,
         createdAt: existing?.createdAt ?? now,
         updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt),

+ 5 - 0
packages/feedback/message-feedback/src/types.ts

@@ -8,6 +8,7 @@
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { FeedbackCategory } from '@deepseek-ai/dsh-command-feedback/types'
 
 /** Opaque compare-and-set token for one exact feedback item revision. */
 export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'>
@@ -23,6 +24,8 @@ export interface MessageFeedbackItem {
   readonly rating: MessageFeedbackRating
   /** Optional explanation, preserved verbatim after validation. */
   readonly note?: string
+  /** Category the human filed a negative judgment under. */
+  readonly category?: FeedbackCategory
   /** Equality-only token replaced by every material create or update. */
   readonly version: MessageFeedbackVersion
   /** Host-assigned creation time in Unix epoch milliseconds. */
@@ -78,6 +81,8 @@ export interface MessageFeedbackPutRequest {
   readonly rating: MessageFeedbackRating
   /** Optional non-blank explanation. */
   readonly note?: string
+  /** Optional category; absent keeps the item uncategorized. */
+  readonly category?: FeedbackCategory
   /** Observed item version, or `null` to require that no item exists. */
   readonly ifVersion: MessageFeedbackVersion | null
 }

+ 56 - 0
packages/feedback/message-feedback/tests/message-feedback.spec.ts

@@ -181,6 +181,62 @@ describe('MessageFeedbackService public contract', () => {
     expect(Object.isFrozen(listed.value.items[0])).toBe(true)
   })
 
+  it('stores a category with the judgment, treats a category change as material, and validates stored categories', async () => {
+    const { ctx, persistence } = await harness()
+    const fixture = messageFixture('categories')
+    persistence.persist(fixture.session)
+    const messageId = fixture.assistantMessageIds[0]
+
+    const created = expectItem(await ctx.messageFeedback.put({
+      sessionId: fixture.session.id,
+      messageId,
+      rating: 'negative',
+      note: 'wrong file',
+      category: 'task-result',
+      ifVersion: null,
+    }))
+    expect(created).toMatchObject({ rating: 'negative', note: 'wrong file', category: 'task-result' })
+
+    // The same value is a no-op; a different category is a material edit;
+    // omitting the category drops it.
+    const same = expectItem(await ctx.messageFeedback.put({
+      sessionId: fixture.session.id, messageId, rating: 'negative', note: 'wrong file', category: 'task-result',
+      ifVersion: created.version,
+    }))
+    expect(same).toEqual(created)
+    const recategorized = expectItem(await ctx.messageFeedback.put({
+      sessionId: fixture.session.id, messageId, rating: 'negative', note: 'wrong file', category: 'other',
+      ifVersion: created.version,
+    }))
+    expect(recategorized.version).not.toBe(created.version)
+    expect(recategorized.category).toBe('other')
+    const dropped = expectItem(await ctx.messageFeedback.put({
+      sessionId: fixture.session.id, messageId, rating: 'negative', ifVersion: recategorized.version,
+    }))
+    expect(dropped).not.toHaveProperty('category')
+    expect(dropped).not.toHaveProperty('note')
+    const events = (persistence.durable.get(fixture.session.id)?.events ?? [])
+      .filter(event => event.type === 'feedback/message-put')
+      .map(event => event.data.item.category)
+    expect(events).toEqual(['task-result', 'other', undefined])
+
+    // A stored payload outside the fixed taxonomy is refused on read.
+    const corrupt = messageFixture('corrupt-category')
+    corrupt.session.append('feedback/message-put', {
+      sessionId: corrupt.session.id,
+      item: {
+        messageId: corrupt.assistantMessageIds[0],
+        rating: 'negative',
+        category: 'not-a-category' as never,
+        version: staleVersion(),
+        createdAt: 1,
+        updatedAt: 1,
+      },
+    })
+    persistence.persist(corrupt.session)
+    await expect(ctx.messageFeedback.list({ sessionId: corrupt.session.id })).rejects.toThrow()
+  })
+
   it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => {
     const { ctx, persistence } = await harness(4)
     const fixture = messageFixture('note-limits')

+ 3 - 0
packages/feedback/message-feedback/tsconfig.json

@@ -31,6 +31,9 @@
     },
     {
       "path": "../../typert/protocol"
+    },
+    {
+      "path": "../command-feedback"
     }
   ]
 }

+ 1 - 1
packages/session/session-log-deepseek/tests/feedback-composition.spec.ts

@@ -81,7 +81,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged
     session.append('user/message', user, { surfaceOp: 'append' })
     session.append('assistant/message', { message: assistant, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' })
     const messages = session.deriveMessages()
-    recordFeedback(session, '  The session needs a clearer explanation.  ')
+    recordFeedback(session, { text: '  The session needs a clearer explanation.  ' })
     const created = await ctx.messageFeedback.put({ sessionId: session.id, messageId: assistant.id, rating: 'negative', note: 'Explain the result.', ifVersion: null })
     if (!created.ok) throw new Error(created.error.code)
     const initialPrefix = session.snapshotEvents()

+ 1 - 1
packages/session/session-telemetry-otel/tests/egress.spec.ts

@@ -109,7 +109,7 @@ describe('session-telemetry-otel egress', () => {
           session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'initial' })
         }
         session.append('turn/start', { turn: 1 })
-        recordFeedback(session, 'explicit egress feedback')
+        recordFeedback(session, { text: 'explicit egress feedback' })
         session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
         await fiber.dispose()
 

+ 1 - 1
packages/session/session-telemetry-otel/tests/fixtures/driver.ts

@@ -48,7 +48,7 @@ try {
     const [agent] = ctx.get('agents')?.roots() ?? []
     if (agent === undefined) throw new Error('session-telemetry-otel driver requires one root agent')
     if (process.env.DSH_TELEMETRY_E2E_FEEDBACK !== 'none') {
-      recordFeedback(agent.session, 'fixture feedback')
+      recordFeedback(agent.session, { text: 'fixture feedback' })
     }
     await runFixtureTurn(ctx, { task: 'post-feedback private suffix' })
     ctx.emit('agent/error', { agent, turn: 2, step: 1, error: new Error('private operational error') })

+ 16 - 16
packages/session/session-telemetry-otel/tests/otel.spec.ts

@@ -156,7 +156,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
       attributes: { 'session.id': 'wire', 'event.type': 'manual', 'event.seq': 99 },
       body: { direct: true },
     })
-    recordFeedback(session, 'explicit report')
+    recordFeedback(session, { text: 'explicit report' })
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)
@@ -264,10 +264,10 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const session = ctx.sessions.create(SessionId('drain'), { meta: {} })
     session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
-    recordFeedback(session, 'first report')
+    recordFeedback(session, { text: 'first report' })
     await arrived.promise
 
-    recordFeedback(session, 'second report')
+    recordFeedback(session, { text: 'second report' })
     session.append('turn/start', { turn: 2 })
     const shutdown = vi.spyOn(ctx.sessionTelemetry, 'shutdown')
     const disposal = fiber.dispose()
@@ -300,10 +300,10 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} })
     session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
-    recordFeedback(session, 'first report')
+    recordFeedback(session, { text: 'first report' })
     await arrived.promise
 
-    recordFeedback(session, 'second report')
+    recordFeedback(session, { text: 'second report' })
     const started = performance.now()
     await fiber.dispose()
     expect(performance.now() - started).toBeLessThan(1_000)
@@ -330,7 +330,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
     session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
-    recordFeedback(session, 'compressed report')
+    recordFeedback(session, { text: 'compressed report' })
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)
@@ -350,7 +350,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
     // No flush(): the coordinator's optional-call forwarding no-ops, and the
     // batch processor owns export cadence end to end (see the backend note).
     expect('flush' in ctx.sessionTelemetry && ctx.sessionTelemetry.flush !== undefined).toBe(false)
-    recordFeedback(session, 'warning feedback')
+    recordFeedback(session, { text: 'warning feedback' })
     await fiber.dispose()
     const start = allRecords(captures).find(r =>
       r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
@@ -378,9 +378,9 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} })
     session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
-    recordFeedback(session, 'first report')
+    recordFeedback(session, { text: 'first report' })
     session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    recordFeedback(session, 'second report')
+    recordFeedback(session, { text: 'second report' })
     session.append('turn/start', { turn: 2 })
     await fiber.dispose()
 
@@ -435,7 +435,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const session = ctx.sessions.create(SessionId('disabled'), { meta: {} })
     session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' } }, reason: 'initial' })
     session.append('turn/start', { turn: 1 })
-    recordFeedback(session, 'local report')
+    recordFeedback(session, { text: 'local report' })
 
     expect(warn).toHaveBeenCalledWith(
       'OpenTelemetry session upload is DISABLED; this feedback is not uploaded through OpenTelemetry',
@@ -449,7 +449,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
     })
     await ctx.sessionTelemetry.shutdown()
     await fiber.dispose()
-    recordFeedback(session, 'after disposal')
+    recordFeedback(session, { text: 'after disposal' })
     expect(warn).toHaveBeenCalledTimes(1)
     expect(captures).toEqual([])
   })
@@ -492,7 +492,7 @@ describe('OpenTelemetrySessionBackend wire', () => {
       session.append('request/header', { header: { config: { provider: 'deepseek-official', model: 'mock' } }, reason: 'initial' })
       session.append('turn/start', { turn: 1 })
       expect(captures).toEqual([])
-      recordFeedback(session, 'explicit report')
+      recordFeedback(session, { text: 'explicit report' })
       const submitted = session.snapshotEvents().map(event => event.type)
       await expect.poll(() => eventTypes(captures)).toEqual(submitted)
       session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -517,7 +517,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
       if (provider !== undefined) session.append('request/header', { header: { config: { provider, model: 'm' } }, reason: 'initial' })
       session.append('turn/start', { turn: 1 })
       expect(captures).toEqual([])
-      recordFeedback(session, 'explicit report')
+      recordFeedback(session, { text: 'explicit report' })
       const expected = session.snapshotEvents().map(event => event.type)
       await expect.poll(() => eventTypes(captures)).toEqual(expected)
       session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -534,7 +534,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const donor = Session.create(SessionId('stored-feedback'))
-    recordFeedback(donor, 'old feedback is not a submission')
+    recordFeedback(donor, { text: 'old feedback is not a submission' })
     const restored = ctx.sessions.create(donor.id, { seed: donor.snapshotEvents(), meta: donor.header })
     try {
       const first = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
@@ -615,7 +615,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
       meta: session.header, events: session.snapshotEvents(), inheritedEventCount: session.inheritedEventCount,
     })
     await notify()
-    recordFeedback(session, 'older feedback')
+    recordFeedback(session, { text: 'older feedback' })
     session.append('turn/start', { turn: 1 })
     await notify()
     const child = ctx.sessions.create(SessionId('foreign'))
@@ -645,7 +645,7 @@ describe('OpenTelemetrySessionBackend route and feedback', () => {
       const child = Session.create(SessionId('cold-child'), parent.snapshotEvents(), {
         ...parent.header, id: SessionId('cold-child'), parentSession: parent.id, isSeeded: true,
       }, parent.seq)
-      recordFeedback(child, 'child-owned stored feedback')
+      recordFeedback(child, { text: 'child-owned stored feedback' })
       const handle = await ctx.sessionPersistence.create(child.header, { inheritedEventCount: child.inheritedEventCount })
       try {
         await handle.append(child.snapshotEvents())

+ 22 - 0
pnpm-lock.yaml

@@ -920,6 +920,9 @@ importers:
       '@deepseek-ai/dsh-client-file-upload':
         specifier: workspace:^
         version: link:../../client/file-upload
+      '@deepseek-ai/dsh-command-feedback':
+        specifier: workspace:^
+        version: link:../../feedback/command-feedback
       '@deepseek-ai/dsh-commands':
         specifier: workspace:^
         version: link:../../interaction/commands
@@ -3026,12 +3029,18 @@ importers:
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
+      '@deepseek-ai/dsh-client-store':
+        specifier: workspace:^
+        version: link:../store
       '@deepseek-ai/dsh-client-test-runtime':
         specifier: workspace:^
         version: link:../../test-support/client-runtime
       '@deepseek-ai/dsh-client-ui-chat':
         specifier: workspace:^
         version: link:../ui-chat
+      '@deepseek-ai/dsh-client-ui-commands':
+        specifier: workspace:^
+        version: link:../ui-commands
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
@@ -3047,6 +3056,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-command-feedback':
+        specifier: workspace:^
+        version: link:../../feedback/command-feedback
       '@deepseek-ai/dsh-message-feedback':
         specifier: workspace:^
         version: link:../../feedback/message-feedback
@@ -5839,6 +5851,10 @@ importers:
         version: 18.3.1
 
   packages/feedback/command-feedback:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -5867,6 +5883,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
 
   packages/feedback/message-feedback:
     dependencies:
@@ -5889,6 +5908,9 @@ importers:
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand
+      '@deepseek-ai/dsh-command-feedback':
+        specifier: workspace:^
+        version: link:../command-feedback
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm

+ 3 - 0
scripts/gen-cordis-catalog.ts

@@ -85,6 +85,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   llm: 'llm-streaming.md',
   lsp: 'lsp.md',
   messageFeedback: 'feedback.md',
+  sessionFeedback: 'feedback.md',
   permissionPresets: 'permission-presets.md',
   planMode: 'plan.md',
   terminals: 'terminal.md',
@@ -288,6 +289,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   MessageFeedbackFailure: 'feedback.md',
   MessageFeedbackItem: 'feedback.md',
   MessageFeedbackListRequest: 'feedback.md',
+  SessionFeedbackRecordRequest: 'feedback.md',
+  SessionFeedbackRecordResult: 'feedback.md',
   MessageFeedbackListResult: 'feedback.md',
   MessageFeedbackListValue: 'feedback.md',
   MessageFeedbackNoteBlank: 'feedback.md',

+ 7 - 0
scripts/gen-doc-graphs.ts

@@ -313,6 +313,13 @@ const SERVICE_ROLES: ServiceRole[] = [
     mode: 'core',
     note: 'Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy.',
   },
+  {
+    key: 'sessionFeedback',
+    pkg: 'command-feedback',
+    title: 'Session-level feedback recorder',
+    mode: 'core',
+    note: 'Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer.',
+  },
   {
     key: 'workspaceRegistry',
     pkg: 'workspace',

+ 1 - 1
scripts/smoke-python-runtime.py

@@ -1321,7 +1321,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
         assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
         feedback_types = [event.get("type") for event in result.events
                           if str(event.get("type")).startswith("feedback/")]
-        if feedback_types != ["feedback/record", "feedback/message-put", "feedback/message-put", "feedback/message-delete"]:
+        if feedback_types != ["feedback/record", "feedback/record", "feedback/message-put", "feedback/message-put", "feedback/message-delete"]:
             raise AssertionError(f"advanced snapshot did not exercise all feedback mutations: {feedback_types}")
         methods = [notification.method for notification in result.notifications]
         if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:

+ 2 - 1
scripts/snapshots/python-sdk-single-exe/advanced/session.v2.jsonl

@@ -61,7 +61,8 @@
 {"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}}
 {"type":"feedback/record","data":{"text":"The session needs a clearer explanation."}}
 {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{parent}}\nAnonymous user: {{anonymous-user}}."}}
-{"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"negative","note":"Explain the result.","version":"{{feedback-version:1}}","createdAt":0,"updatedAt":0}}}
+{"type":"feedback/record","data":{"category":"other"}}
+{"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"negative","note":"Explain the result.","category":"task-result","version":"{{feedback-version:1}}","createdAt":0,"updatedAt":0}}}
 {"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"positive","note":"The explanation is clear now.","version":"{{feedback-version:2}}","createdAt":0,"updatedAt":0}}}
 {"type":"feedback/message-delete","data":{"sessionId":"{{parent}}","messageId":"{{message:1}}"}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 30 - 0
scripts/type-equiv.manifest.json

@@ -2071,6 +2071,36 @@
       "symbol": "MessageFeedbackDeleteResult",
       "source": "packages/feedback/message-feedback/src/types.ts"
     },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "FeedbackCategory",
+      "source": "packages/feedback/command-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "FeedbackRecord",
+      "source": "packages/feedback/command-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "SessionFeedbackRecordRequest",
+      "source": "packages/feedback/command-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "SessionFeedbackRecordValue",
+      "source": "packages/feedback/command-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "SessionFeedbackSessionNotFound",
+      "source": "packages/feedback/command-feedback/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/feedback.md",
+      "symbol": "SessionFeedbackRecordResult",
+      "source": "packages/feedback/command-feedback/src/types.ts"
+    },
     {
       "doc": "packages/client/ui-conversation/README.md",
       "symbol": "ComposerChainProps",

+ 1 - 1
snapshots/sdk/sdk.snapshot.ts

@@ -630,7 +630,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{
         if (scenario.manifest.environment?.DSH_SNAPSHOT_FEEDBACK === '1') {
           const feedback = result.events.filter(event => event.type.startsWith('feedback/'))
           expect(feedback.map(event => event.type)).toEqual([
-            'feedback/record', 'feedback/message-put', 'feedback/message-put', 'feedback/message-delete',
+            'feedback/record', 'feedback/record', 'feedback/message-put', 'feedback/message-put', 'feedback/message-delete',
           ])
         }
         await waitForRootEvent(

+ 4 - 2
snapshots/sdk/text-turn/feedback-producer.mjs

@@ -1,6 +1,6 @@
 /** Exercise real feedback services before the SDK closes the recorded root turn. */
 export const name = 'snapshot-feedback-producer'
-export const inject = ['commands', 'messageFeedback']
+export const inject = ['commands', 'messageFeedback', 'sessionFeedback']
 
 /**
  * @param {import('@deepseek-ai/cordis').Context} ctx - Composed runtime services.
@@ -13,8 +13,10 @@ export function apply(ctx) {
     if (message === undefined) throw new Error('feedback snapshot requires an assistant message')
     const command = await ctx.commands.execute(agent, '/feedback The session needs a clearer explanation.', [], signal)
     if (command?.result.kind !== 'success') throw new Error('feedback command did not succeed')
+    const recorded = await ctx.sessionFeedback.record({ sessionId: agent.id, category: 'other' })
+    if (!recorded.ok) throw new Error(recorded.error.code)
     const target = { sessionId: agent.id, messageId: message.id }
-    const created = await ctx.messageFeedback.put({ ...target, rating: 'negative', note: 'Explain the result.', ifVersion: null })
+    const created = await ctx.messageFeedback.put({ ...target, rating: 'negative', note: 'Explain the result.', category: 'task-result', ifVersion: null })
     if (!created.ok) throw new Error(created.error.code)
     const edited = await ctx.messageFeedback.put({ ...target, rating: 'positive', note: 'The explanation is clear now.', ifVersion: created.value.version })
     if (!edited.ok) throw new Error(edited.error.code)

+ 3 - 2
snapshots/sdk/text-turn/session.v2.jsonl

@@ -12,12 +12,13 @@
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","sessionFormatVersion":2,"throughSeq":11}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19},"stream":[{"type":"chunk","time":1788603540672,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788603540672,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]},{"type":"chunk","time":1788603540672,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788603540672,"index":1,"dt":[0,0,0],"texts":["SD","K"," snapshot"," OK"]},{"type":"chunk","time":1788603540672,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}},{"type":"chunk","time":1788603540672,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}},{"type":"chunk","time":1788603540672,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}},{"type":"chunk","time":1788603540672,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19},"stream":[{"type":"chunk","time":1788871683462,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788871683462,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]},{"type":"chunk","time":1788871683462,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788871683462,"index":1,"dt":[0,0,0],"texts":["SD","K"," snapshot"," OK"]},{"type":"chunk","time":1788871683462,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}},{"type":"chunk","time":1788871683462,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}},{"type":"chunk","time":1788871683462,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}},{"type":"chunk","time":1788871683462,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}}
 {"type":"feedback/record","data":{"text":"The session needs a clearer explanation."}}
 {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{session:1}}\nAnonymous user: {{id:1}}."}}
-{"type":"feedback/message-put","data":{"sessionId":"{{session:1}}","item":{"messageId":"{{message:3}}","rating":"negative","note":"Explain the result.","version":"{{id:2}}","createdAt":0,"updatedAt":0}}}
+{"type":"feedback/record","data":{"category":"other"}}
+{"type":"feedback/message-put","data":{"sessionId":"{{session:1}}","item":{"messageId":"{{message:3}}","rating":"negative","note":"Explain the result.","category":"task-result","version":"{{id:2}}","createdAt":0,"updatedAt":0}}}
 {"type":"feedback/message-put","data":{"sessionId":"{{session:1}}","item":{"messageId":"{{message:3}}","rating":"positive","note":"The explanation is clear now.","version":"{{id:3}}","createdAt":0,"updatedAt":0}}}
 {"type":"feedback/message-delete","data":{"sessionId":"{{session:1}}","messageId":"{{message:3}}"}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 3 - 2
snapshots/web/feedback-release/feedback-release.expected.json

@@ -15,8 +15,9 @@
     },
     {
       "type": "feedback/message-put",
-      "rating": "positive",
-      "note": "Read both files before answering."
+      "rating": "negative",
+      "note": "Read both files before answering.",
+      "category": "task-result"
     },
     {
       "type": "feedback/message-delete"

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

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

+ 0 - 9
snapshots/web/message-feedback-layout/snapshot.yml

@@ -1,9 +0,0 @@
-version: 1
-scenario: message-feedback-layout
-profile: web
-composition: web-default
-recording: authored
-header:
-  class: web-default
-session:
-  source: ../seeded-history/session.v3.jsonl

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.