feat(web): name context sources and mark steering in the transcript

Every logged non-user user/message collapsed into one identical
「上下文注入」 row, and mid-turn steering rendered in exactly the bubble a
turn-opening prompt uses, so the transcript could not say what had been
added or which message interrupted a running turn.

TranscriptAdapter and the history fold now attach a provenance view to
each context node, computed by contextProvenance() from the durable
source alone: a role (inject, or recall for a cross-session snapshot)
and a producer name read out of the log — instruction paths for
workspace-instructions, session titles for session-reference, the plugin
id for a plugin source, and the bare kind for any other. No client-side
table of producer names, so a renamed or newly mounted producer stays
identifiable without a client release and a foreign log projects like a
live one. ContextInjectionRow titles itself from the role and shows the
name beside it; MessageItem captions durable and pending steering
bubbles.

The caption reverses one clause of the no-interjection-chrome decision,
which removed it because the composer could not steer; composer steering
shipped afterwards without amending that note, so this change supplies
the product decision its reintroduction clause required and corrects the
stale facts left in it.

Fixes #1291
This commit is contained in:
creatixchu
2026-08-04 17:44:18 +08:00
parent 11bad56fc3
commit de96087923
50 changed files with 380 additions and 74 deletions
@@ -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-04-web-context-source-and-steer-marks.md
2026-08-04-web-context-source-and-steer-marks.md: 5d158039b742af58a91b0e9d194b98e89485d8d7
2026-08-04-web-context-source-and-steer-marks.zh.md: 46f5285033671407c1d26794cfd4ba74c06a8ad9
@@ -0,0 +1,48 @@
# Agent Note: Web transcript marks context source, recall, and steering
Status: implemented
English | [中文](2026-08-04-web-context-source-and-steer-marks.zh.md)
## Problem
Everything a producer adds to the model-facing conversation reached the Web transcript as one of two anonymous shapes. Every logged non-user `user/message` — the skill catalog, the runtime snapshot, reconciled `AGENTS.md` instructions, a guard notice, a subagent report, a cross-session snapshot — collapsed into one identical `上下文注入` row, so a reader could not tell what had been added without expanding each row and reading raw JSON. Mid-turn steering was worse: it rendered in exactly the bubble a turn-opening prompt uses, leaving the transcript unable to say which message interrupted a running turn.
The distinctions are already durable. `user/message.source` is the merge-extensible provenance every producer must supply, and steering has its own `steering/message` event; only the presentation discarded them. The terminal transcript this Web UI replaced did name each card's producer, so the Web surface was a regression for the same log.
## Decision
The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering.
`TranscriptAdapter` and the history fold attach a `provenance` view to every `ContextMessageNode`, computed by `contextProvenance()` from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the expanded body, its 141px scrollport, and the truncation policy are unchanged from the [disclosure decision](2026-07-30-web-context-injection-disclosure.md).
**The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session.
`recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario.
`MessageItem` captions durable and pending steering bubbles with `插话`. This reverses one clause of [no steer entry or interjection chrome](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners.
## Alternatives considered
**Localize producer names in the client.** A dictionary keyed by plugin id would read better than `@deepseek-ai/dsh-system-prompt`, but it drifts silently on every rename, needs a client change per new producer, and cannot name a producer from a foreign log at all. Provenance the log already carries is worth more than prose the client invents.
**Register presentations per source kind.** The disclosure decision deferred a keyed context-view slot until source-owned presentations emerged. Naming a row is not a distinct presentation, and a registry keyed on mounted producers would fail exactly where it matters — a resumed log whose producer is no longer mounted still has to render.
**Compute the role and label on the host.** The host would have to attach a view to each event copy, duplicating what the durable source already states and adding a wire field per context message. The projection derives it once per node instead, where the transcript's other derived facts live.
**Give steering its own row instead of a captioned bubble.** Steering is a user message that arrived mid-turn; a separate row shape would break the right-aligned reading rhythm and duplicate the bubble's copy and branch actions for no new information.
**Extend the trajectory table with the same names.** Out of scope: the table's context cell has its own text derivation, and the issue asks for the conversation surface.
## Testing
- `packages/client/runtime` unit coverage pins each provenance arm, the label fallbacks when a name field is missing, empty, or wrongly typed, and the unnamed degradation for a source with no readable kind.
- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles.
- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks.
## Consequences
- A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for.
- Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better name records better provenance.
- `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it.
- The `recall` arm has no producer in a shipped Web leaf until a host mounts `dsh-session-reference`; it is reachable only through logs written elsewhere.
@@ -0,0 +1,48 @@
# Agent NoteWeb transcript 标出上下文来源、召回与 steering
Status: implemented
[English](2026-08-04-web-context-source-and-steer-marks.md) | 中文
## Problem
生产方向模型侧对话补充的一切内容,进入 Web transcript(文本记录)后只剩两种匿名形态。每一条已记录的非用户 `user/message`——skill 目录、运行时快照、经过对账的 `AGENTS.md` 指令、guard 提示、子 agent 汇报、跨会话快照——都塌缩成同一行 `上下文注入`,读者不逐行展开去读原始 JSON 就无从知道究竟注入了什么。steering(中途引导)的情况更糟:它渲染成与开轮提示完全相同的气泡,于是 transcript 无法说明哪一条消息打断了正在运行的轮次。
这些区分本来就是持久事实。`user/message.source` 是每个生产方都必须提供的可合并扩展来源,steering 另有自己的 `steering/message` 事件;把它们丢掉的只有呈现层。被这套 Web UI 取代的终端 transcript 本来会写出每张卡片的生产者,因此面对同一份日志,Web 侧是一次倒退。
## Decision
transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。
`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份 `provenance` 视图,由 `contextProvenance()` 仅依据持久来源计算得出。它返回 `role``inject`,跨会话快照则为 `recall`)与命名生产者的 `label``ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;展开内容区、141px 滚动视口与截断策略沿用[展开项决策](2026-07-30-web-context-injection-disclosure.md),未作改动。
**名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。
`recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。
`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。这推翻了[取消 steer 入口与插话装饰](../simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论——当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。
## Alternatives considered
**在客户端本地化生产者名称。** 以插件 id 为键的字典读起来确实比 `@deepseek-ai/dsh-system-prompt` 好,但它会在每次重命名时悄悄失准,每新增一个生产者都要改客户端,而且对来自外部的日志根本无法命名。日志已经承载的来源,比客户端自己编出来的措辞更有价值。
**按来源 kind 注册呈现。** 展开项决策把键控的 context-view 槽位推迟到出现由来源自有的呈现需求为止。为一行命名并不构成独立呈现,而以「已挂载的生产者」为键的注册表恰恰会在最要紧的地方失效——生产者已不再挂载的恢复日志同样必须渲染出来。
**在 host 侧计算角色与名称。** 那需要为每份事件副本附加一个视图,重复陈述持久来源已经说明的事实,并为每条上下文消息增加一个 wire 字段。改由投影为每个节点计算一次,与 transcript 其他派生事实同处一地。
**给 steering 独立的行而非带标注的气泡。** steering 是一条在轮次中途抵达的用户消息;独立行形会打断右对齐的阅读节奏,并且要为零新增信息重复气泡上的复制与分支操作。
**把同一套名称扩展到 trajectory 表格。** 不在本次范围内:该表格的上下文单元格有自己的文本推导,而 issue 要求的是对话面。
## Testing
- `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退,以及来源没有可读 kind 时的无名降级。
- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。
- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。
## Consequences
- 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。
- 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill``@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好名称的生产者应当记录更好的来源。
- `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。
- 在某个宿主挂载 `dsh-session-reference` 之前,`recall` 分支在已发布的 Web 叶子中没有生产者,只能通过别处写入的日志抵达。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 6f397668106a6c74f327fc799327752c54824d8c
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: f127bd57006747465a1ece87406f5f97086b6a34
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: 15b9b9d299161e9d7404b43cfabfdc11e87d8feb
2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 8c25f2700c316e75d29143e85c4c966fcb744c85
@@ -28,6 +28,7 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome:
## Consequences
- **Superseded in part.** Composer steering shipped afterwards, so the first Decision bullet and the first consequence below no longer describe master: `InputMachine` carries the `queue`/`steer` mode union again and Web users can steer. The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and restored the `插话` caption on steering bubbles. Host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript still hold.
- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls.
- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections.
- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one.
@@ -28,6 +28,7 @@ Status: implemented
## 后果
- **部分被取代。** composer steering 在此之后落地,因此 Decision 的第一条与下面的第一条后果都已不再描述 master:`InputMachine` 重新携带 `queue``steer` mode 联合类型,Web 用户可以 steer。随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并恢复了 steering 气泡上的 `插话` 标注。host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍,仍然成立。
- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。
- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。
- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。