fix(web): project sent user text inline and fold wire references in queue rows
The user-bubble decorator rendered every plain run through the block-level MessageText div, so a decorated single-line message broke into one line per run and the space between two tokens rendered as a blank line. The queue dock's read-only row printed row.preview verbatim, showing the wire session form (@[label](dsh-session:...)) instead of a readable label. Both predate the Lexical composer; the logged model text was correct in both cases. One shared inline projection (reference/user-text.tsx) now owns sent user text for the bubble and the queue row: plain runs are spans with white-space policy left to the consumer (bubble pre-wrap, queue nowrap), and a highest-precedence rule folds the wire session form to its label chip, shielding the URI from the bare-token scan. The queue edit field keeps the literal sent text. user-text.client.spec pins the inline guarantee and every fold rule; queue-actions.e2e locators move to row-container matching (the projection adds one span layer).
This commit is contained in:
parent
df0fbbb091
commit
339a12030d
14 changed files with 288 additions and 96 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/bug-fix/2026-08-21-inline-user-text-projection.md
|
||||
2026-08-21-inline-user-text-projection.md: deb0c54f41f950313246f3700d13b2a2c9f1b7ef
|
||||
2026-08-21-inline-user-text-projection.zh.md: f7b8dd1d825aae05440256df267ae5bf7404ec29
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Agent Note: Sent user text projects inline, and queue rows fold wire references
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-21-inline-user-text-projection.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two display gaps in sent user text, both older than the Lexical composer. The user-bubble decorator (`projectUserText`, then private to `MessageItem`) split one message into plain runs and reference chips, but rendered every plain run through the block-level `MessageText` div — so a decorated single-line message broke into one line per run, and the single space between two adjacent tokens rendered as a whole blank line. Separately, the queue dock's read-only row printed `row.preview` verbatim, so a queued message carrying a chip showed the wire session form `@[查看并分析图片](dsh-session:InNlc3Npb24t…)` — the model-facing text, unreadable as a preview. The logged model text was correct in both cases (verified against the session log bytes); both defects were presentation only.
|
||||
|
||||
## Decision
|
||||
|
||||
One shared inline projection, `reference/user-text.tsx`, owns the display of sent user text and is consumed by the bubble and the queue row:
|
||||
|
||||
- **Everything inline.** Plain runs render as `span`s; the block-level `MessageText` leaves the path entirely. White-space policy stays with the consumer: the bubble declares `pre-wrap` (real newlines survive), the queue preview keeps its `nowrap`/ellipsis single line — the shared spans pin neither.
|
||||
- **Wire session forms fold.** A new highest-precedence rule folds `@[label](dsh-session:…)` to a session chip showing the label (the source text stays on `title`). The existing rules — recall-associated exact labels, then bare `/name` / `@name` tokens by shape — follow at their old precedence, so the fold also shields the URI from the bare-token scan that would otherwise misread it as a file path.
|
||||
- **Queue edit stays literal.** The row's edit field exposes `row.text` unchanged: the user edits exactly what will be sent, and folding an editable surface would detach the visible text from the durable one.
|
||||
|
||||
`queue-actions.e2e` locators moved from `getByText(…).locator('..')` to row-container matching (`li` with `hasText`): the projection adds one span layer, so a parent hop from the matched text no longer lands on the row.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Carry a display text beside the queued model text**: rejected — it adds a wire/session field for a presentation concern and violates the single-truth rule; folding at render needs no new state.
|
||||
- **Fold inside the editor field too**: rejected — the edit target is the literal sent text; a folded editable view would let the user "edit" text that is not what gets sent.
|
||||
- **Fix only the bubble's blank line with CSS** (collapse empty runs): rejected — the runs were block-level by construction, and the queue gap needed the shared projection anyway.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A decorated single-line message renders on one line; the bubble in the field report dropped from four visual lines (one blank) to its natural wrapped height.
|
||||
- Queue previews read as the composer showed them: label chips instead of `dsh-session:` URIs; the wire form also folds in the bubble if it ever reaches durable text.
|
||||
- `MessageItem` and `QueueDock` share one decoration vocabulary and stylesheet (`user-text.module.css`); the chip styles left `MessageItem.module.css`.
|
||||
- Tests: `user-text.client.spec` pins the inline guarantee (zero `div`s, whitespace-preserving runs) and every fold rule; the chat-view literal-text matcher followed the element change.
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Agent Note: 已发送用户文本行内投影,Queue 行折叠 wire 引用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-21-inline-user-text-projection.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
已发送用户文本存在两个显示缺口,都早于 Lexical composer。用户气泡装饰器(`projectUserText`,当时是 `MessageItem` 的私有函数)把一条消息切成普通片段与引用 chip,但每个普通片段都经块级的 `MessageText` div 渲染——被装饰的单行消息因此被拆成每段一行,两个相邻 token 之间的单个空格更是渲染成一整行空白。另一处,queue dock 的只读行原样打印 `row.preview`,携带 chip 的排队消息因此显示 wire 会话形式 `@[查看并分析图片](dsh-session:InNlc3Npb24t…)`——面向模型的文本,作为预览不可读。两处的持久模型文本都是正确的(已对照会话日志字节核实);两个缺陷都纯属呈现层。
|
||||
|
||||
## 决策
|
||||
|
||||
由一个共享的行内投影 `reference/user-text.tsx` 负责已发送用户文本的显示,气泡与 queue 行共同消费:
|
||||
|
||||
- **一切行内。** 普通片段渲染为 `span`;块级 `MessageText` 彻底退出该路径。换行策略归消费方:气泡声明 `pre-wrap`(真实换行保留),queue 预览保持 `nowrap`/省略号单行——共享 span 两者都不钉死。
|
||||
- **wire 会话形式折叠。** 新增最高优先级规则把 `@[label](dsh-session:…)` 折叠为显示标签的会话 chip(原文保留在 `title`)。既有规则——recall 关联的精确标签、按形状识别的裸 `/name` / `@name` token——按原优先级跟随,因此折叠同时挡住了裸 token 扫描(否则它会把 URI 误读成文件路径)。
|
||||
- **Queue 编辑态保持原文。** 行的编辑框展示未经处理的 `row.text`:用户编辑的正是将要发送的内容,折叠一个可编辑表面会让可见文本与持久文本脱钩。
|
||||
|
||||
`queue-actions.e2e` 的定位器从 `getByText(…).locator('..')` 改为行容器匹配(带 `hasText` 的 `li`):投影多了一层 span,从命中文本向上跳一级不再落在行元素上。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **在排队模型文本旁另存一份显示文本**:否决——为呈现关切增加 wire/会话字段,违背单一真源;渲染期折叠不需要新状态。
|
||||
- **编辑框内也折叠**:否决——编辑对象就是字面发送文本,可编辑表面折叠会让用户"编辑"一段并非实际发送的文本。
|
||||
- **仅用 CSS 修气泡空行**(折叠空片段):否决——片段的块级性是结构性的,且 queue 缺口本来就需要共享投影。
|
||||
|
||||
## 后果
|
||||
|
||||
- 被装饰的单行消息渲染为一行;现场报告中的气泡从四视觉行(含一空行)降到自然换行高度。
|
||||
- Queue 预览按 composer 中的样子呈现:标签 chip 取代 `dsh-session:` URI;wire 形式若进入持久文本,气泡同样折叠。
|
||||
- `MessageItem` 与 `QueueDock` 共享一套装饰词汇与样式表(`user-text.module.css`);chip 样式移出 `MessageItem.module.css`。
|
||||
- 测试:`user-text.client.spec` 钉住行内保证(零 `div`、片段保留空白)与每条折叠规则;chat-view 的字面文本匹配器随元素变化更新。
|
||||
|
|
@ -133,7 +133,7 @@ describe('web e2e: queue row actions', () => {
|
|||
expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
|
||||
const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT })
|
||||
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
|
||||
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
|
||||
await editor.fill(EDITED)
|
||||
|
|
@ -142,7 +142,7 @@ describe('web e2e: queue row actions', () => {
|
|||
await page.getByRole('button', { name: 'Save queued message' }).click()
|
||||
await page.getByText(EDITED, { exact: true }).waitFor()
|
||||
|
||||
const removeRow = page.getByText(REMOVE, { exact: true }).locator('..')
|
||||
const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE })
|
||||
await removeRow.getByRole('button', { name: 'Remove queued message' }).click()
|
||||
await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: c067aeb577789b8db5e44888cb7d20568c69be36
|
||||
README.zh.md: 42574792768e3cdc5d1bad5e557fb78a51407734
|
||||
README.md: eb2ff1f160e630c88e3b2219d7c8d9d908dfd532
|
||||
README.zh.md: 118bcf60a9f3571af90acaecedfa30ad3db12645
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro
|
|||
|
||||
The session header dispatches each current ordinary title and subagent breadcrumb through the optional session-scoped `'conversation.session.header.lineage'` seat, followed by the `'conversation.session.header.actions'` list and the independent `'conversation.session.header.utilities'` list at the right edge. Each lineage owner supplies plain breadcrumb identity and display text; the render site retains the ordinary title as fallback, and an ancestor also supplies its upward-navigation callback. Removing the occupant restores every title without affecting header actions, and optional Session utilities cannot reorder or move either group. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Composer references are atomic Lexical decorator nodes inside the contenteditable surface: the chip is a real DOM capsule carrying its domain glyph and truncating label, keyboard and selection address it as one unit, and editing around it never disturbs its identity. Claimed slash commands stay ordinary text with a styled leading token. Each chip node carries the owner's serialization identity, so submission expands it through the owner codec. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
|
||||
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Sent user text projects as inline runs and chips — a decorated single-line message never breaks across lines — and the wire session form `@[label](dsh-session:…)` folds to its label. Composer references are atomic Lexical decorator nodes inside the contenteditable surface: the chip is a real DOM capsule carrying its domain glyph and truncating label, keyboard and selection address it as one unit, and editing around it never disturbs its identity. Claimed slash commands stay ordinary text with a styled leading token. Each chip node carries the owner's serialization identity, so submission expands it through the owner codec. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows.
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ The chat flow projects each producer-correlated retry chain into one stable, mut
|
|||
|
||||
`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-user-questions's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md).
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview — rendered through the user bubble's inline reference projection, so wire session forms fold to their label, while an edit exposes the literal sent text — with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。
|
|||
|
||||
会话页头通过可选的会话作用域 `'conversation.session.header.lineage'` seat 派发当前普通 title 与每一级 subagent 面包屑,随后依次渲染 `'conversation.session.header.actions'` 列表和最右侧独立的 `'conversation.session.header.utilities'` 列表。每个谱系 owner 都会提供纯数据形式的面包屑身份与显示文本;render site 保留普通 title 作为回退,祖先还会提供向上导航的回调。移除 occupant 会恢复每个 title,且不影响页头操作;可选的会话工具不会改变这两个区域的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。输入框引用是 contenteditable 表面内的原子 Lexical decorator 节点:chip 是真实的 DOM 胶囊,携带领域图标与可截断的标签,键盘与选择区将其作为一个整体寻址,围绕它的编辑不会动摇其身份。已认领的 slash command 仍是普通文本,只给起始 token 上色。每个 chip 节点携带所有者的序列化身份,提交时经所有者 codec 展开。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。已发送的用户文本投影为行内片段与 chip——被装饰的单行消息绝不会被拆成多行——wire 会话形式 `@[label](dsh-session:…)` 折叠为其标签。输入框引用是 contenteditable 表面内的原子 Lexical decorator 节点:chip 是真实的 DOM 胶囊,携带领域图标与可截断的标签,键盘与选择区将其作为一个整体寻址,围绕它的编辑不会动摇其身份。已认领的 slash command 仍是普通文本,只给起始 token 上色。每个 chip 节点携带所有者的序列化身份,提交时经所有者 codec 展开。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md))。
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
|||
|
||||
`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.zh.md)。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览——经用户气泡同款行内引用投影渲染,wire 会话形式折叠为其标签,编辑态则展示发送原文——并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md))——并能在重连后从同一权威恢复。
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* The projected user text is inline runs; the bubble owns wrapping so a
|
||||
decorated single-line message stays one line. */
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.referenceSummary {
|
||||
|
|
@ -273,19 +277,3 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Inline references use domain glyphs and business-color text without another
|
||||
container inside the user bubble. */
|
||||
.refChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 2px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.refIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import type { ReactNode } from 'react'
|
|||
import type {
|
||||
ModelRetryNode, TurnErrorNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ReferenceIcon } from '../reference/ReferenceIcon.tsx'
|
||||
import { projectUserText } from '../reference/user-text.tsx'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
|
|
@ -144,74 +144,6 @@ function TurnMaxTokensItem({ t }: {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
* logged model text remains the single truth; this is presentation only.
|
||||
* Plain-text `/name` / `@name` word-boundary tokens decorate (the sent text
|
||||
* IS the reference — the bubble uses the same plainest token
|
||||
* scan as the composer, minus the lexicon: sent tokens were validated at
|
||||
* compose time, so shape alone decorates).
|
||||
*/
|
||||
function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode {
|
||||
const ranges: { start: number; end: number; label: string; kind: 'session' | 'plain' }[] = []
|
||||
for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) {
|
||||
const label = `@${rawLabel}`
|
||||
let start = text.indexOf(label)
|
||||
while (start >= 0) {
|
||||
ranges.push({ start, end: start + label.length, label, kind: 'session' })
|
||||
start = text.indexOf(label, start + label.length)
|
||||
}
|
||||
}
|
||||
const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const tokenStart = m.index + (m[1]?.length ?? 0)
|
||||
const rawLabel = m[2] ?? ''
|
||||
const label = rawLabel.startsWith('@"')
|
||||
? rawLabel
|
||||
: rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '')
|
||||
if (label.length <= 1) continue
|
||||
ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
|
||||
}
|
||||
ranges.sort((a, b) => a.start - b.start
|
||||
|| (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1))
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
for (const range of ranges) {
|
||||
if (range.start < cursor) continue
|
||||
const { start: tokenStart, end, label, kind } = range
|
||||
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
|
||||
const referenceKind = kind === 'session'
|
||||
? 'session'
|
||||
: label.startsWith('@')
|
||||
? label.endsWith('/') ? 'folder' : 'file'
|
||||
: undefined
|
||||
const displayLabel = referenceKind === undefined
|
||||
? label
|
||||
: referenceKind === 'session'
|
||||
? label.slice(1)
|
||||
: label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1)
|
||||
parts.push(
|
||||
<span
|
||||
key={tokenStart}
|
||||
className={css.refChip}
|
||||
data-ref-chip={referenceKind ?? 'skill'}
|
||||
title={label}
|
||||
>
|
||||
{referenceKind !== undefined && (
|
||||
<ReferenceIcon kind={referenceKind} size={16} className={css.refIcon} />
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>,
|
||||
)
|
||||
cursor = end
|
||||
}
|
||||
if (parts.length === 0) return <MessageText text={text} />
|
||||
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** Right-aligned bubble shared by user and steering rows. */
|
||||
function UserStyleBubble({
|
||||
content, renderMessageImages, actions, pending = false, referenceLabels = [], t,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import { projectUserText } from '../reference/user-text.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
import css from './QueueDock.module.css'
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
|||
}}
|
||||
/>
|
||||
)
|
||||
: <span className={css.preview}>{row.preview}</span>}
|
||||
: <span className={css.preview}>{projectUserText(row.preview, [])}</span>}
|
||||
{queueMutable && <div className={css.actions}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/* Inline projection of sent user text: plain runs and reference chips flow in
|
||||
the consumer's own line box (bubble or queue row); nothing here is block.
|
||||
White-space policy stays with the consumer: the bubble wraps with pre-wrap,
|
||||
the queue preview keeps its single nowrap/ellipsis line. */
|
||||
|
||||
.plainRun {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.refChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0 2px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.refIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Display projection of reference forms in sent user text (bubble and queue
|
||||
* rows). The logged model text remains the single truth; this is presentation
|
||||
* only, and every part renders inline so a single-line message never breaks
|
||||
* across lines. Three decoration sources, by precedence: the wire session form
|
||||
* `@[label](dsh-session:...)` folds to its label; exact session labels
|
||||
* supplied by an adjacent recall decorate their bare `@label` mention; and
|
||||
* plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent
|
||||
* tokens were validated at compose time).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { ReferenceIcon } from './ReferenceIcon.tsx'
|
||||
import css from './user-text.module.css'
|
||||
|
||||
/** The wire form a session chip serializes to; label is the display text. */
|
||||
const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
|
||||
|
||||
interface DecorationRange {
|
||||
readonly start: number
|
||||
readonly end: number
|
||||
/** Matched source text (hover title). */
|
||||
readonly label: string
|
||||
readonly kind: 'session' | 'plain'
|
||||
/** Pre-resolved display text (wire folds); derived from label when absent. */
|
||||
readonly display?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one sent text into inline plain runs and reference chips.
|
||||
* @param text - the logged model text of the message or queue row.
|
||||
* @param sessionLabels - exact session mention labels associated by an adjacent recall.
|
||||
* @returns inline nodes covering the whole text.
|
||||
*/
|
||||
export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode {
|
||||
const ranges: DecorationRange[] = []
|
||||
SESSION_WIRE_RE.lastIndex = 0
|
||||
let wire: RegExpExecArray | null
|
||||
while ((wire = SESSION_WIRE_RE.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: wire.index,
|
||||
end: wire.index + wire[0].length,
|
||||
label: wire[0],
|
||||
kind: 'session',
|
||||
display: wire[1] ?? '',
|
||||
})
|
||||
}
|
||||
for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) {
|
||||
const label = `@${rawLabel}`
|
||||
let start = text.indexOf(label)
|
||||
while (start >= 0) {
|
||||
ranges.push({ start, end: start + label.length, label, kind: 'session' })
|
||||
start = text.indexOf(label, start + label.length)
|
||||
}
|
||||
}
|
||||
const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const tokenStart = m.index + (m[1]?.length ?? 0)
|
||||
const rawLabel = m[2] ?? ''
|
||||
const label = rawLabel.startsWith('@"')
|
||||
? rawLabel
|
||||
: rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '')
|
||||
if (label.length <= 1) continue
|
||||
ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' })
|
||||
}
|
||||
ranges.sort((a, b) => a.start - b.start
|
||||
|| (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1))
|
||||
const parts: ReactNode[] = []
|
||||
let cursor = 0
|
||||
const pushPlain = (from: number, to: number): void => {
|
||||
parts.push(<span key={`t${from}`} className={css.plainRun}>{text.slice(from, to)}</span>)
|
||||
}
|
||||
for (const range of ranges) {
|
||||
if (range.start < cursor) continue
|
||||
const { start: tokenStart, end, label, kind } = range
|
||||
if (tokenStart > cursor) pushPlain(cursor, tokenStart)
|
||||
const referenceKind = kind === 'session'
|
||||
? 'session'
|
||||
: label.startsWith('@')
|
||||
? label.endsWith('/') ? 'folder' : 'file'
|
||||
: undefined
|
||||
const displayLabel = range.display
|
||||
?? (referenceKind === undefined
|
||||
? label
|
||||
: referenceKind === 'session'
|
||||
? label.slice(1)
|
||||
: label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1))
|
||||
parts.push(
|
||||
<span
|
||||
key={tokenStart}
|
||||
className={css.refChip}
|
||||
data-ref-chip={referenceKind ?? 'skill'}
|
||||
title={label}
|
||||
>
|
||||
{referenceKind !== undefined && (
|
||||
<ReferenceIcon kind={referenceKind} size={16} className={css.refIcon} />
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>,
|
||||
)
|
||||
cursor = end
|
||||
}
|
||||
if (parts.length === 0) return <span className={css.plainRun}>{text}</span>
|
||||
if (cursor < text.length) pushPlain(cursor, text.length)
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
|
@ -787,7 +787,7 @@ describe('ChatView', () => {
|
|||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(1)
|
||||
const literal = view.getByText((_content, element) => (
|
||||
element?.tagName === 'DIV' && element.childElementCount === 0 && element.textContent === markdown
|
||||
element?.tagName === 'SPAN' && element.childElementCount === 0 && element.textContent === markdown
|
||||
))
|
||||
expect(literal.querySelector('h1')).toBeNull()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Inline projection of sent user text: decoration never breaks a single-line
|
||||
* message (bubble regression), and wire session forms fold to their label
|
||||
* (queue-row readability).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { projectUserText } from '../src/client/reference/user-text.tsx'
|
||||
|
||||
const project = (text: string, labels: readonly string[] = []) =>
|
||||
render(<div data-host>{projectUserText(text, labels)}</div>).container.querySelector('[data-host]')!
|
||||
|
||||
describe('projectUserText', () => {
|
||||
it('keeps a decorated single-line message on one line: every part is inline', () => {
|
||||
const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'])
|
||||
expect(host.querySelectorAll('div').length).toBe(0)
|
||||
expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试')
|
||||
const chips = host.querySelectorAll('[data-ref-chip]')
|
||||
expect([...chips].map(c => c.getAttribute('data-ref-chip'))).toEqual(['skill', 'session'])
|
||||
// The whitespace between tokens survives as its own inline run.
|
||||
const runs = [...host.querySelectorAll('span')].filter(s => !s.hasAttribute('data-ref-chip') && s.closest('[data-ref-chip]') === null)
|
||||
expect(runs.map(r => r.textContent)).toEqual(['反反复复 ', ' '])
|
||||
})
|
||||
|
||||
it('folds the wire session form to its label with the session glyph', () => {
|
||||
const host = project('看看 @[查看并分析图片](dsh-session:InNlc3Npb24tNDM0) 的结论')
|
||||
const chip = host.querySelector('[data-ref-chip="session"]')!
|
||||
expect(chip.textContent).toBe('查看并分析图片')
|
||||
expect(chip.getAttribute('title')).toBe('@[查看并分析图片](dsh-session:InNlc3Npb24tNDM0)')
|
||||
expect(chip.querySelector('svg')).not.toBeNull()
|
||||
expect(host.textContent).toBe('看看 查看并分析图片 的结论')
|
||||
})
|
||||
|
||||
it('prefers the wire fold over the bare-token scan on the same range', () => {
|
||||
const host = project('@[a](dsh-session:x)', [])
|
||||
expect(host.querySelectorAll('[data-ref-chip]').length).toBe(1)
|
||||
expect(host.querySelector('[data-ref-chip="session"]')!.textContent).toBe('a')
|
||||
})
|
||||
|
||||
it('decorates recall-associated labels, files, folders, and quoted paths', () => {
|
||||
const host = project('@会话一 说 @src/deep/file.txt 与 @dir/ 与 @"a b.md"', ['会话一'])
|
||||
const kinds = [...host.querySelectorAll('[data-ref-chip]')].map(c =>
|
||||
[c.getAttribute('data-ref-chip'), c.textContent])
|
||||
expect(kinds).toEqual([
|
||||
['session', '会话一'],
|
||||
['file', 'file.txt'],
|
||||
['folder', 'dir'],
|
||||
['file', 'a b.md'],
|
||||
])
|
||||
})
|
||||
|
||||
it('repeated recall labels decorate every occurrence once', () => {
|
||||
const host = project('@再看 前情 @再看', ['再看', '再看'])
|
||||
expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2)
|
||||
})
|
||||
|
||||
it('strips trailing punctuation and skips degenerate tokens', () => {
|
||||
const host = project('用 /plan。 试试 @。')
|
||||
const chips = [...host.querySelectorAll('[data-ref-chip]')]
|
||||
expect(chips.map(c => c.textContent)).toEqual(['/plan'])
|
||||
expect(host.textContent).toBe('用 /plan。 试试 @。')
|
||||
})
|
||||
|
||||
it('renders undecorated text as one inline run', () => {
|
||||
const host = project('纯文本,无引用')
|
||||
expect(host.querySelectorAll('div').length).toBe(0)
|
||||
expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
|
||||
expect(host.textContent).toBe('纯文本,无引用')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue