feat(web-shell): add per-turn satisfied / not satisfied marks (#12174)

A reader had no way to tell Web Shell that one particular answer was
satisfactory, and a host embedding it had nowhere to hook that signal:
the existing answer-footer extension point renders in its own block
above the action row, so it cannot put controls beside Copy.

Add two mutually exclusive marks to the right of Copy on a completed
turn's final answer, plus an opt-in `assistantFeedback` customization
item that notifies a host about every transition. The host is a
listener only: the mark lands first, and throwing, rejecting, or
cancelling a dialog in the callback never changes it.

The mark is deliberately local — kept in localStorage, not in the
session record — so no daemon route, record type, or transcript
projection is added and the wire stays untouched. It is keyed by the
daemon's promptId, the only value that survives a reload, since a
block id is a per-projection ordinal and a persisted replay stamps the
prompt's block rather than the answer's.

No telemetry: marking emits no UserFeedbackEvent and is not gated by
the usage-statistics setting.
This commit is contained in:
ytahdn authored and GitHub committed 2026-09-18 10:12:27 +00:00
1 parent 3307b89cda
commit 2d49eff011
18 files changed
+1700 -5

No files matched your search

@@ -0,0 +1,276 @@
# Web Shell Assistant Answer Feedback
[English](web-shell-assistant-feedback.md) | [简体中文](web-shell-assistant-feedback.zh-CN.md)
Status: implemented in this change.
## Problem
A reader of a completed answer has no way to tell Web Shell that the answer was
satisfactory or not. The CLI TUI already offers a feedback dialog, but it is
asked about the session rather than the turn, and no other client can record
per-turn sentiment about a specific answer.
Hosts embedding Web Shell also have nowhere to hook such a signal: the existing
answer-footer extension point only renders host content into its own block above
the action row, so a host cannot put controls next to the built-in Copy button.
## Goals
- Let the reader mark a completed assistant turn as satisfied or not satisfied,
from the answer's own action row, directly right of Copy.
- Keep the mark visible afterwards, so a reader can see what they chose.
- Let a host learn about each mark, without letting the host become the source of
truth for the UI state.
- Leave every other client and the daemon wire untouched.
## Non-goals
- No server-side persistence. The mark is not written to the session record, and
is not readable by another browser, device, or client.
- No telemetry. Marking does not emit an `UserFeedbackEvent` or any other
analytics event, and is not gated by the usage-statistics setting.
- No prompt-id anchoring, no daemon route, and no change to the daemon's
transcript projection or wire format.
- No host-supplied initial state: a host cannot seed or override a mark.
- No per-turn visibility control: the host switches the whole feature on or off.
## Design
### Placement
The marks render in the completed assistant turn's action row, immediately after
the Copy button and before the Branch button, the turn sources, and the
timestamp. They appear only where Copy already appears — a completed, non
streaming answer that is the final answer of its turn, in an interactive
transcript.
### Interaction
| Current mark | Click | Result | Host payload `rating` |
| ------------- | ------------- | ----------------------------------------- | --------------------- |
| none | Satisfied | Satisfied is lit | `up` |
| Satisfied | Not satisfied | Satisfied goes dark, Not satisfied lights | `down` |
| Not satisfied | Not satisfied | dark again, unmarked | `null` |
The two marks are mutually exclusive. Every transition notifies the host once,
including the transition to unmarked. The host is a listener: the mark lands
before the notification runs, and nothing the host does in the callback — throw,
reject, open a dialog the reader never submits — changes the mark.
A marked turn is coloured by direction — blue for satisfied, red for not
satisfied — on a glyph sized to sit evenly beside the built-in Copy and Branch
icons. The colour survives hover and focus, because the row only appears on
hover; a mark that greyed out under the pointer would never be readable.
The row keeps its existing hover behaviour: marking does not pin it open. That
needs one explicit step, since a pointer click leaves the button focused and the
row's `:focus-within` rule would otherwise hold it open after the pointer
leaves. Pointer clicks release focus; keyboard activation keeps it, so the row
stays reachable without a mouse.
### State and storage
Web Shell owns the mark. State lives in the message list rather than in a message
row, because the transcript virtualizes and unmounts rows that scroll out of
view.
- Storage key: `qwen-web-shell-assistant-feedback`, one versioned JSON payload
`{ v: 1, <sessionId>: { <promptId>: 'up' | 'down' } }`.
- The mark is keyed by the turn's admitted prompt id, not by a transcript block
id. See "Turn identity" below for why that is the only value that survives a
reload, and what happens when a turn has none.
- At most `MAX_ASSISTANT_FEEDBACK_SESSIONS` (10) sessions are kept. The oldest is
dropped first, and editing a session moves it to the newest position so a
session in active use is not aged out by an untouched neighbour.
- Clearing the last mark of a session removes that session's entry.
Storage is best effort. A private or embedded context can refuse `localStorage`
entirely, and a hand-edited or future payload may not parse: writing never
throws, and reading degrades to "nothing marked" rather than guessing. In all
failure modes the click still lands and the host is still notified; only the
memory across a reload is lost.
### Turn identity
A block id is not an identity: the transcript reducer mints ids as
`<kind>-<ordinal>` from a counter that starts at 1 per projection, so a block
id depends on how the transcript was loaded rather than on what it contains.
The mark is therefore keyed by the daemon's `promptId` — the uuid the daemon
mints at prompt admission — which the daemon also persists on the turn's
`turn_result` record and addresses turns by (`GET /session/:id/turns/:promptId`).
Where that value sits on the blocks differs between the two ways a transcript
arrives, which was measured end to end rather than assumed:
- **Live**: the assistant blocks carry it, and it equals the id returned when the
prompt was admitted.
- **Replay** (opening a session, reloading, paginating — the daemon's
`historyPageSize` path): the assistant blocks carry **no** `promptId`; only
`user_message_chunk` does, and it equals the turn's persisted record.
- A reload served from the daemon's in-memory snapshot instead of a persisted
replay carries it on the assistant blocks again, so which shape arrives is not
fixed.
The client therefore takes `answer.promptId ?? headUserMessage.promptId`: both
sources hold the same value when either is present, and a turn with neither is
not offered the marks at all. A second consequence is that the mark is not
carried across a fork: forks deliberately exclude `turn_result` records so a new
session does not inherit the source's prompt identities.
### Host API
```ts
customization.assistantFeedback?: {
enabled?: boolean; // `false` hides the marks again
onRate?: (info: {
rating: 'up' | 'down' | null; // null when a mark was cleared
previousRating?: 'up' | 'down';
sessionId?: string; // which conversation
promptId: string; // which turn inside it
userMessage: {
text: string; // prompt tail, or what it carried
timestamp?: number; // prompt wall-clock ms
};
}) => void;
};
```
`sessionId` locates the conversation and `promptId` locates the turn inside it —
the same pair the daemon's own turn routes take; neither alone is enough.
`userMessage.text` is the prompt's most recent 100 characters, trimmed, because
the tail is what distinguishes one prompt from another in a list of marks. When
the prompt carried no text at all, the text is instead what it carried: `[图片]`
when it had images, `[附件]` when it had attachments, and both joined by a space
when it had each. The placeholders are literal and do not follow the UI
language, because a host consumes them as data. A shell turn's prompt is its
command, so `text` carries the command for those turns.
A host passes this as the `assistantFeedback` prop on the embedded `WebShell`,
which forwards it into the customization context. Passing the object shows the
marks; omitting it leaves the answer footer exactly as it is today, which is what
keeps existing hosts unchanged. The payload types are exported from the package
entry so a host can name them. This repository's own standalone app does not opt
in, so the marks appear only where a host passes the option — deliberately, since
the standalone app has no host to notify.
The marks are offered only in an interactive transcript that has a session id, so
the read-only and embedded transcript renderings never show them — as does the
frozen historical viewport, which drops its session id and therefore hides the
marks on answers that still show Copy.
## Decisions and rationale
- **Built into the answer footer rather than a host-rendered footer.** The
existing `renderAssistantTurnFooter` extension point renders above the action
row, in its own block; it cannot place controls beside Copy, and it would force
every host to re-implement lighting, exclusivity, and hover behaviour. The
built-in path keeps one implementation and lets a host opt in with one object.
- **The host is notified, never consulted.** A host that could reject or revert a
mark would make the icon's state depend on a network round trip, and would put
the record of what the reader chose outside the client. Notification-only keeps
a click instant and its outcome final.
- **Local storage rather than a session record.** Per-turn feedback persisted
server-side would need a daemon write route, a new record type in a session
file every client reads, and a read-back projection — a scope far beyond the
feature, and one that reopens questions about user data and telemetry gating.
A deliberately local mark buys the visible behaviour now and leaves the
upgrade path open.
- **No reuse of the TUI's `UserFeedbackEvent`.** That event is session-scoped,
three-valued (`bad`/`fine`/`good`), has no "cleared" state, and its writer also
emits telemetry. Reusing it would bind a UI toggle to an analytics sink and
still not express a per-turn mark.
## Constraints
- The mark is keyed by session and turn, so it requires both. A transcript
without a session id is not offered the marks rather than accepting clicks that
could not be stored.
- Two tabs, or two panes, do not synchronize live, and a write replaces the whole
stored payload: each renders from the storage it read when it mounted, and a
mark made in one instance can be dropped by a write from another.
- A session that is deleted, rewound, or forked leaves its marks behind until the
ten-session cap ages them out. They are never shown for a different session.
## Risks
- If a host also persists the marks on its own side, the two records can
disagree — for example after the host deletes or migrates its copy. The client
mark is presentation state and never reads the host's.
- A cleared mark deletes the stored entry, so nothing preserves the history of
a reader changing their mind.
## Files
| File | Change |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `packages/web-shell/client/adapters/messageTypes.ts` | Declares `promptId` on rendered messages. |
| `packages/web-shell/client/adapters/transcriptToMessages.ts` | Pass that copies the id from the contributing blocks onto the messages built from them. |
| `packages/web-shell/client/utils/assistantFeedback.ts` | Store shape, pruning, tolerant read, best-effort write, the offer predicate, the prompt summary, and the host notification helper. |
| `packages/web-shell/client/hooks/useAssistantFeedback.ts` | Owns the marks for one session and persists them. |
| `packages/web-shell/client/components/messages/AssistantMessage.tsx` | Renders the two marks in the answer action row. |
| `packages/web-shell/client/components/messages/AssistantMessage.module.css` | Direction colour for the marked icon, and the glyph sizing that matches its neighbours. |
| `packages/web-shell/client/components/MessageItem.tsx` | Binds a row to its turn and prompt id. |
| `packages/web-shell/client/components/MessageList.tsx` | Hosts the state, resolves each turn's prompt id, and builds the host payload. |
| `packages/web-shell/client/customization.tsx` | `assistantFeedback` option and its payload types. |
| `packages/web-shell/client/i18n.tsx` | `assistant.satisfied` and `assistant.dissatisfied`. |
| `packages/web-shell/client/index.tsx` | Exports the payload types from the package entry. |
| `packages/web-shell/client/App.tsx` | Forwards the host's option into the customization context. |
## Verification
Unit tests:
- `utils/assistantFeedback.test.ts`: round-trip, tolerant read (non-JSON, unknown
rating, wrong version, throwing `localStorage` with a positive control), write
that never throws, pruning and re-ordering, mark/switch/clear, the offer
predicate's five cases, the prompt summary (tail length, trimming, the
attachment and image placeholders, an unknown prompt), and the notification
helper's throw containment.
- `adapters/transcriptToMessages.test.ts`: the prompt-id pass stamps the messages
built from stamped blocks, and invents no id for a block that has none.
- `hooks/useAssistantFeedback.test.tsx`: marks immediately without a host
handler, notifies with `previousRating`, clears on a repeat click, restores the
mark after a remount, and keeps marks per session.
- `components/messages/AssistantMessage.test.tsx`: no marks unless opted in,
`aria-pressed` projection, the per-direction colour class, pointer-versus-
keyboard focus handling, click-to-notify payloads, and the localized titles.
The live-versus-replay behaviour of `promptId` in "Turn identity" was measured
end to end against a real daemon and a real browser, not inferred from the
reducer: the same turn's assistant block carries the admitted id while streaming
and no id after a persisted replay, while the prompt's own block carries the
persisted value in both cases. That measurement is why the key falls back to the
prompt's block instead of the answer's.
Gates: `prettier`, `eslint --max-warnings 0`, `tsc --noEmit`, and the full
`packages/web-shell` suite all pass.
Manual: the visual result is checked by the owner on the running dev page. The
strings introduced are `Satisfied` / `Not satisfied` in English and `满意` /
`不满意` in Chinese, used as both tooltip and accessible name.
## Acceptance criteria
- With the option passed and a session id present, the marks appear on the final,
completed, non-streaming answer of an interactive turn and on no other row.
- Clicking a mark lights it and notifies the host once, reporting `previousRating`
as it was before the click; clicking the lit mark clears it and notifies with
`rating: null`.
- A mark survives a reload of the same session and is never shown for a different
session.
- Omitting the option, or rendering a read-only or document transcript, shows no
marks and changes nothing else in the footer.
## Follow-up work
- Server-side persistence is still not implemented: the mark lives in this
browser. Doing it later means writing a record through a new daemon route and
reading it back through a projection — the key (`sessionId` + `promptId`) and
the payload in this document already carry what such a record needs.
- The TUI's feedback dialog numbers its options `GOOD: 1, BAD: 2, FINE: 3` while
the telemetry enum is `BAD = 1, FINE = 2, GOOD = 3`, and the rating is cast
across without remapping, so the recorded rating is rotated. That is a
pre-existing defect on a telemetry path, independent of this feature, and is
deliberately not changed here.
@@ -0,0 +1,217 @@
# Web Shell 助手回答的满意/不满意标记
[English](web-shell-assistant-feedback.md) | [简体中文](web-shell-assistant-feedback.zh-CN.md)
状态:本次改动已实现。
## 问题
读完一条回答后,读者没有办法告诉 Web Shell 这条回答是否令人满意。CLI 的 TUI 已有
一个反馈弹窗,但它问的是整个会话、而不是某一轮,且任何其他客户端都无法记录"针对某一条
具体回答"的态度。
嵌入 Web Shell 的宿主也没有地方挂这个信号:既有的回答底部扩展点只能把宿主内容渲染到
操作行**上方**的独立块里,宿主没法把控件放到内置"复制"按钮旁边。
## 目标
- 让读者能在回答自己的操作行里、紧挨复制的右侧,把这一轮标记为满意或不满意。
- 标记之后一直可见,读者能看见自己选了什么。
- 让宿主能得知每一次标记,同时不让宿主成为界面状态的来源。
- 其他所有客户端与 daemon 协议零改动。
## 非目标
- 不做服务端持久化。标记不写入会话记录,别的浏览器、设备或客户端读不到。
- 不发遥测。标记不会发出 `UserFeedbackEvent` 或任何分析事件,也不受使用统计开关约束。
- 不引入 `promptId` 锚点、不新增 daemon 路由、不改 daemon 的 transcript 投影与线格式。
- 不接受宿主传入初始状态:宿主无法预置或覆盖标记。
- 不做逐轮可见性控制:宿主只能整体开或关。
## 设计
### 位置
标记渲染在"已完成助手轮次"的操作行里,紧跟复制按钮之后,位于分叉按钮、来源与时间戳
之前。它们出现的位置与复制按钮完全相同——已完成、不在流式中、且是该轮最终回答的那条,
且只在可交互的 transcript 中。
### 交互
| 当前标记 | 点击 | 结果 | 回调里的 `rating` |
| -------- | ------ | ------------------ | ----------------- |
| 无 | 满意 | 满意亮 | `up` |
| 满意 | 不满意 | 满意熄灭、不满意亮 | `down` |
| 不满意 | 不满意 | 熄灭,回到未评 | `null` |
两个标记互斥。每一次变化都回调宿主一次,**包括回到未评**这一次。宿主是"监听者":标记
先落到界面上,回调随后执行,宿主在回调里做什么——抛错、拒绝、弹出一个读者最终没提交的
对话框——都不改变这个标记。
被标记的那一轮按方向着色——满意为蓝色,不满意为红色——图标尺寸调成与内置的复制、分叉
同一视觉重量。颜色在 hover 与键盘聚焦下都保持:这行只在 hover 时出现,若鼠标一停上去
颜色就被盖回灰色,方向色实际上永远读不到。
操作行保持原有的 hover 行为:标记不会让它常显。这一点需要额外一步——指针点击会给按钮
留下焦点,而该行的 `:focus-within` 规则会因此在指针移开后仍把它撑开。指针点击会主动释放
焦点;键盘激活则保留,这样不用鼠标也能继续操作这一行。
### 状态与存储
标记归 Web Shell 所有。状态放在消息列表**里**、而不是每一行里,因为 transcript 会虚拟滚动,
滚出视野的行会被卸载。
- 存储键:`qwen-web-shell-assistant-feedback`,单条带版本的 JSON:
`{ v: 1, <sessionId>: { <promptId>: 'up' | 'down' } }`。
- 键用的是该轮**准入时的 prompt id**,不是 transcript 块 id。原因与"拿不到时怎么办"见
下面的"轮次身份"。
- 最多保留 `MAX_ASSISTANT_FEEDBACK_SESSIONS`(10)个会话,最先丢弃最旧的;编辑某个会话
会把它移到最新位置,避免"正在使用的会话被邻居挤掉"。
- 清掉某会话的最后一个标记时,该会话的条目一并删除。
存储是尽力而为。隐私模式或嵌入环境可能完全拒绝 `localStorage`,手改过或未来版本的载荷
也可能解析不出来:写入永不抛错,读取在解析不了时退化为"没有任何标记",而不是猜。所有
失败模式下,点击照常生效、宿主照常收到回调,丢掉的只是"刷新之后的记忆"。
### 轮次身份
块 id 不是身份:transcript reducer 按 `<kind>-<序号>` 发号,序号是每次投影从 1 开始的
计数器,所以块 id 取决于"这份记录是怎么加载的",而不是它承载了什么。因此标记以 daemon 的
`promptId` 为键——那是 daemon 在 prompt 准入时铸的 uuid,同时会被持久化到该轮的
`turn_result` 记录里,也是 daemon 寻址一轮用的值(`GET /session/:id/turns/:promptId`)。
这个值落在哪些块上,取决于 transcript 走哪条路来——这两条路是**端到端实测**过的,不是推断:
- **live**:assistant 块带它,值等于 prompt 准入时返回的 id。
- **重放**(打开会话、刷新、翻页——daemon 的 `historyPageSize` 路径):assistant 块**完全
没有** `promptId`,只有 `user_message_chunk` 带,且它等于该轮落盘的记录。
- 若刷新命中的是 daemon 的内存快照而非持久化重放,assistant 块又会重新带上它——所以到底
来哪一种并不固定。
客户端因此取 `answer.promptId ?? headUserMessage.promptId`:只要有一侧存在,两侧就是同一个
值;两侧都没有的轮次干脆不提供标记。另一个推论是**标记不随分叉携带**:分叉会刻意排除
`turn_result` 记录,避免新会话继承来源的 prompt 身份。
### 宿主 API
```ts
customization.assistantFeedback?: {
enabled?: boolean; // 置 false 可再次隐藏标记
onRate?: (info: {
rating: 'up' | 'down' | null; // 取消标记时为 null
previousRating?: 'up' | 'down';
sessionId?: string; // 哪一次会话
promptId: string; // 会话里的哪一轮
userMessage: {
text: string; // prompt 的尾部,或它携带了什么
timestamp?: number; // prompt 的时间(epoch 毫秒)
};
}) => void;
};
```
`sessionId` 定位会话、`promptId` 定位会话内的一轮——与 daemon 自己的轮次路由所需的是同一
对;只给其中一个都不够。
`userMessage.text` 是该 prompt **最新的 100 个字符**(先 trim),因为在一份标记列表里,
区分两条 prompt 靠的是尾部。当这条 prompt 完全没有文字时,改为报告它携带了什么:有图片
就是 `[图片]`,有附件就是 `[附件]`,两者都有则用空格连接。这两个占位符是字面量、**不随
界面语言变化**,因为宿主是把它当数据消费的。shell 轮的 prompt 就是它的命令,因此这些轮次
的 `text` 是那条命令。
宿主把它作为 `assistantFeedback` prop 传给嵌入的 `WebShell`,由后者转发进 customization
上下文。传入这个对象即显示标记;不传则回答底部与今天完全一致,这正是老宿主零变化的原因。
载荷类型已从包的入口导出,宿主可以给它们命名。本仓库自己的独立应用**不**开启它,所以标记
只在宿主传入配置项时出现——这是刻意的,因为独立应用没有宿主可通知。
只有在**有会话 id 的可交互 transcript** 中才会提供标记,因此只读与嵌入的 transcript 渲染
永远不显示它们——冻结的历史视口也一样,它会丢掉会话 id,于是在"复制"仍在的回答上不显示
标记。
## 决策与理由
- **内建在回答操作行,而不是交给宿主渲染。** 既有的 `renderAssistantTurnFooter` 渲染在
操作行**上方**的独立块里,无法把控件放到复制旁边;而且会让每个宿主各自重做点亮、互斥、
hover 这些行为。内建路径只有一份实现,宿主用一个对象即可开启。
- **宿主只被通知,不被询问。** 若宿主能否决或回滚标记,图标状态就会依赖一次网络往返,
也会把"读者选了什么"的记录推到客户端之外。只通知,才能让点击即时且终局。
- **用本地存储而不是会话记录。** 服务端持久化需要新增 daemon 写入路由、在所有客户端都读
的会话文件里新增记录类型、再加一条读回投影——远超本功能的量级,并且会重新打开"用户数据"
与"遥测开关口径"的问题。刻意做成局部的标记,先拿到可见行为,同时把升级路径留着。
- **不复用 TUI 的 `UserFeedbackEvent`。** 那个事件是会话级、三值(`bad`/`fine`/`good`)、
没有"已取消"状态,且它的写入函数同时向遥测出口发送。复用它会把一个界面开关绑到分析出口上,
而且仍然表达不了"每轮一个标记"。
## 约束
- 标记以会话 + 轮次为键,两者都必须有。没有会话 id 的 transcript 不提供标记,而不是接受
一次存不下来的点击。
- 两个标签页、或分屏的两个 pane,不会实时同步,而且每次写入都会替换整份存储:各自按挂载
时读到的存储渲染,一个实例里的标记可能被另一个实例的写入抹掉。
- 会话被删除、回退或分叉后,它的标记会留到被"10 个会话"上限挤掉为止,且绝不会显示在
别的会话里。
## 风险
- 若宿主自己也持久化了一份标记,两份记录可能不一致——例如宿主删掉或迁移了自己的那份。
客户端这份是展示状态,永远不读宿主那份。
- 取消标记会删除存储条目,因此不保留"读者改过主意"的历史。
## 涉及文件
| 文件 | 改动 |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `packages/web-shell/client/adapters/messageTypes.ts` | 声明渲染用消息上的 `promptId` 字段。 |
| `packages/web-shell/client/adapters/transcriptToMessages.ts` | 一趟后处理:把贡献块的 id 复制到由它们构建出的消息上。 |
| `packages/web-shell/client/utils/assistantFeedback.ts` | 存储形态、裁剪、容错读取、尽力写入、是否提供标记的判定、prompt 摘要、宿主通知封装。 |
| `packages/web-shell/client/hooks/useAssistantFeedback.ts` | 持有一个会话的标记并持久化。 |
| `packages/web-shell/client/components/messages/AssistantMessage.tsx` | 在回答操作行渲染两个标记。 |
| `packages/web-shell/client/components/messages/AssistantMessage.module.css` | 标记图标的按方向着色,以及与相邻图标对齐的字形尺寸。 |
| `packages/web-shell/client/components/MessageItem.tsx` | 把某一行绑定到它的轮次与 prompt id。 |
| `packages/web-shell/client/components/MessageList.tsx` | 承载状态、解析每轮的 prompt id、组装宿主载荷。 |
| `packages/web-shell/client/customization.tsx` | `assistantFeedback` 配置项及其载荷类型。 |
| `packages/web-shell/client/i18n.tsx` | `assistant.satisfied` 与 `assistant.dissatisfied`。 |
| `packages/web-shell/client/index.tsx` | 从包的入口导出载荷类型。 |
| `packages/web-shell/client/App.tsx` | 把宿主的配置项转发进 customization 上下文。 |
## 验证
单元测试:
- `utils/assistantFeedback.test.ts`:往返读写、容错读取(非 JSON、未知取值、版本不符、
`localStorage` 抛错且带正向控制)、写入永不抛错、裁剪与重排、标记/切换/取消、判定函数的
五个分支、prompt 摘要(尾部长度、trim、附件与图片占位符、未知 prompt)、通知封装的抛错隔离。
- `adapters/transcriptToMessages.test.ts`:prompt id 后处理会把带戳块上的值盖到消息上,
且不会给没有戳的块编造一个。
- `hooks/useAssistantFeedback.test.tsx`:没有宿主动调时标记即刻生效、回调带
`previousRating`、重复点击取消、重新挂载后标记恢复、标记按会话隔离。
- `components/messages/AssistantMessage.test.tsx`:未开启时不渲染、`aria-pressed` 投影、
按方向的着色类名、指针点击与键盘激活的焦点差异、点击回调载荷、中英标题。
"轮次身份"一节里 live 与重放的差异,是**对着真实 daemon 与真实浏览器端到端实测**出来的,
不是从 reducer 推的:同一轮的 assistant 块在流式时带准入 id,走持久化重放后则不带;而
prompt 自己的块在两种情况下都带落盘值。正因如此,键才回退到 prompt 的块而不是回答的块。
门禁:`prettier`、`eslint --max-warnings 0`、`tsc --noEmit`,以及 `packages/web-shell`
全量套件全部通过。
人工:视觉效果由使用者本人在运行中的开发页面确认。新增文案为英文
`Satisfied` / `Not satisfied`,中文 `满意` / `不满意`,同时用作 tooltip 与无障碍名称。
## 验收标准
- 传入配置项且存在会话 id 时,标记只出现在可交互轮次里那条最终、已完成、不在流式中的回答上,
不出现在任何其他行。
- 点击标记会点亮它并回调宿主一次,`previousRating` 是点击之前的值;再次点击点亮的标记会取消
它,并以 `rating: null` 回调。
- 同一会话刷新后标记仍在,且绝不会显示在别的会话里。
- 不传配置项,或渲染只读 / document transcript,都不显示标记,回答底部的其他部分不变。
## 后续事项
- 服务端持久化仍未实现:标记只存在这个浏览器里。将来要做,需要新增一条 daemon 路由写入、
再通过投影读回——键(`sessionId` + `promptId`)与本文档里的载荷已经带齐了这样的记录所需
的内容。
- TUI 的反馈弹窗把选项编号为 `GOOD: 1, BAD: 2, FINE: 3`,而遥测枚举是
`BAD = 1, FINE = 2, GOOD = 3`,中间是裸强转、没有重映射,因此记录下来的 rating 是错位
的。这是遥测路径上一个既有缺陷,与本功能无关,本次刻意不予改动。
+9
View File
@@ -402,6 +402,7 @@ import {
} from './themeContext';
import {
WebShellCustomizationProvider,
type WebShellAssistantFeedbackOptions,
type WebShellComposerApi,
type WebShellComposerInput,
type WebShellMarkdownCustomization,
@@ -1369,6 +1370,11 @@ export interface WebShellProps {
onComposerTagClick?: ComposerTagClickHandler;
/** Custom renderer displayed after the final assistant message of each turn. */
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
/**
* Satisfied / not-satisfied marks on each completed assistant turn. Omit the
* object to leave the answer footer unchanged.
*/
assistantFeedback?: WebShellAssistantFeedbackOptions;
getAssistantSourcesIcon?: WebShellSourceIconResolver;
sourceReferences?: readonly WebShellSourceReference[];
/** Custom renderer inserted before the built-in chat composer toolbar controls. */
@@ -3059,6 +3065,7 @@ export function App({
renderComposerTagTooltip,
onComposerTagClick,
renderAssistantTurnFooter,
assistantFeedback,
getAssistantSourcesIcon,
sourceReferences,
renderComposerToolbarStart,
@@ -3330,6 +3337,7 @@ export function App({
renderComposerTagTooltip,
onComposerTagClick,
renderAssistantTurnFooter,
assistantFeedback,
getAssistantSourcesIcon,
sourceReferences,
renderComposerToolbarStart,
@@ -3362,6 +3370,7 @@ export function App({
renderComposerTagTooltip,
onComposerTagClick,
renderAssistantTurnFooter,
assistantFeedback,
getAssistantSourcesIcon,
sourceReferences,
renderComposerToolbarStart,
@@ -87,6 +87,16 @@ export interface DaemonMessageTodoItem {
*/
export interface DaemonMessageMeta {
backgroundTurn?: DaemonBackgroundTurn;
/**
* Admitted prompt this message belongs to, copied from the daemon-stamped
* `promptId` of the transcript blocks it was built from.
*
* Unlike a block id (a per-projection ordinal), this survives a reload: the
* live stream stamps it on assistant blocks, and a replay stamps it on the
* user block, which is the same value the turn's persisted record carries.
* Undefined for locally appended messages the daemon has not echoed yet.
*/
promptId?: string;
/**
* Wall-clock epoch milliseconds when the backing transcript block was first
* observed, populated from `serverTimestamp ?? clientReceivedAt`. Surfaced
@@ -5810,3 +5810,29 @@ it('does not let old execution output consume or relabel a newer result for the
).toBe(false);
expect(after[0]).toMatchObject({ data: { backgroundTask: firstTask } });
});
describe('transcript message prompt ids', () => {
it('carries the daemon-stamped prompt id onto the messages it built', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('user-1', 'user', 'hello', 1000, false, {
promptId: 'prompt-1',
}),
textBlock('assistant-2', 'assistant', 'hi', 1100),
textBlock('user-3', 'user', 'again', 2000, false, {
promptId: 'prompt-2',
}),
textBlock('assistant-4', 'assistant', 'sure', 2100, false, {
promptId: 'prompt-2',
}),
]);
const promptIdOf = (id: string) =>
messages.find((message) => message.id === id)?.promptId;
// A replayed turn stamps the prompt's own block; a live turn may stamp the
// answer instead. Both carry the same value.
expect(promptIdOf('user-1')).toBe('prompt-1');
expect(promptIdOf('assistant-4')).toBe('prompt-2');
// A block without a stamp must not have one invented for it.
expect(promptIdOf('assistant-2')).toBeUndefined();
});
});
@@ -1269,10 +1269,41 @@ export function transcriptBlocksToDaemonMessages(
}
synchronizeToolGroupSourceIdentity(messages);
attachTurnPromptIds(messages, blocks);
if (!retainSourceIdentity) stripSourceIdentity(messages);
return messages;
}
/**
* Copies the daemon-stamped per-turn `promptId` from contributing blocks onto
* the messages built from them.
*
* A block id is only an ordinal within one projection, so it cannot identify a
* turn across a reload; `promptId` can. It is attached regardless of
* `includeSourceIdentity`, which governs the tool-group record identity that
* host source references use, not turn identity.
*/
function attachTurnPromptIds(
messages: DaemonMessage[],
blocks: readonly DaemonTranscriptBlock[],
): void {
const promptIdByBlockId = new Map<string, string>();
for (const block of blocks) {
if (block.promptId) promptIdByBlockId.set(block.id, block.promptId);
}
if (promptIdByBlockId.size === 0) return;
for (const message of messages) {
if (message.promptId) continue;
for (const id of [message.id, ...(message.sourceBlockIds ?? [])]) {
const promptId = promptIdByBlockId.get(id);
if (promptId) {
message.promptId = promptId;
break;
}
}
}
}
function synchronizeToolGroupSourceIdentity(messages: DaemonMessage[]): void {
const collect = (tools: readonly DaemonMessageToolCall[]): string[] => {
const ids: string[] = [];
@@ -14,6 +14,7 @@ import type {
} from '../adapters/types';
import { CompactModeContext } from '../WebShellContexts';
import type {
WebShellAssistantFeedbackRating,
WebShellAssistantTurnFooterRenderInfo,
WebShellSource,
} from '../customization';
@@ -75,6 +76,16 @@ interface MessageItemProps {
branchRecordId?: string;
showAssistantActions?: boolean;
showAssistantBranch?: boolean;
/** Turn id marks are keyed by; unset hides the marks for this message. */
assistantFeedbackTurnId?: string;
/** Admitted prompt id of the turn; unset hides the marks for this message. */
assistantFeedbackPromptId?: string;
assistantFeedbackRating?: WebShellAssistantFeedbackRating;
onAssistantFeedbackRate?: (
promptId: string,
turnId: string,
rating: WebShellAssistantFeedbackRating | null,
) => void;
isLocateFlashing?: boolean;
assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
turnSources?: readonly WebShellSource[];
@@ -102,6 +113,10 @@ export const MessageItem = memo(function MessageItem({
branchRecordId,
showAssistantActions = false,
showAssistantBranch = false,
assistantFeedbackTurnId,
assistantFeedbackPromptId,
assistantFeedbackRating,
onAssistantFeedbackRate,
isLocateFlashing = false,
assistantTurnFooterInfo,
turnSources,
@@ -143,6 +158,22 @@ export const MessageItem = memo(function MessageItem({
: undefined,
[onBranchSession, branchRecordId],
);
const boundFeedbackRate = useMemo(
() =>
onAssistantFeedbackRate && assistantFeedbackPromptId
? (rating: WebShellAssistantFeedbackRating | null) =>
onAssistantFeedbackRate(
assistantFeedbackPromptId,
assistantFeedbackTurnId ?? '',
rating,
)
: undefined,
[
onAssistantFeedbackRate,
assistantFeedbackPromptId,
assistantFeedbackTurnId,
],
);
const compactMode = useContext(CompactModeContext);
const questionTool =
message.role === 'tool_group' &&
@@ -187,6 +218,9 @@ export const MessageItem = memo(function MessageItem({
onBranchSession={boundBranchSession}
showFooterActions={showAssistantActions}
showBranchAction={showAssistantBranch}
showAssistantFeedback={assistantFeedbackPromptId !== undefined}
assistantFeedbackRating={assistantFeedbackRating}
onAssistantFeedbackRate={boundFeedbackRate}
isLocateFlashing={isLocateFlashing}
customFooterInfo={assistantTurnFooterInfo}
turnSources={turnSources}
@@ -431,6 +465,14 @@ function areMessageItemPropsEqual(
if (prev.branchRecordId !== next.branchRecordId) return false;
if (prev.showAssistantActions !== next.showAssistantActions) return false;
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
if (prev.assistantFeedbackTurnId !== next.assistantFeedbackTurnId)
return false;
if (prev.assistantFeedbackPromptId !== next.assistantFeedbackPromptId)
return false;
if (prev.assistantFeedbackRating !== next.assistantFeedbackRating)
return false;
if (prev.onAssistantFeedbackRate !== next.onAssistantFeedbackRate)
return false;
if (prev.isLocateFlashing !== next.isLocateFlashing) return false;
if (prev.generateContent !== next.generateContent) return false;
if (
@@ -44,6 +44,7 @@ import {
import { CompactModeContext } from '../WebShellContexts';
import {
useWebShellCustomization,
type WebShellAssistantFeedbackRating,
type WebShellAssistantTurnFooterRenderInfo,
type WebShellSource,
} from '../customization';
@@ -51,6 +52,12 @@ import { useI18n } from '../i18n';
import { formatContextTokens } from '../utils/formatTokenCount';
import { useWebShellPortalRoot } from '../portalRoot';
import { useTranscriptRenderMode } from '../transcriptRenderMode';
import { useAssistantFeedback } from '../hooks/useAssistantFeedback';
import {
feedbackUserMessageOf,
notifyAssistantFeedback,
shouldOfferAssistantFeedback,
} from '../utils/assistantFeedback';
import { MessageItem } from './MessageItem';
import { summaryRunFirstMemberId, summaryRunId } from './summaryRunId';
import type { SessionContentGenerator } from './messages/AssistantMessage';
@@ -3493,8 +3500,56 @@ export const MessageList = memo(
// (collapsed once complete). `displayItems` stays the full, pre-collapse
// list — used only to locate rows hidden inside a collapsed turn — while
// `visibleItems` is what actually renders.
const { collapseCompletedTurns, sourceReferences } =
const { collapseCompletedTurns, sourceReferences, assistantFeedback } =
useWebShellCustomization();
// `sourceSessionId` is the transcript's own session, which is what a mark
// belongs to.
const {
ratings: assistantFeedbackRatings,
rate: rateAssistantFeedback,
ratingForTurn: assistantFeedbackRatingForTurn,
} = useAssistantFeedback(sourceSessionId);
// A turn's mark is keyed by the daemon-stamped prompt id, which a replay
// only carries on the prompt's own (turn-head) block — see
// `shouldOfferAssistantFeedback` and the adapter's prompt-id pass.
const assistantFeedbackHeadById = useMemo(() => {
const heads = new Map<string, Message>();
for (const message of messages) {
if (isTurnStartMessage(message)) heads.set(message.id, message);
}
return heads;
}, [messages]);
const assistantFeedbackEnabled = shouldOfferAssistantFeedback({
renderMode: transcriptRenderMode,
sessionId: sourceSessionId,
options: assistantFeedback,
});
// Kept in a ref so a host passing the options object inline does not
// invalidate the handler (and therefore every rendered message) per render.
const assistantFeedbackOptionsRef = useRef(assistantFeedback);
assistantFeedbackOptionsRef.current = assistantFeedback;
const messagesRef = useRef(messages);
messagesRef.current = messages;
const handleAssistantFeedbackRate = useCallback(
(
promptId: string,
turnId: string,
rating: WebShellAssistantFeedbackRating | null,
) => {
const previousRating = assistantFeedbackRatingForTurn(promptId);
rateAssistantFeedback(promptId, rating);
const onRate = assistantFeedbackOptionsRef.current?.onRate;
if (!onRate) return;
notifyAssistantFeedback(onRate, {
rating,
previousRating,
sessionId: sourceSessionId,
promptId,
userMessage: feedbackUserMessageOf(messagesRef.current, turnId),
});
},
[assistantFeedbackRatingForTurn, rateAssistantFeedback, sourceSessionId],
);
const sourcesByTurnCache = useRef<
| {
sourceMessages: readonly Message[];
@@ -5604,6 +5659,13 @@ export const MessageList = memo(
},
};
}
const feedbackHead = finalAssistantTurnId
? assistantFeedbackHeadById.get(finalAssistantTurnId)
: undefined;
// A live turn stamps the answer's own block while a replayed turn
// only stamps the prompt's; both carry the same value.
const feedbackPromptId =
displayItem.message.promptId ?? feedbackHead?.promptId;
const branchRecordId =
displayItem.message.role === 'assistant'
? displayItem.message.branchRecordId
@@ -5688,6 +5750,18 @@ export const MessageList = memo(
!isResponding &&
branchRecordId !== undefined
}
assistantFeedbackTurnId={
assistantFeedbackEnabled ? finalAssistantTurnId : undefined
}
assistantFeedbackPromptId={
assistantFeedbackEnabled ? feedbackPromptId : undefined
}
assistantFeedbackRating={
assistantFeedbackEnabled && feedbackPromptId
? assistantFeedbackRatings[feedbackPromptId]
: undefined
}
onAssistantFeedbackRate={handleAssistantFeedbackRate}
isLocateFlashing={displayItemMatchesLocateTarget(
displayItem,
flashTarget,
@@ -5748,6 +5822,10 @@ export const MessageList = memo(
visibleItems,
flashTarget,
finalAssistantTurnIdByAssistantId,
assistantFeedbackEnabled,
assistantFeedbackHeadById,
assistantFeedbackRatings,
handleAssistantFeedbackRate,
frozenViewport,
workspaceCwd,
showRetryHint,
@@ -46,8 +46,35 @@
}
.copyButton svg {
width: 14px;
height: 14px;
width: 16px;
height: 16px;
}
/* A thumb glyph fills its view box far more than the built-in copy and branch
icons do, so it takes a smaller box and a proportionally thicker stroke: at
13px the 2.4 stroke paints the same ~1.3px as their 16px default, so the
three read as one row instead of the thumb looking heavier. */
.feedbackButton svg {
width: 13px;
height: 13px;
stroke-width: 2.4;
}
/* The mark reads as state: blue means satisfied, red means not.
The hover and focus variants are listed so the colour survives `.copyButton`'s
own hover rule, which has a higher specificity: the row only appears on
hover, so a mark that greyed out while the pointer was on it would never be
readable. */
.feedbackButtonActiveUp,
.feedbackButtonActiveUp:hover,
.feedbackButtonActiveUp:focus-visible {
color: var(--agent-blue-400, #335cff);
}
.feedbackButtonActiveDown,
.feedbackButtonActiveDown:hover,
.feedbackButtonActiveDown:focus-visible {
color: var(--destructive, #ef4444);
}
.footerTime {
@@ -686,3 +686,132 @@ describe('AssistantMessage copy without the async Clipboard API (issue #9485)',
}
});
});
describe('AssistantMessage satisfied / not-satisfied marks', () => {
const find = (container: HTMLElement, title: string) =>
container.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
it('offers no marks unless the host opts in', () => {
const container = render(
<AssistantMessage content="answer" showFooterActions />,
);
expect(find(container, 'Satisfied')).toBeNull();
expect(find(container, 'Not satisfied')).toBeNull();
});
it('offers both marks and marks the pressed one', () => {
const container = render(
<AssistantMessage
content="answer"
showFooterActions
showAssistantFeedback
assistantFeedbackRating="up"
/>,
);
expect(find(container, 'Satisfied')?.getAttribute('aria-pressed')).toBe(
'true',
);
expect(find(container, 'Not satisfied')?.getAttribute('aria-pressed')).toBe(
'false',
);
});
it('releases focus after a pointer click but keeps it for keyboard activation', () => {
const onRate = vi.fn();
const container = render(
<AssistantMessage
content="answer"
showFooterActions
showAssistantFeedback
onAssistantFeedbackRate={onRate}
/>,
);
const button = find(container, 'Satisfied')!;
act(() => button.focus());
expect(document.activeElement).toBe(button);
// Keyboard activation: focus stays, so the hover-only row is still
// reachable from the keyboard.
act(() => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.activeElement).toBe(button);
// Pointer click: a focused button would hold the row open through
// `:focus-within` after the pointer leaves.
act(() => {
button.dispatchEvent(
new MouseEvent('click', { bubbles: true, detail: 1 }),
);
});
expect(document.activeElement).not.toBe(button);
expect(onRate).toHaveBeenCalledTimes(2);
});
it('colours the mark by direction', () => {
const unmarked = render(
<AssistantMessage content="a1" showFooterActions showAssistantFeedback />,
);
expect(find(unmarked, 'Satisfied')?.className).toContain('feedbackButton');
expect(find(unmarked, 'Satisfied')?.className).not.toContain(
'feedbackButtonActiveUp',
);
const satisfied = render(
<AssistantMessage
content="a2"
showFooterActions
showAssistantFeedback
assistantFeedbackRating="up"
/>,
);
expect(find(satisfied, 'Satisfied')?.className).toContain(
'feedbackButtonActiveUp',
);
expect(find(satisfied, 'Not satisfied')?.className).not.toContain(
'feedbackButtonActiveDown',
);
const dissatisfied = render(
<AssistantMessage
content="a3"
showFooterActions
showAssistantFeedback
assistantFeedbackRating="down"
/>,
);
expect(find(dissatisfied, 'Not satisfied')?.className).toContain(
'feedbackButtonActiveDown',
);
});
it('reports the click to the host, and a repeat as a clear', () => {
const onRate = vi.fn();
const container = render(
<AssistantMessage
content="answer"
showFooterActions
showAssistantFeedback
assistantFeedbackRating="down"
onAssistantFeedbackRate={onRate}
/>,
);
act(() => find(container, 'Not satisfied')?.click());
expect(onRate).toHaveBeenCalledWith(null);
act(() => find(container, 'Satisfied')?.click());
expect(onRate).toHaveBeenLastCalledWith('up');
});
it('translates the mark titles', () => {
const container = render(
<AssistantMessage
content="answer"
showFooterActions
showAssistantFeedback
/>,
'zh-CN',
);
expect(find(container, '满意')).not.toBeNull();
expect(find(container, '不满意')).not.toBeNull();
});
});
@@ -1,9 +1,18 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LightbulbIcon } from 'lucide-react';
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { LightbulbIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
import { Markdown } from './Markdown';
import { TurnSources } from '../sources/TurnSources';
import {
useWebShellCustomization,
type WebShellAssistantFeedbackRating,
type WebShellAssistantTurnFooterRenderInfo,
type WebShellSource,
} from '../../customization';
@@ -31,6 +40,12 @@ interface AssistantMessageProps {
onBranchSession?: () => void | Promise<void>;
showFooterActions?: boolean;
showBranchAction?: boolean;
/** Satisfied / not-satisfied marks are only offered when this is set. */
showAssistantFeedback?: boolean;
assistantFeedbackRating?: WebShellAssistantFeedbackRating;
onAssistantFeedbackRate?: (
rating: WebShellAssistantFeedbackRating | null,
) => void;
isLocateFlashing?: boolean;
customFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
turnSources?: readonly WebShellSource[];
@@ -44,6 +59,9 @@ export const AssistantMessage = memo(function AssistantMessage({
onBranchSession,
showFooterActions = false,
showBranchAction = false,
showAssistantFeedback = false,
assistantFeedbackRating,
onAssistantFeedbackRate,
isLocateFlashing = false,
customFooterInfo,
turnSources,
@@ -84,6 +102,36 @@ export const AssistantMessage = memo(function AssistantMessage({
})
.catch(warnClipboardWriteFailure);
}, [content, flashCopied]);
// Clicking the lit icon clears the mark; clicking the other one switches it.
const handleFeedback = useCallback(
(
rating: WebShellAssistantFeedbackRating,
event: ReactMouseEvent<HTMLButtonElement>,
) => {
if (!onAssistantFeedbackRate) return;
onAssistantFeedbackRate(
assistantFeedbackRating === rating ? null : rating,
);
// A pointer click leaves the button focused, and the row's
// `:focus-within` rule would then pin this hover-only row open after the
// pointer leaves. Keyboard activation reports detail 0, so it keeps focus
// and the row stays reachable from the keyboard.
if (event.detail > 0) event.currentTarget.blur();
},
[assistantFeedbackRating, onAssistantFeedbackRate],
);
const feedbackButtonClass = useCallback(
(rating: WebShellAssistantFeedbackRating) => {
const activeClass =
rating === 'up'
? styles.feedbackButtonActiveUp
: styles.feedbackButtonActiveDown;
return `${styles.copyButton} ${styles.feedbackButton}${
assistantFeedbackRating === rating ? ` ${activeClass}` : ''
}`;
},
[assistantFeedbackRating],
);
return (
<div className={styles.message}>
{content && (
@@ -117,6 +165,30 @@ export const AssistantMessage = memo(function AssistantMessage({
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
)}
{showFooterActions && showAssistantFeedback && (
<>
<button
type="button"
className={feedbackButtonClass('up')}
title={t('assistant.satisfied')}
aria-label={t('assistant.satisfied')}
aria-pressed={assistantFeedbackRating === 'up'}
onClick={(event) => handleFeedback('up', event)}
>
<ThumbsUpIcon />
</button>
<button
type="button"
className={feedbackButtonClass('down')}
title={t('assistant.dissatisfied')}
aria-label={t('assistant.dissatisfied')}
aria-pressed={assistantFeedbackRating === 'down'}
onClick={(event) => handleFeedback('down', event)}
>
<ThumbsDownIcon />
</button>
</>
)}
{showFooterActions && showBranchAction && onBranchSession && (
<button
type="button"
@@ -317,6 +317,51 @@ export type AssistantTurnFooterRenderer = (
info: WebShellAssistantTurnFooterRenderInfo,
) => ReactNode | null | undefined;
export type WebShellAssistantFeedbackRating = 'up' | 'down';
/** The prompt that started a marked turn. */
export interface WebShellAssistantFeedbackUserMessage {
/**
* The prompt's most recent characters, or a placeholder naming what the
* prompt carried when it had no text at all.
*/
text: string;
/** Wall-clock epoch ms of the prompt, when the transcript knows it. */
timestamp?: number;
}
export interface WebShellAssistantFeedbackInfo {
/** The new mark; `null` when the user cleared it by clicking the lit icon. */
rating: WebShellAssistantFeedbackRating | null;
/** The mark the turn carried before this change, when there was one. */
previousRating?: WebShellAssistantFeedbackRating;
/** The session the marked turn belongs to. */
sessionId?: string;
/**
* Admitted prompt id of the marked turn. This is the daemon's own per-turn
* identity: unlike a transcript block id it survives a reload, and it is
* what the daemon addresses a turn by (`/session/:id/turns/:promptId`).
*/
promptId: string;
/** The prompt that started the marked turn. */
userMessage: WebShellAssistantFeedbackUserMessage;
}
export type AssistantFeedbackHandler = (
info: WebShellAssistantFeedbackInfo,
) => void;
export interface WebShellAssistantFeedbackOptions {
/** Passing the object shows the icons; `false` hides them again. */
enabled?: boolean;
/**
* Notified after every change, including clearing a mark. This is a
* notification, not a gate: the icon is already lit (or dark) when it runs,
* and neither a throw nor a later rejection changes that.
*/
onRate?: AssistantFeedbackHandler;
}
/** Return custom artifact artwork, or null/undefined/false for the built-in icon. */
export type ArtifactImageRenderer = (
artifact: DaemonSessionArtifact,
@@ -640,6 +685,11 @@ export interface WebShellCustomization {
renderComposerTagTooltip?: ComposerTagRenderer;
onComposerTagClick?: ComposerTagClickHandler;
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
/**
* Satisfied / not-satisfied marks on each completed assistant turn. Omit the
* object to leave Web Shell's answer footer unchanged.
*/
assistantFeedback?: WebShellAssistantFeedbackOptions;
getAssistantSourcesIcon?: WebShellSourceIconResolver;
sourceReferences?: readonly WebShellSourceReference[];
renderComposerToolbarStart?: ComposerToolbarStartRenderer;
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { WebShellAssistantFeedbackRating } from '../customization';
import { AssistantMessage } from '../components/messages/AssistantMessage';
import { I18nProvider } from '../i18n';
import { ASSISTANT_FEEDBACK_STORAGE_KEY } from '../utils/assistantFeedback';
import { useAssistantFeedback } from './useAssistantFeedback';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const TURN_ID = 'turn-1';
let root: Root;
let container: HTMLDivElement;
interface HostCall {
rating: WebShellAssistantFeedbackRating | null;
previousRating?: WebShellAssistantFeedbackRating;
}
/**
* Mirrors the wiring in MessageList + MessageItem: the hook owns the mark, the
* message reports the click, and the host is told afterwards.
*/
function Transcript({
sessionId,
onHostRate,
}: {
sessionId: string;
onHostRate?: (call: HostCall) => void;
}) {
const { ratings, rate, ratingForTurn } = useAssistantFeedback(sessionId);
return (
<AssistantMessage
content="answer"
showFooterActions
showAssistantFeedback
assistantFeedbackRating={ratings[TURN_ID]}
onAssistantFeedbackRate={(rating) => {
const previousRating = ratingForTurn(TURN_ID);
rate(TURN_ID, rating);
onHostRate?.({ rating, previousRating });
}}
/>
);
}
function mount(
sessionId = 'session-1',
onHostRate?: (call: HostCall) => void,
): void {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<Transcript sessionId={sessionId} onHostRate={onHostRate} />
</I18nProvider>,
);
});
}
function unmount(): void {
act(() => root.unmount());
container.remove();
}
const button = (title: string) =>
container.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
const isLit = (title: string) =>
button(title)?.getAttribute('aria-pressed') === 'true';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
describe('useAssistantFeedback through the answer footer', () => {
it('marks the answer immediately, without a host handler', () => {
mount();
act(() => button('Satisfied')?.click());
expect(isLit('Satisfied')).toBe(true);
expect(isLit('Not satisfied')).toBe(false);
unmount();
});
it('tells the host, and switches without clearing first', () => {
const calls: HostCall[] = [];
mount('session-1', (call) => calls.push(call));
act(() => button('Satisfied')?.click());
expect(calls).toEqual([{ rating: 'up', previousRating: undefined }]);
act(() => button('Not satisfied')?.click());
expect(calls).toEqual([
{ rating: 'up', previousRating: undefined },
{ rating: 'down', previousRating: 'up' },
]);
expect(isLit('Not satisfied')).toBe(true);
unmount();
});
it('clears the mark when the lit icon is clicked again', () => {
const calls: HostCall[] = [];
mount('session-1', (call) => calls.push(call));
act(() => button('Satisfied')?.click());
act(() => button('Satisfied')?.click());
expect(isLit('Satisfied')).toBe(false);
expect(calls[1]).toEqual({ rating: null, previousRating: 'up' });
expect(
window.localStorage.getItem(ASSISTANT_FEEDBACK_STORAGE_KEY),
).not.toContain(TURN_ID);
unmount();
});
it('restores the mark after a reload', () => {
mount();
act(() => button('Not satisfied')?.click());
unmount();
mount();
expect(isLit('Not satisfied')).toBe(true);
unmount();
});
it('keeps marks per session', () => {
mount('session-1');
act(() => button('Satisfied')?.click());
unmount();
mount('session-2');
expect(isLit('Satisfied')).toBe(false);
unmount();
});
});
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
readAssistantFeedbackStore,
setAssistantFeedbackRating,
writeAssistantFeedbackStore,
type AssistantFeedbackRating,
type AssistantFeedbackStore,
} from '../utils/assistantFeedback';
const NO_RATINGS: Record<string, AssistantFeedbackRating> = {};
export interface UseAssistantFeedbackResult {
/** Marks for the current session, keyed by turn prompt id. */
ratings: Readonly<Record<string, AssistantFeedbackRating>>;
/** Marks `promptId` with `rating`, or clears it when `rating` is null. */
rate(promptId: string, rating: AssistantFeedbackRating | null): void;
/** Current mark for a turn, for the host payload's `previousRating`. */
ratingForTurn(
promptId: string | undefined,
): AssistantFeedbackRating | undefined;
}
/**
* Owns the satisfied / not-satisfied marks for one transcript.
*
* State lives here rather than in a message row because the transcript
* virtualizes: a row that scrolls out is unmounted and rebuilt.
*/
export function useAssistantFeedback(
sessionId: string | undefined,
): UseAssistantFeedbackResult {
const [store, setStore] = useState<AssistantFeedbackStore>(
readAssistantFeedbackStore,
);
// Hydrating is not an edit, so the first render must not write back.
const hydratedStore = useRef(store);
const storeRef = useRef(store);
storeRef.current = store;
const rate = useCallback(
(turnId: string, rating: AssistantFeedbackRating | null) => {
if (!sessionId || !turnId) return;
setStore((current) =>
setAssistantFeedbackRating(current, sessionId, turnId, rating),
);
},
[sessionId],
);
const ratingForTurn = useCallback(
(turnId: string | undefined) =>
sessionId && turnId ? storeRef.current[sessionId]?.[turnId] : undefined,
[sessionId],
);
useEffect(() => {
if (store === hydratedStore.current) return;
writeAssistantFeedbackStore(store);
}, [store]);
return {
ratings: (sessionId && store[sessionId]) || NO_RATINGS,
rate,
ratingForTurn,
};
}
+4
View File
@@ -618,6 +618,8 @@ const EN: Messages = {
'approval.option.allowAlwaysTool': 'Always allow for this tool',
'assistant.branch': 'Branch',
'assistant.copy': 'Copy',
'assistant.dissatisfied': 'Not satisfied',
'assistant.satisfied': 'Satisfied',
'at.category.extensions': 'Extensions',
'at.category.extensions.description': 'Reference active extensions',
'at.category.files': 'Files',
@@ -4539,6 +4541,8 @@ const ZH: Messages = {
'approval.option.allowAlwaysTool': '对此工具始终允许',
'assistant.branch': '分叉',
'assistant.copy': '复制',
'assistant.dissatisfied': '不满意',
'assistant.satisfied': '满意',
'at.category.extensions': '扩展',
'at.category.extensions.description': '引用已启用扩展',
'at.category.files': '文件',
+5
View File
@@ -269,6 +269,11 @@ export type {
WebShellSessionArtifactsChangeReason,
WebShellModelInfo,
WebShellSkillInfo,
WebShellAssistantFeedbackInfo,
WebShellAssistantFeedbackOptions,
WebShellAssistantFeedbackRating,
WebShellAssistantFeedbackUserMessage,
AssistantFeedbackHandler,
} from './customization';
export type { WelcomeHeaderProps } from './components/WelcomeHeader';
export type {
@@ -0,0 +1,278 @@
// @vitest-environment jsdom
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ASSISTANT_FEEDBACK_STORAGE_KEY,
MAX_ASSISTANT_FEEDBACK_SESSIONS,
describeFeedbackUserMessage,
feedbackUserMessageOf,
notifyAssistantFeedback,
readAssistantFeedbackStore,
setAssistantFeedbackRating,
shouldOfferAssistantFeedback,
writeAssistantFeedbackStore,
type AssistantFeedbackStore,
} from './assistantFeedback';
function storeOf(sessionIds: readonly string[]): AssistantFeedbackStore {
const store: AssistantFeedbackStore = {};
for (const sessionId of sessionIds) {
store[sessionId] = { 'turn-1': 'up' };
}
return store;
}
function sessionIds(n: number): string[] {
return Array.from({ length: n }, (_, index) => `session-${index}`);
}
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
});
describe('readAssistantFeedbackStore', () => {
it('round-trips what writeAssistantFeedbackStore persisted', () => {
const store = setAssistantFeedbackRating({}, 'session-1', 'turn-1', 'down');
writeAssistantFeedbackStore(store);
expect(readAssistantFeedbackStore()).toEqual({
'session-1': { 'turn-1': 'down' },
});
});
it('degrades to nothing marked instead of guessing', () => {
window.localStorage.setItem(ASSISTANT_FEEDBACK_STORAGE_KEY, 'not json');
expect(readAssistantFeedbackStore()).toEqual({});
window.localStorage.setItem(
ASSISTANT_FEEDBACK_STORAGE_KEY,
JSON.stringify({ v: 1, 'session-1': { 'turn-1': 'maybe' } }),
);
expect(readAssistantFeedbackStore()).toEqual({});
window.localStorage.setItem(ASSISTANT_FEEDBACK_STORAGE_KEY, '"a string"');
expect(readAssistantFeedbackStore()).toEqual({});
});
it('ignores a payload written by another version', () => {
window.localStorage.setItem(
ASSISTANT_FEEDBACK_STORAGE_KEY,
JSON.stringify({ v: 99, 'session-1': { 'turn-1': 'up' } }),
);
expect(readAssistantFeedbackStore()).toEqual({});
});
it('survives a localStorage that refuses to be read', () => {
const getItem = vi
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new Error('unavailable');
});
expect(readAssistantFeedbackStore()).toEqual({});
// Positive control: without this the assertion above passes on an empty
// localStorage even if the spy never intercepts.
expect(getItem).toHaveBeenCalled();
});
});
describe('writeAssistantFeedbackStore', () => {
it('never throws when localStorage refuses to be written', () => {
const setItem = vi
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new Error('quota exceeded');
});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() =>
writeAssistantFeedbackStore(storeOf(['session-1'])),
).not.toThrow();
expect(setItem).toHaveBeenCalled();
expect(warn).toHaveBeenCalled();
});
it('keeps only the most recent sessions', () => {
writeAssistantFeedbackStore(storeOf(sessionIds(12)));
const stored = readAssistantFeedbackStore();
expect(Object.keys(stored)).toHaveLength(MAX_ASSISTANT_FEEDBACK_SESSIONS);
expect(Object.keys(stored)).toEqual(sessionIds(12).slice(2));
});
});
describe('setAssistantFeedbackRating', () => {
it('marks, switches, and clears a turn', () => {
const marked = setAssistantFeedbackRating({}, 'session-1', 'turn-1', 'up');
expect(marked).toEqual({ 'session-1': { 'turn-1': 'up' } });
const switched = setAssistantFeedbackRating(
marked,
'session-1',
'turn-1',
'down',
);
expect(switched).toEqual({ 'session-1': { 'turn-1': 'down' } });
expect(
setAssistantFeedbackRating(switched, 'session-1', 'turn-1', null),
).toEqual({});
});
it('keeps a session that is still being rated out of the prune order', () => {
const atLimit = storeOf(sessionIds(MAX_ASSISTANT_FEEDBACK_SESSIONS));
const edited = setAssistantFeedbackRating(
atLimit,
'session-0',
'turn-1',
'down',
);
// Adding one more session must drop the stalest one, not the just-edited.
writeAssistantFeedbackStore({
...edited,
'session-10': { 'turn-1': 'up' },
});
const stored = readAssistantFeedbackStore();
expect(Object.keys(stored)).toContain('session-0');
expect(Object.keys(stored)).not.toContain('session-1');
});
it('ignores an empty session or turn id', () => {
const store = storeOf(['session-1']);
expect(setAssistantFeedbackRating(store, '', 'turn-1', 'up')).toBe(store);
expect(setAssistantFeedbackRating(store, 'session-1', '', 'up')).toBe(
store,
);
});
});
describe('shouldOfferAssistantFeedback', () => {
const offer = (overrides: {
renderMode?: 'interactive' | 'readonly' | 'document';
sessionId?: string;
options?: { enabled?: boolean };
}) =>
shouldOfferAssistantFeedback({
renderMode: overrides.renderMode ?? 'interactive',
sessionId: 'sessionId' in overrides ? overrides.sessionId : 'session-1',
options: 'options' in overrides ? overrides.options : {},
});
it('offers the marks to a live session the host opted in', () => {
expect(offer({})).toBe(true);
});
it('stays off when the host did not opt in', () => {
expect(offer({ options: undefined })).toBe(false);
});
it('stays off when the host switched it back off', () => {
expect(offer({ options: { enabled: false } })).toBe(false);
});
it('stays off without a session to store the mark against', () => {
expect(offer({ sessionId: undefined })).toBe(false);
});
it('stays off in a read-only or embedded transcript', () => {
expect(offer({ renderMode: 'readonly' })).toBe(false);
expect(offer({ renderMode: 'document' })).toBe(false);
});
});
describe('describeFeedbackUserMessage', () => {
it('keeps the tail of a long prompt', () => {
const prompt = `${'a'.repeat(150)}TAIL`;
const described = describeFeedbackUserMessage({ content: prompt });
expect(described.text).toHaveLength(100);
expect(described.text.endsWith('TAIL')).toBe(true);
});
it('trims and forwards a short prompt whole', () => {
expect(
describeFeedbackUserMessage({ content: ' hello ', timestamp: 42 }),
).toEqual({ text: 'hello', timestamp: 42 });
});
it('names what a text-less prompt carried', () => {
expect(
describeFeedbackUserMessage({ content: '', images: [{}] }).text,
).toBe('[图片]');
expect(
describeFeedbackUserMessage({ content: ' ', files: [{}] }).text,
).toBe('[附件]');
expect(
describeFeedbackUserMessage({
content: '',
images: [{}, {}],
files: [{}],
}).text,
).toBe('[图片] [附件]');
});
it('leaves the timestamp out when the transcript has none', () => {
expect(describeFeedbackUserMessage({ content: 'hi' })).toEqual({
text: 'hi',
});
});
});
describe('feedbackUserMessageOf', () => {
const messages = [
{ id: 'u1', role: 'user', content: 'first prompt', timestamp: 10 },
{ id: 'a1', role: 'assistant', content: 'answer' },
{ id: 's1', role: 'user_shell', command: 'ls -la', timestamp: 20 },
];
it('reports the prompt of the turn it is asked about', () => {
expect(feedbackUserMessageOf(messages, 'u1')).toEqual({
text: 'first prompt',
timestamp: 10,
});
});
it('uses a shell turn\u2019s command as its prompt', () => {
expect(feedbackUserMessageOf(messages, 's1')).toEqual({
text: 'ls -la',
timestamp: 20,
});
});
it('reports an unknown prompt rather than guessing one', () => {
expect(feedbackUserMessageOf(messages, 'missing')).toEqual({ text: '' });
expect(feedbackUserMessageOf(messages, 'a1')).toEqual({ text: '' });
});
});
describe('notifyAssistantFeedback', () => {
const info = {
rating: 'up' as const,
promptId: 'prompt-1',
sessionId: 'session-1',
userMessage: { text: 'hello', timestamp: 42 },
};
it('hands the mark to the host', () => {
const handler = vi.fn();
notifyAssistantFeedback(handler, info);
expect(handler).toHaveBeenCalledWith(info);
});
it('does nothing without a handler', () => {
expect(() => notifyAssistantFeedback(undefined, info)).not.toThrow();
});
it('swallows a throwing handler: the mark is already made', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(() =>
notifyAssistantFeedback(() => {
throw new Error('host blew up');
}, info),
).not.toThrow();
expect(warn).toHaveBeenCalled();
});
});
@@ -0,0 +1,230 @@
/**
* Persisted satisfied / not-satisfied marks for assistant answers.
*
* The mark is Web Shell's own UI state: the host is notified through
* `customization.assistantFeedback.onRate`, but never feeds state back. Storage
* is therefore best-effort and local to this browser — a failure to read or
* write must never affect the click that just happened.
*/
import type {
WebShellAssistantFeedbackInfo,
WebShellAssistantFeedbackOptions,
WebShellAssistantFeedbackUserMessage,
} from '../customization';
import type { TranscriptRenderMode } from '../transcriptRenderMode';
/**
* Whether an assistant answer may carry satisfied / not-satisfied marks.
*
* Two conditions, both required: a session to store the mark under, and an
* interactive rendering — read-only and document transcripts stay untouched
* however much session context they carry.
*/
export function shouldOfferAssistantFeedback({
renderMode,
sessionId,
options,
}: {
renderMode: TranscriptRenderMode;
sessionId?: string;
options?: WebShellAssistantFeedbackOptions;
}): boolean {
return (
renderMode === 'interactive' &&
sessionId !== undefined &&
options !== undefined &&
options.enabled !== false
);
}
/**
* Tells the host about a mark the user just made. The host is a listener, not
* an authority: it cannot veto the mark, and a throw here must not reach the
* click handler.
*/
export function notifyAssistantFeedback(
handler: ((info: WebShellAssistantFeedbackInfo) => void) | undefined,
info: WebShellAssistantFeedbackInfo,
): void {
if (!handler) return;
try {
handler(info);
} catch (error) {
console.warn('[web-shell] assistant feedback handler threw:', error);
}
}
export type AssistantFeedbackRating = 'up' | 'down';
/** Session id -> turn prompt id -> mark. */
export type AssistantFeedbackStore = Record<
string,
Record<string, AssistantFeedbackRating>
>;
export const ASSISTANT_FEEDBACK_STORAGE_KEY =
'qwen-web-shell-assistant-feedback';
/** Sessions kept in storage; the rest age out oldest-first. */
export const MAX_ASSISTANT_FEEDBACK_SESSIONS = 10;
/** Characters of the turn's prompt forwarded to the host. */
export const FEEDBACK_USER_MESSAGE_MAX_CHARS = 100;
/**
* Summarises the turn's prompt for the host. Only the tail is kept: the end of
* a prompt is what distinguishes it from its neighbours in a list of marks.
*/
export function describeFeedbackUserMessage(message: {
content: string;
images?: readonly unknown[];
files?: readonly unknown[];
timestamp?: number;
}): WebShellAssistantFeedbackUserMessage {
const trimmed = message.content.trim();
const text =
trimmed.length > 0
? trimmed.slice(-FEEDBACK_USER_MESSAGE_MAX_CHARS)
: [
(message.images?.length ?? 0) > 0 ? '[图片]' : undefined,
(message.files?.length ?? 0) > 0 ? '[附件]' : undefined,
]
.filter((part): part is string => part !== undefined)
.join(' ');
return {
text,
...(message.timestamp !== undefined
? { timestamp: message.timestamp }
: {}),
};
}
/**
* The prompt that started the turn whose head message is `turnId`. A shell
* turn's prompt is its command; anything else is reported as unknown rather
* than guessed.
*/
export function feedbackUserMessageOf(
messages: readonly {
id: string;
role: string;
content?: string;
command?: string;
images?: readonly unknown[];
files?: readonly unknown[];
timestamp?: number;
}[],
turnId: string,
): WebShellAssistantFeedbackUserMessage {
const head = messages.find((message) => message.id === turnId);
if (!head || head.role === 'user' || head.role === 'user_shell') {
return describeFeedbackUserMessage({
content: head?.command ?? head?.content ?? '',
images: head?.images,
files: head?.files,
timestamp: head?.timestamp,
});
}
return describeFeedbackUserMessage({ content: '' });
}
const STORAGE_VERSION = 1;
const VALID_RATINGS: ReadonlySet<string> = new Set(['up', 'down']);
function isRating(value: unknown): value is AssistantFeedbackRating {
return typeof value === 'string' && VALID_RATINGS.has(value);
}
function keepMostRecentSessions(
store: AssistantFeedbackStore,
): AssistantFeedbackStore {
const sessionIds = Object.keys(store);
if (sessionIds.length <= MAX_ASSISTANT_FEEDBACK_SESSIONS) return store;
const kept: AssistantFeedbackStore = {};
for (const sessionId of sessionIds.slice(-MAX_ASSISTANT_FEEDBACK_SESSIONS)) {
kept[sessionId] = store[sessionId]!;
}
return kept;
}
/**
* Reads the persisted marks, dropping anything unrecognizable rather than
* guessing: a hand-edited or future payload must degrade to "nothing marked".
*/
export function readAssistantFeedbackStore(): AssistantFeedbackStore {
if (typeof window === 'undefined') return {};
let parsed: unknown;
try {
const raw = window.localStorage.getItem(ASSISTANT_FEEDBACK_STORAGE_KEY);
if (!raw) return {};
parsed = JSON.parse(raw);
} catch {
// localStorage can be unavailable in private or embedded contexts, and the
// stored value may not be JSON at all.
return {};
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const { v, ...sessions } = parsed as Record<string, unknown>;
if (v !== undefined && v !== STORAGE_VERSION) return {};
const store: AssistantFeedbackStore = {};
for (const [sessionId, value] of Object.entries(sessions)) {
if (
!sessionId ||
!value ||
typeof value !== 'object' ||
Array.isArray(value)
) {
continue;
}
const ratings: Record<string, AssistantFeedbackRating> = {};
for (const [turnId, rating] of Object.entries(
value as Record<string, unknown>,
)) {
if (turnId && isRating(rating)) ratings[turnId] = rating;
}
if (Object.keys(ratings).length > 0) store[sessionId] = ratings;
}
return keepMostRecentSessions(store);
}
export function writeAssistantFeedbackStore(
store: AssistantFeedbackStore,
): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
ASSISTANT_FEEDBACK_STORAGE_KEY,
JSON.stringify({ v: STORAGE_VERSION, ...keepMostRecentSessions(store) }),
);
} catch (error) {
// A full or disabled localStorage must not break the click that just
// happened; the mark still shows for this page.
console.warn('[web-shell] failed to persist assistant feedback:', error);
}
}
/**
* Returns a new store with `promptId` marked (or cleared when `rating` is
* null). Pure so React can run it inside a state updater.
*/
export function setAssistantFeedbackRating(
store: AssistantFeedbackStore,
sessionId: string,
promptId: string,
rating: AssistantFeedbackRating | null,
): AssistantFeedbackStore {
if (!sessionId || !promptId) return store;
const session = { ...(store[sessionId] ?? {}) };
if (rating === null) delete session[promptId];
else session[promptId] = rating;
const next: AssistantFeedbackStore = { ...store };
// Re-insert the session so its position tracks its last edit: pruning keeps
// the tail of the key order, and an existing key keeps its old position.
delete next[sessionId];
if (Object.keys(session).length > 0) next[sessionId] = session;
return next;
}