From 526febc44d06be1112452421485c2a494dcf380e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 16:58:40 +0800 Subject: [PATCH 1/8] feat(feedback): add the Web surface for message feedback Consume the durable message-feedback sidecar from #2217 in the browser: per-message Like/Dislike with an optional note, contributed through a declared assistant-actions slot. - carry MessageId on finalized AssistantMessageNode so a target is nameable - declare conversation.chat.assistant-actions and render it in the IconActions row between copy and branch - hold one FeedbackController per Session with per-item ifVersion CAS, reconciling a version-conflict from the reply's authoritative item - mount messageFeedbackRemote alongside goalsRemote --- ...-11-message-feedback-web-surface.i18n.yaml | 6 + ...2026-08-11-message-feedback-web-surface.md | 55 ++++ ...6-08-11-message-feedback-web-surface.zh.md | 55 ++++ apps/web/tests/message-feedback.e2e.ts | 121 ++++++++ .../snapshots/code-mode-round/ui.expected.md | 4 + .../cordis-tool-round/ui.expected.md | 4 + .../feedback-command/ack.expected.md | 4 + .../snapshots/fresh-round-trip/ui.expected.md | 4 + .../goal-multi-turn-actions/ui.expected.md | 8 + .../lifecycle-chrome/reloaded.expected.md | 4 + .../live-interactions/retry.expected.md | 4 + .../markdown-cjk-strong/ui.expected.md | 4 + .../snapshots/markdown-images/ui.expected.md | 4 + .../markdown-inline-code-links/ui.expected.md | 4 + .../snapshots/math-rendering/ui.expected.md | 4 + .../snapshots/message-actions/ui.expected.md | 8 + .../plan-review/approved.expected.md | 4 + .../question-composer/answered.expected.md | 4 + .../seeded-history/command-row.expected.md | 4 + .../seeded-history/feedback-row.expected.md | 4 + .../snapshots/seeded-history/ui.expected.md | 4 + .../snapshots/skill-tool-row/ui.expected.md | 4 + .../skill-user-invoke/ui.expected.md | 4 + .../snapshots/steer-all/settled.expected.md | 4 + .../snapshots/steering/settled.expected.md | 4 + .../subagent-conversation/ui.expected.md | 8 + .../snapshots/web-search-round/ui.expected.md | 4 + apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 13 +- docs/module-graph.zh.md | 13 +- docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 12 +- docs/subsystems/feedback.zh.md | 12 +- packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 10 +- packages/api/remotes/tsconfig.client.json | 3 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + .../src/client/sessions/conversation.ts | 6 + .../src/client/chat/MessageIconActions.tsx | 11 +- .../src/client/chat/TurnTailNodeView.tsx | 12 +- .../client/chat/register-node-renderers.ts | 5 +- .../src/client/contract/slots.ts | 23 ++ .../client/conversation-nodes/assistant.ts | 1 + packages/client/ui-feedback/README.i18n.yaml | 6 + packages/client/ui-feedback/README.md | 25 ++ packages/client/ui-feedback/README.zh.md | 25 ++ packages/client/ui-feedback/package.json | 82 ++++++ .../src/client/FeedbackActions.module.css | 108 +++++++ .../src/client/FeedbackActions.tsx | 140 +++++++++ .../ui-feedback/src/client/controller.ts | 267 +++++++++++++++++ .../client/ui-feedback/src/client/index.ts | 84 ++++++ .../client/ui-feedback/src/client/locales.ts | 41 +++ .../client/ui-feedback/src/client/slots.ts | 51 ++++ .../client/ui-feedback/src/css-modules.d.ts | 6 + packages/client/ui-feedback/src/index.ts | 9 + packages/client/ui-feedback/src/invariant.ts | 33 +++ .../ui-feedback/tests/browser-plugin.spec.tsx | 197 +++++++++++++ .../ui-feedback/tests/controller.spec.ts | 273 ++++++++++++++++++ .../tests/feedback-actions.spec.tsx | 172 +++++++++++ packages/client/ui-feedback/tsconfig.json | 42 +++ packages/client/ui-feedback/tsdown.config.ts | 3 + .../client/trajectory-assistant-definition.ts | 1 + pnpm-lock.yaml | 63 +++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 72 files changed, 2095 insertions(+), 22 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md create mode 100644 apps/web/tests/message-feedback.e2e.ts create mode 100644 packages/client/ui-feedback/README.i18n.yaml create mode 100644 packages/client/ui-feedback/README.md create mode 100644 packages/client/ui-feedback/README.zh.md create mode 100644 packages/client/ui-feedback/package.json create mode 100644 packages/client/ui-feedback/src/client/FeedbackActions.module.css create mode 100644 packages/client/ui-feedback/src/client/FeedbackActions.tsx create mode 100644 packages/client/ui-feedback/src/client/controller.ts create mode 100644 packages/client/ui-feedback/src/client/index.ts create mode 100644 packages/client/ui-feedback/src/client/locales.ts create mode 100644 packages/client/ui-feedback/src/client/slots.ts create mode 100644 packages/client/ui-feedback/src/css-modules.d.ts create mode 100644 packages/client/ui-feedback/src/index.ts create mode 100644 packages/client/ui-feedback/src/invariant.ts create mode 100644 packages/client/ui-feedback/tests/browser-plugin.spec.tsx create mode 100644 packages/client/ui-feedback/tests/controller.spec.ts create mode 100644 packages/client/ui-feedback/tests/feedback-actions.spec.tsx create mode 100644 packages/client/ui-feedback/tsconfig.json create mode 100644 packages/client/ui-feedback/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml new file mode 100644 index 0000000000..b6cca60d72 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md +2026-08-11-message-feedback-web-surface.md: 77d21796762ce024f80fd46a7eeeea998fe94753 +2026-08-11-message-feedback-web-surface.zh.md: a6a6248c950070ebc131d285e8008313bf76d374 diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md new file mode 100644 index 0000000000..77d2179676 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md @@ -0,0 +1,55 @@ +# Agent Note: Web surface for message feedback + +Status: implemented + +English | [中文](2026-08-11-message-feedback-web-surface.zh.md) + +## Problem + +[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) landed the durable message-feedback sidecar and its three Host Remote methods, but it was explicitly backend-only: no client package consumed `messageFeedback.list`, `put`, or `delete`, so the Web GUI had no way to record a rating. Its Agent Note deferred "client Remote aggregate mounting and UI" to a separate owner. Issue #1326 asks for the Web surface and was closed by that backend merge without the user-visible half existing. + +An earlier full-stack attempt, [PR #1010](https://github.com/deepseek-harness/deepseek-harness/pull/1010), carried a UI layer but was built against its own backend with a different shape: one Session-wide `revision` for compare-and-swap and RPC named `feedback.upsert`. #2217 shipped per-item `ifVersion` and `messageFeedback.put` instead, so #1010's controller logic no longer matched the contract, and its branch had also drifted structurally (it edited `packages/cordis/`, renamed to `packages/self-modification/`, and added a top-level `packages/session-feedback/` that conflicts with the consolidated `packages/feedback/`). It was closed as superseded rather than rebased. + +The blocking gap for any UI was that the browser could not name a feedback target. The Host accepts only an append-origin `assistant/message` addressed by `MessageId`, but `AssistantMessageNode` — the client's finalized-assistant node — carried `seq`, `turn`, and `step` and no message identity. Only `SteeringMessageNode` had a `messageId`. + +## Decision + +Three seams, each owned where its authority already lives. + +**Message identity in the client node.** `AssistantMessageNode` gains an optional `messageId`, copied from `event.data.message.id` where the node is materialized from a finalized `assistant/message`. It stays absent on interruption-frozen partials, which were never finalized and address no durable message, and on the synthetic sentinel the trajectory layout builds for an unfinalized partial. The field is optional precisely so those two cases remain unrepresentable as feedback targets rather than being papered over with a placeholder. `ui-conversation` and `ui-trajectory` each materialize their own copy of this node, so both finalized branches were updated; the interrupted branches were deliberately left alone. This mirrors the Host's own target rule, which filters on `isAppendSurfaceEvent`, so client and Host agree on what is addressable without sharing code. + +**A declared slot rather than a direct dependency.** `ui-conversation` declares `conversation.chat.assistant-actions` (list kind, session scope, owner `{messageId}`) and authorizes it as a second child of the `turn-tail` node renderer, next to the existing `conversation.chat.turnTail` chain. `TurnTailNodeView` renders it and threads the result into `MessageIconActions` through a new `extraActions` prop, placed between copy and branch. The render site skips the slot entirely when `messageId` is absent, so an interrupted turn shows no controls. The feedback package therefore contributes an entry and never imports the conversation implementation; the strip renders nothing at zero cost when the plugin is composed out of `cordis.yml`. + +`extraActions` is a `ReactNode` prop rather than a second render-slot hole because `MessageIconActions` is shared chrome for user and assistant messages: the assistant caller resolves the slot and passes the result down, so the user path stays unaware of a slot it must never render. + +**Per-item CAS in a per-session controller.** `@deepseek-ai/dsh-client-ui-feedback` holds one `FeedbackController` per Session, keyed by `MessageId` in a map. A single `list` seeds every control in that Session's transcript. Each mutation sends the version that controller last observed as `ifVersion` — `null` when it knows of no item, which is exactly the Host's "must not exist" precondition. + +The conflict path is where this diverges most from #1010. `MessageFeedbackVersionConflict` carries the authoritative `current` item (or `null`), so a lost race reconciles from the reply itself; #1010 answered every conflict with a blind full refresh. A conflict reporting `current: null` deletes the local entry, which is how a rating removed in another tab disappears here. Mutations serialize on a per-Session tail so a queued operation always compares against the committed version rather than the version read when the click landed. + +The list read is deferred to the first hover or focus, not fired on mount, because the controls mount once per settled message in the visible history; a transcript-wide read on mount would fan out one request per message strip. `connection/reset` refreshes only Sessions whose status is no longer `cold`, so a reconnect does not warm Sessions nobody has looked at. + +Toggle semantics keep the two verbs honest: re-clicking the recorded rating calls `delete`, switching sides calls `put` and carries any existing note forward, and clearing a message with no known item returns success without a call because it is already in the requested state. + +**Remote mounting.** `@deepseek-ai/dsh-api-remotes` now mounts `messageFeedbackRemote` alongside `goalsRemote` and composes both disposers in reverse order. The generated `./remote` artifact already existed in #2217's package exports, so no codegen change was needed; the client calls `ctx.remote.messageFeedback` and never touches the transport. Business results cross this boundary as the ordinary tagged union — the gateway throws only on transport failure — so the controller pattern-matches `ok` and translates a throw into the same settled result shape the controls already render. + +## Alternatives considered + +**Reuse `conversation.chat.turnTail` instead of a new slot.** Rejected: `turnTail` is a chain keyed on the Turn and carries `TurnTailOwnerProps {turn, seq, openFile}`, which addresses a Turn boundary rather than a message identity. Feedback needs `MessageId`, and a chain is selector-routed one-at-a-time where the action strip is genuinely a list of independent contributors. + +**Put `messageId` on the chat node's `id` field.** Rejected: that id is `"${turn}:${step}"` and is load-bearing for keyed dispatch and stable React keys. Overloading it would couple node identity to model output identity, and a message id is not unique per node anyway once replacement-origin events exist. + +**Keep #1010's session-wide revision.** Not available: the merged Host contract is per-item `ifVersion`. Even as a client-side simplification it would be worse — one Session revision makes unrelated per-message edits conflict, which is the precise problem #2217's Agent Note records as the reason for per-item versions. + +**Rebase #1010.** Rejected after inspection: 102 files, `mergeable: false`, a duplicate backend and RPC layer that #2217 supersedes under different names, and two directory renames since. Only its ~1,400-line UI layer had residual value, and that layer called `feedback.upsert` with a revision it no longer has. Rewriting the UI against the merged contract was less work than reconciling the branch, and the closing comment on #1010 records that reasoning. + +## Consequences + +The Web GUI records per-message ratings and notes. #1326's user-visible half now exists; the issue was reopened because the backend merge had closed it while no entry point existed. + +`AssistantMessageNode.messageId` is optional, so every existing reader compiles unchanged, but any future consumer must handle absence rather than assume a finalized message. The two parallel materializers remain a duplication hazard: a third view that builds this node must remember to copy the id, and nothing enforces it. Only the chat view renders controls today, even though trajectory and waterfall nodes now carry the same id. + +Feedback stays invisible to the model — the sidecar reaches neither the Session log, model context, nor telemetry — so the package's Model Experience is an audited `none` entry rather than a structured block. + +The sidecar publishes no live frames, so a second tab's rating surfaces on reconnect or on the next conflict reply, not immediately. The note editor does not pre-check `maxNoteBytes` (8192 in the Web bundle), so an oversized note fails on save with `note-too-large` rather than while typing. + +Nine existing Web UI snapshots gained the two rating buttons, confirming the strip reaches every settled assistant message in the shipped composition rather than only the fixture under test. diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md new file mode 100644 index 0000000000..a6a6248c95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md @@ -0,0 +1,55 @@ +# Agent Note:消息反馈的 Web 界面 + +Status: implemented + +[English](2026-08-11-message-feedback-web-surface.md) | 中文 + +## 问题 + +[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) 交付了持久化的消息反馈 sidecar 及其三个 Host Remote 方法,但它明确只做后端:没有任何客户端包消费 `messageFeedback.list`、`put` 或 `delete`,因此 Web GUI 无法记录评价。它的 Agent Note 把「客户端 Remote aggregate 挂载与 UI」留给了另一个负责人。Issue #1326 要求的正是 Web 界面,却在该后端合并时被关闭,而用户可见的那一半并不存在。 + +更早的全栈尝试 [PR #1010](https://github.com/deepseek-harness/deepseek-harness/pull/1010) 带有 UI 层,但它基于自己的后端、形状不同:整个 Session 一个 `revision` 做 compare-and-swap,RPC 名为 `feedback.upsert`。#2217 最终交付的是逐条 `ifVersion` 与 `messageFeedback.put`,因此 #1010 的 controller 逻辑不再匹配契约;它的分支在结构上也已漂移(改动了 `packages/cordis/`,该目录已重命名为 `packages/self-modification/`;新增的顶层 `packages/session-feedback/` 与整合后的 `packages/feedback/` 冲突)。它作为 superseded 关闭,而不是 rebase。 + +任何 UI 的阻塞缺口在于浏览器无法指名一个反馈目标。Host 只接受以 `MessageId` 寻址的 append 来源 `assistant/message`,但 `AssistantMessageNode`——客户端表示已完成 assistant 输出的节点——只携带 `seq`、`turn`、`step`,没有消息身份。只有 `SteeringMessageNode` 有 `messageId`。 + +## 决策 + +三个接缝,各自归属于其权威已经所在的位置。 + +**客户端节点中的消息身份。** `AssistantMessageNode` 增加可选的 `messageId`,在该节点由已完成的 `assistant/message` 物化时从 `event.data.message.id` 复制。它在被中断冻结的部分输出上保持缺失——那些从未完成、不指向任何持久消息——在 trajectory 布局为未完成部分输出构造的合成哨兵上同样缺失。该字段之所以可选,正是为了让这两种情况无法被表示为反馈目标,而不是用占位值掩盖过去。`ui-conversation` 与 `ui-trajectory` 各自物化自己的该节点副本,因此两条「已完成」分支都做了更新;「被中断」分支被有意保留原样。这与 Host 自身的目标规则一致——它按 `isAppendSurfaceEvent` 过滤——因此客户端与 Host 在「什么是可寻址的」上取得一致,而不需要共享代码。 + +**声明式槽位而非直接依赖。** `ui-conversation` 声明 `conversation.chat.assistant-actions`(list 类型、session 作用域、owner 为 `{messageId}`),并把它授权为 `turn-tail` 节点渲染器的第二个子项,与既有的 `conversation.chat.turnTail` 链并列。`TurnTailNodeView` 渲染它,并通过新的 `extraActions` prop 把结果传入 `MessageIconActions`,位置在复制与分支之间。当 `messageId` 缺失时渲染点整体跳过该槽位,因此被中断的 Turn 不显示任何控件。反馈包因此只贡献一个 entry,从不引入 conversation 的实现;当该插件从 `cordis.yml` 组装中移除时,这条操作栏以零成本渲染为空。 + +`extraActions` 是一个 `ReactNode` prop 而不是第二个 render-slot 洞,因为 `MessageIconActions` 是用户消息与 assistant 消息共享的外壳:由 assistant 一侧解析槽位并把结果向下传递,用户路径则对这个它永远不该渲染的槽位保持无感。 + +**per-session controller 中的逐条 CAS。** `@deepseek-ai/dsh-client-ui-feedback` 为每个 Session 持有一个 `FeedbackController`,以 `MessageId` 为键存入 map。一次 `list` 为该 Session 转录中的所有控件播种。每次 mutation 发送该 controller 最后观察到的版本作为 `ifVersion`——当它不知道任何条目时为 `null`,这正是 Host 的「必须不存在」前置条件。 + +冲突路径是与 #1010 分歧最大的地方。`MessageFeedbackVersionConflict` 携带权威的 `current` 条目(或 `null`),因此竞争失败方直接从回复本身收敛;#1010 对每次冲突都以一次盲目的全量刷新作答。报告 `current: null` 的冲突会删除本地条目,这就是在另一个标签页中被移除的评价在此处消失的方式。mutation 在 per-Session 的尾部串行化,因此排队中的操作总是与已提交的版本比较,而不是与点击落下那一刻读到的版本比较。 + +list 读取被推迟到首次 hover 或 focus,而不是在 mount 时触发,因为控件会为可见历史中每条已结算消息各 mount 一次;在 mount 时做全转录读取会导致每条消息栏各发一个请求。`connection/reset` 只刷新状态不再是 `cold` 的 Session,因此重连不会预热没人看过的 Session。 + +切换语义让两个动词保持诚实:再次点击已记录的评价调用 `delete`,切换到另一侧调用 `put` 并携带已有备注,而对没有已知条目的消息执行清除会直接返回成功且不发起调用,因为它已处于被请求的状态。 + +**Remote 挂载。** `@deepseek-ai/dsh-api-remotes` 现在把 `messageFeedbackRemote` 与 `goalsRemote` 并列挂载,并以相反顺序组合两个 disposer。生成的 `./remote` 产物在 #2217 的包导出中已存在,因此不需要 codegen 改动;客户端调用 `ctx.remote.messageFeedback`,从不接触传输层。业务结果以普通的 tagged union 穿过该边界——gateway 只在传输失败时抛出——因此 controller 对 `ok` 做模式匹配,并把抛出翻译为控件已经在渲染的同一种结算结果形状。 + +## 考虑过的替代方案 + +**复用 `conversation.chat.turnTail` 而不新增槽位。** 否决:`turnTail` 是以 Turn 为键的链,携带 `TurnTailOwnerProps {turn, seq, openFile}`,寻址的是 Turn 边界而非消息身份。反馈需要 `MessageId`,而链是选择器路由的一次一个,操作栏则确实是一组互相独立的贡献者的列表。 + +**把 `messageId` 放到 chat 节点的 `id` 字段上。** 否决:该 id 是 `"${turn}:${step}"`,且承载着 keyed dispatch 与稳定 React key 的作用。重载它会把节点身份与模型输出身份耦合起来,而且一旦存在 replacement 来源的事件,消息 id 本身在每个节点上也并非唯一。 + +**保留 #1010 的 session 级 revision。** 不可行:已合并的 Host 契约是逐条 `ifVersion`。即便作为客户端侧的简化也更糟——单一 Session revision 会让互不相关的逐条编辑相互冲突,而这正是 #2217 的 Agent Note 记录的采用逐条版本的原因。 + +**Rebase #1010。** 经检查后否决:102 个文件、`mergeable: false`、一个被 #2217 以不同名称取代的重复后端与 RPC 层,以及此后的两次目录重命名。只有其约 1400 行的 UI 层有残余价值,而该层调用的 `feedback.upsert` 及其 revision 已不复存在。基于已合并的契约重写 UI 比调和该分支更省力,#1010 的关闭评论记录了这一理由。 + +## 结果 + +Web GUI 可以记录逐条消息的评价与备注。#1326 中用户可见的那一半现在存在了;该 Issue 之所以被重开,是因为后端合并在没有任何入口存在的情况下关闭了它。 + +`AssistantMessageNode.messageId` 是可选的,因此所有既有读取方无需改动即可编译,但任何将来的消费方都必须处理缺失,而不能假定消息已完成。两个并行的物化点仍是重复隐患:第三个构造该节点的视图必须记得复制该 id,而没有任何机制强制这一点。今天只有 chat 视图渲染控件,尽管 trajectory 与 waterfall 节点现在携带同一个 id。 + +反馈对模型保持不可见——该 sidecar 既不进入 Session 日志、也不进入模型上下文与 telemetry——因此该包的 Model Experience 是一条经审计的 `none` 条目,而不是结构化区块。 + +该 sidecar 不发布实时帧,因此第二个标签页的评价会在重连时或下一次冲突回复时才浮现,而不是立即。备注编辑器不预先校验 `maxNoteBytes`(Web bundle 中为 8192),因此过大的备注会在保存时以 `note-too-large` 失败,而不是在输入过程中。 + +九个既有 Web UI 快照获得了这两个评价按钮,确认这条操作栏在已发布的组装中触达每条已结算的 assistant 消息,而不仅是被测试的那个 fixture。 diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts new file mode 100644 index 0000000000..69c5ad54bb --- /dev/null +++ b/apps/web/tests/message-feedback.e2e.ts @@ -0,0 +1,121 @@ +// 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. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, + seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled assistant message to +// address, not a new recording (message-actions / sidebar-scrollbar pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'message-feedback-web-e2e' +const NOTE = 'Read both files before answering.' + +describe('web e2e: durable per-message feedback', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_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. The group is already + * expanded on a fresh load, so clicking it unconditionally would collapse it + * and hide the session row. + */ + async function openSeededSession(): Promise { + 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() + } + + it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) + await openSeededSession() + + // The controls live in the assistant message's IconActions row, which the + // transcript reveals on hover/focus like copy and branch. Wait for the + // settled closing text first: the strip mounts with that turn's tail. + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + const like = page.getByRole('button', { name: 'Good response' }).first() + await like.waitFor({ timeout: 30_000 }) + 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. + 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 }) + + // The durable assertion: a cold browser re-reads the sidecar over the wire. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + 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 a + // cold reload shows the unrated label until the strip is touched. Hovering + // the unrated control is what triggers the authoritative re-read. + const cold = page.getByRole('button', { name: 'Good response' }).first() + await cold.waitFor({ timeout: 30_000 }) + await cold.scrollIntoViewIfNeeded() + await cold.hover() + + const restored = page.getByRole('button', { name: 'Remove rating' }).first() + await restored.waitFor({ timeout: 30_000 }) + 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 }) + + // 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'), + { timeout: 10_000 }, + ).toBe('false') + await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + }, 90_000) + + it.skipIf(MODE === 'record')('kept the console clean', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 9d9ea8ab0e..b7da854448 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -33,6 +33,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 42197364ae..7ab633e583 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -48,6 +48,10 @@ - paragraph: CORDIS_UI_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 89d40acb3b..b5b308a22e 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -20,6 +20,10 @@ - paragraph: LIGHTHOUSE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index d360f936bd..8a2268f24d 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index c0ece71be8..be77ef8ee9 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -77,6 +77,10 @@ - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s @@ -186,6 +190,10 @@ - text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。 - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - tooltip "Branch into a new conversation" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 19283ae51d..d4c71faa15 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -20,6 +20,10 @@ - paragraph: LIGHTHOUSE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 7bb61c5b27..88463747a0 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -22,6 +22,10 @@ - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index dd566c17d5..06c084f747 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -35,6 +35,10 @@ - paragraph: CJK_STRONG_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 21e84e000d..319bc6b78b 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -14,6 +14,10 @@ - paragraph: REMOTE_IMAGE_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 221294ad6b..ee727ab171 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -26,6 +26,10 @@ - paragraph: INLINE_CODE_LINK_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index 4880d108e0..9bbef6740e 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -30,6 +30,10 @@ - paragraph: MATH_RENDERING_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 38430d636e..7876ec8577 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -15,6 +15,10 @@ - paragraph: I will read both files before answering. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s @@ -38,6 +42,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 23664b4a1d..d6d48515a2 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -33,6 +33,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index d15b2af3a1..a0a90f9746 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 3aa9e2d738..dbe8db9c55 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 6928b95777..3e48c3f679 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index a30ae29e1e..bfbf1c09a2 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 15ddf45a0d..24a3fa942f 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -31,6 +31,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index 1f1cd0ea18..26af0930a3 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -20,6 +20,10 @@ - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index b20e590686..72a4bd8d8f 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -30,6 +30,10 @@ - paragraph: "Got it: BANANA and ORANGE." - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 93c311cce4..bf4fe45dad 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -31,6 +31,10 @@ - paragraph: Great, let's move forward. BANANA! - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 15ceebba6b..a42437cead 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -25,6 +25,10 @@ - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}} @@ -37,6 +41,10 @@ - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 9e86cdcf2c..44e8382035 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -20,6 +20,10 @@ - paragraph: SEARCH_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f96a1f8dd1..fa4aae0ce9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 098c91c804..b246558d27 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 911255077833354351b08bd2800f2116510ca3c0 -config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc +config-catalog.md: a730801fd6c1307dd5ac1b2281f7052630855f41 +config-catalog.zh.md: 0c379a1f4872bf8528538c401791797f58f31d75 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9112550778..a730801fd6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2740,6 +2740,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-feedback` ([`packages/client/ui-feedback/src/index.ts`](../packages/client/ui-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d3141ab389..0c379a1f48 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2741,6 +2741,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-command`([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-deliverables`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-feedback`([`packages/client/ui-feedback/src/index.ts`](../packages/client/ui-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model`([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f14bf8cea2..96a8e6f4f6 100644 --- a/docs/module-graph.i18n.yaml +++ b/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: 59e22a8b82a210dd66f6e2186f0b827a541e00cc -module-graph.zh.md: 00a433ebaeabba4ce0e39919c3e0fe817608e718 +module-graph.md: 6072f8b6851fef92193600d4c84c0d522033447b +module-graph.zh.md: 0c6acac25c5755478dbca6732cd3b6cb84359939 diff --git a/docs/module-graph.md b/docs/module-graph.md index 59e22a8b82..6072f8b685 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -152,6 +152,7 @@ flowchart TD pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] + pkg_client_ui_feedback["client-ui-feedback"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -701,6 +702,7 @@ flowchart TD pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry @@ -1150,6 +1152,14 @@ flowchart TD pkg_client_ui_deliverables --> pkg_client_ui_conversation pkg_client_ui_deliverables --> pkg_client_ui_slots pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_feedback --> pkg_api_remotes + pkg_client_ui_feedback --> pkg_client_locale + pkg_client_ui_feedback --> pkg_client_runtime + pkg_client_ui_feedback --> pkg_client_ui_conversation + pkg_client_ui_feedback --> pkg_client_ui_primitives + pkg_client_ui_feedback --> pkg_client_ui_slots + pkg_client_ui_feedback --> pkg_invariants + pkg_client_ui_feedback --> pkg_message_feedback pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1390,7 +1400,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1464,6 +1474,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 00a433ebae..0c6acac25c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -154,6 +154,7 @@ flowchart TD pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] + pkg_client_ui_feedback["client-ui-feedback"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -703,6 +704,7 @@ flowchart TD pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry @@ -1152,6 +1154,14 @@ flowchart TD pkg_client_ui_deliverables --> pkg_client_ui_conversation pkg_client_ui_deliverables --> pkg_client_ui_slots pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_feedback --> pkg_api_remotes + pkg_client_ui_feedback --> pkg_client_locale + pkg_client_ui_feedback --> pkg_client_runtime + pkg_client_ui_feedback --> pkg_client_ui_conversation + pkg_client_ui_feedback --> pkg_client_ui_primitives + pkg_client_ui_feedback --> pkg_client_ui_slots + pkg_client_ui_feedback --> pkg_invariants + pkg_client_ui_feedback --> pkg_message_feedback pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1392,7 +1402,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1466,6 +1476,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index bef441ece2..111182d7ed 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/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: a0daf47d093f3efb643950c0e124a8db0734fde4 -feedback.zh.md: 1163d30a6af5f818ab3be8ff754de20656e7e743 +feedback.md: 03a14b40968ab27b6321dc82bd12e8af57ddf5f3 +feedback.zh.md: 68da42322dad515ac185296da66401c522fac93c diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index a0daf47d09..03a14b4096 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -201,15 +201,25 @@ The service stores whole Session rows in the `message_feedback` storage domain t Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. +## Web surface + +[`@deepseek-ai/dsh-client-ui-feedback`](../../packages/client/ui-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. + +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. Reaching that render site required one plumbing change: `AssistantMessageNode` now 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, so only messages the Host accepts as feedback targets present controls. + +One `FeedbackController` 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. + ## Boundaries and limitations -- The client Remote aggregate mount and UI consumer are separately owned and deferred. - The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. - Session persistence has no durable deletion API. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. - A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. - Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy. - Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. - 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 sidecar publishes no live frames, 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. diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 1163d30a6a..68da42322d 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -201,15 +201,25 @@ type MessageFeedbackDeleteResult = Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 +## Web 界面 + +[`@deepseek-ai/dsh-client-ui-feedback`](../../packages/client/ui-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback`,不接触传输层。 + +控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。为抵达该渲染点需要一处管道改动:`AssistantMessageNode` 现在携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot,因此只有 Host 认可为反馈目标的消息才会出现控件。 + +每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 + ## 边界与限制 -- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 - 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 - Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 - 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 - 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。 - 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 - Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 +- Web 控件只出现在对话视图。trajectory 与 waterfall 视图不渲染反馈条目,尽管它们的助手节点携带相同的 `messageId`。 +- 该 sidecar 不发布实时帧,因此另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现。 +- 备注编辑器不预先校验 `maxNoteBytes`;超长备注在保存时以 `note-too-large` 失败,而不是在输入过程中。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 241a86ff9f..66f5735a3f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ce67fb05d7..88eed60909 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -2,10 +2,12 @@ import type { Context } from '@deepseek-ai/cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-message-feedback/remote' declare module '@deepseek-ai/cordis' { interface Context { @@ -23,5 +25,11 @@ export const inject = ['remote'] * @returns disposer after every selected Remote namespace is ready. */ export async function apply(ctx: Context): Promise<() => Promise> { - return await ctx.remote.$mount(goalsRemote) + const mounted = [ + await ctx.remote.$mount(goalsRemote), + await ctx.remote.$mount(messageFeedbackRemote), + ] + return async () => { + for (const dispose of mounted.reverse()) await dispose() + } } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index bc26c0b13f..c8f6914c7c 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -15,6 +15,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../feedback/message-feedback" + }, { "path": "../../typert/type-meta" } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1b34c11d1b..4a4ea73add 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -215,6 +215,11 @@ - 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. + - id: ui-feedback + name: '@deepseek-ai/dsh-client-ui-feedback' + # Model selection: the /model popupSelect + composer seat over session.models. - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 95e7428f0a..6d361bd30f 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", + "@deepseek-ai/dsh-client-ui-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 4397013dab..78652ce342 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -96,6 +96,12 @@ export interface AssistantTiming { export interface AssistantMessageNode { kind: 'assistant' seq: number + /** + * Stable identity of the finalized model output, carried from the + * `assistant/message` event. Absent on interruption-frozen partials: those + * were never finalized, so they address no durable message. + */ + messageId?: MessageId /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ time: number turn: number diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index d70912e346..7d817d6ab5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -29,6 +29,11 @@ export interface MessageIconActionsProps { branchUnavailable?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined + /** + * Slot-rendered actions owned by independent plugins, placed between the + * built-in copy and branch controls. + */ + extraActions?: ReactNode /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -39,7 +44,8 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t, + text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, + extraActions, t, }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() @@ -109,6 +115,7 @@ export function MessageIconActions({ {copied ? : } + {extraActions} {onBranch !== undefined && ( {/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */} diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx index 444389e6ea..d4d27570f9 100644 --- a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx' import { assistantText } from './turn-assistant.ts' import css from './TurnTailNodeView.module.css' -type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'> +type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> + & PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'> /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlotChain, t, useSession, + node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession, }: TurnTailNodeViewProps) { const data = node.data const hasLaterChatNode = useSession(snapshot => @@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ const runMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(0, turn.end.time - turn.start.time) + // Interruption-frozen partials carry no messageId, so they address no + // durable message and contribute no per-message actions. + const messageId = closing.finalNode.messageId + const assistantActions = messageId === undefined + ? null + : renderSlot('conversation.chat.assistant-actions', { messageId }) return (
{tail} @@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ onBranch={() => { forkAt(closing.finalNode.seq) }} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} + extraActions={assistantActions} t={t} />
diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts index 78aa36d136..687f04b252 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -39,7 +39,10 @@ export function registerChatNodeRenderers(ctx: Context): void { name: 'conversation.chat.node', key: 'turn-tail', locale: NS, - children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + children: { + 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' }, + }, }, TurnTailNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView)) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 602e54d5d3..83f298a695 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -11,6 +11,7 @@ import type { TurnLocation, WorkspaceId, } from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { @@ -77,6 +78,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * only to return null; an all-declined chain renders nothing. */ 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } + /** + * Action strip attached to one finalized assistant message, rendered + * inside that message's IconActions row. The chat entry owns the render + * site and passes the addressed message identity; contributors add + * per-message actions without importing the conversation implementation. + * Entries render by ascending `order`. + */ + 'conversation.chat.assistant-actions': { + kind: 'list' + scope: 'session' + owner: AssistantActionOwnerProps + } /** Selected Tool call output inside the details panel. */ 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } /** @@ -253,6 +266,16 @@ export interface TurnTailOwnerProps { openFile: (path: string) => void } +/** + * Owner currency of the assistant-message action strip: the durable identity + * of the one finalized message the contributed actions address. Only finalized + * messages reach this slot, so the id is always present. + */ +export interface AssistantActionOwnerProps { + /** Stable identity carried from the `assistant/message` event. */ + messageId: MessageId +} + /** Hook constrained to business data published on the current Chat Node's Turn. */ export type UseChatNodeTurnData = >( key: Key, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 641a0287e4..880bbafa44 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -152,6 +152,7 @@ function finalNode( return { kind: 'assistant', seq: event.seq, + messageId: event.data.message.id, time: event.time, turn: state.turn, step: state.step, diff --git a/packages/client/ui-feedback/README.i18n.yaml b/packages/client/ui-feedback/README.i18n.yaml new file mode 100644 index 0000000000..1f9cdea206 --- /dev/null +++ b/packages/client/ui-feedback/README.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 packages/client/ui-feedback/README.md +README.md: 2f347a22427ce61ea1093434273ba9b4ba759852 +README.zh.md: 2aea4cd2f4c29312f2c771094b7831171bff760f diff --git a/packages/client/ui-feedback/README.md b/packages/client/ui-feedback/README.md new file mode 100644 index 0000000000..2f347a2242 --- /dev/null +++ b/packages/client/ui-feedback/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-client-ui-feedback + +English | [中文](README.zh.md) + +Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. + +One `FeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history. + +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 the Session. Mutations serialize per Session, so a queued operation always compares against the committed version. Re-clicking the recorded rating retracts the feedback; switching sides carries the existing note forward. + +The `/client` exports are the plugin body (`apply`/`inject`), the `FeedbackActions` component, the `FeedbackController` class, and the injected face types. + +## Model Experience + +None, as feedback is a sidecar that never enters the append-only Session log, the model context, or telemetry; no rating or note is ever visible to the model. + +#### KV Cache effect + +None; no feedback mutation touches the history tail. + +## Known Limitations and Deferred Work + +- **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. +- **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the sidecar publishes no live frames. +- **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes now carry the same `messageId`. diff --git a/packages/client/ui-feedback/README.zh.md b/packages/client/ui-feedback/README.zh.md new file mode 100644 index 0000000000..2aea4cd2f4 --- /dev/null +++ b/packages/client/ui-feedback/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-client-ui-feedback + +[English](README.md) | 中文 + +单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。 + +每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。 + +变更通过 `ctx.remote.messageFeedback` 提交,按条目的 compare-and-set 由 Host 负责。每次 `put` 和 `delete` 都携带本 controller 最后观察到的 `version`;`version-conflict` 响应会带回权威条目,因此竞争失败时直接用该响应对账,无需重新拉取整个 Session。变更按 Session 串行,排队中的操作总是与已提交的版本比较。再次点击已记录的评分会撤回反馈;切换到另一侧会保留已有备注。 + +`/client` 导出插件本体(`apply`/`inject`)、`FeedbackActions` 组件、`FeedbackController` 类以及注入面类型。 + +## 模型体验 + +无。反馈是 sidecar,不进入 append-only 的 Session 日志、模型上下文或遥测;任何评分与备注对模型都不可见。 + +#### KV Cache 影响 + +无;任何反馈变更都不触碰历史尾部。 + +## 已知限制与暂缓事项 + +- **备注大小是 Host 策略** —— 部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由 Host 以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。 +- **无跨标签页推送** —— 另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;该 sidecar 不发布实时帧。 +- **仅限对话视图** —— trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点现在也带有相同的 `messageId`。 diff --git a/packages/client/ui-feedback/package.json b/packages/client/ui-feedback/package.json new file mode 100644 index 0000000000..d4cf2e70eb --- /dev/null +++ b/packages/client/ui-feedback/package.json @@ -0,0 +1,82 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-feedback", + "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-feedback" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-feedback/src/client/FeedbackActions.module.css b/packages/client/ui-feedback/src/client/FeedbackActions.module.css new file mode 100644 index 0000000000..8f554f2a3b --- /dev/null +++ b/packages/client/ui-feedback/src/client/FeedbackActions.module.css @@ -0,0 +1,108 @@ +/* Per-message feedback controls. The rating buttons mirror the shared message + IconActions chrome so the strip reads as one row; the note editor is an + inline expansion anchored to the same row. */ + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.action:disabled { + cursor: default; + opacity: 0.4; +} + +/* A recorded rating stays legible without hover, so the signal survives a + pointer leaving the row. */ +.action[data-active] { + 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: 13px; + line-height: 28px; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; +} + +.noteOpen:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.noteEditor { + display: inline-flex; + align-items: flex-start; + gap: 6px; +} + +.noteInput { + width: 260px; + padding: 6px 8px; + border: 1px solid var(--dsw-alias-border-secondary); + border-radius: 8px; + background: var(--dsw-alias-bg-primary); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + resize: vertical; +} + +.noteSave, +.noteCancel { + height: 28px; + padding: 0 10px; + border: none; + border-radius: 14px; + font-size: 13px; + cursor: pointer; +} + +.noteSave { + background: var(--dsw-alias-interactive-bg-primary); + color: var(--dsw-alias-label-inverse); +} + +.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); + font-size: 13px; + line-height: 28px; +} diff --git a/packages/client/ui-feedback/src/client/FeedbackActions.tsx b/packages/client/ui-feedback/src/client/FeedbackActions.tsx new file mode 100644 index 0000000000..5ecb1b466a --- /dev/null +++ b/packages/client/ui-feedback/src/client/FeedbackActions.tsx @@ -0,0 +1,140 @@ +/** + * Per-message feedback controls: a Like/Dislike pair plus an optional note. + * Rendered inside the assistant message's IconActions row, so the buttons + * reuse that row's chrome and sit between copy and branch. + * @module @deepseek-ai/dsh-client-ui-feedback/client/FeedbackActions + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { + IconDislikeOutline16, IconLikeOutline16, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types' +import type { FeedbackActionProps } from './slots.ts' +import css from './FeedbackActions.module.css' + +/** + * One message's feedback controls. + * @param props - the owner's message identity, the injected verbs, and the + * shared feedback hook. + * @returns the rating buttons, plus the note editor while it is open. + */ +export function FeedbackActions({ messageId, ensure, rate, clear, useFeedback, t }: FeedbackActionProps) { + const item = useFeedback(view => view.items.get(messageId)) + const rating = item?.rating + const [noteOpen, setNoteOpen] = useState(false) + const [draft, setDraft] = useState('') + const [pending, setPending] = useState(false) + const [failure, setFailure] = useState(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) + const seed = useCallback(() => { + if (seeded.current) return + seeded.current = true + void ensure() + }, [ensure]) + + const alive = useRef(true) + useEffect(() => () => { alive.current = false }, []) + + const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => { + if (!alive.current) return + setPending(false) + if (result.ok) { + setFailure(null) + return + } + setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic')) + }, [t]) + + const onRate = useCallback((next: MessageFeedbackRating) => { + setPending(true) + setFailure(null) + // Re-clicking the active rating retracts it; the note goes with it. + if (rating === next) { + setNoteOpen(false) + void clear(messageId).then(settle) + return + } + void rate(messageId, next, item?.note).then(settle) + }, [clear, item?.note, messageId, rate, rating, settle]) + + const onSaveNote = useCallback(() => { + if (rating === undefined) return + const trimmed = draft.trim() + setPending(true) + setFailure(null) + void rate(messageId, rating, trimmed.length === 0 ? undefined : trimmed).then((result) => { + settle(result) + if (result.ok && alive.current) setNoteOpen(false) + }) + }, [draft, messageId, rate, rating, settle]) + + const openNote = useCallback(() => { + setDraft(item?.note ?? '') + setNoteOpen(true) + }, [item?.note]) + + const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like') + const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike') + + return ( + <> + + + + + + + {rating !== undefined && !noteOpen && ( + + )} + {noteOpen && ( + +