DSH / Atlas
2026-07-29implementedfeaturearchived 2026-08-07

Web message IconActions and clocks

Web 消息 IconActions 与时钟

The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token. Memoized rows also keep stable props across midnight, so a one-shot `Date.now()` would leave yesterday's messages stuck

English

Problem

The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token. Memoized rows also keep stable props across midnight, so a one-shot Date.now() would leave yesterday's messages stuck on HH:mm.

Decision

User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with margin-top: 16px; both seats stay visible whenever mounted and re-format at the next local midnight.

The assistant seat is narrowed by the completed-turn decision: only a turn with a turn/end grants it, so a turn still producing steps hands the row to nothing. The user seat's branch control is removed outright by the user-bubble branch removal; a user row's IconActions are clock and copy.

Both seats format node.time through formatMessageClock: same calendar day → HH:mm, earlier this year → M月D日 HH:mm, other years → YYYY年M月D日 HH:mm. useCalendarDay is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. MessageItem places the label before copy (figma 388:20051). ChatView derives turn-tail seqs via assistantActionsSeqs and withholds time for mid-turn content; AssistantMarkdown places the row after branch (figma 43:32997) only when streaming is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Both message rows pass their event's seq to the same fork callback; Web session fork actions define the real mutation contract. Clipboard write and the clock helpers live in message-chrome.ts. The assembled surface is pinned by apps/web/tests/message-actions.e2e.ts (cold-seeded history + aria golden); aria normalization collapses every clock shape to {{clock}}.

Alternatives considered

Show assistant IconActions during streaming. Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer.

Put IconActions under every finalized assistant node (including Think-only). Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat.

Put IconActions under every content-text assistant in a multi-step turn. Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of the turn owns the seat.

Hover-reveal the action row on hover-capable pointers. Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate.

Let the IconActions decision also define session fork semantics. Rejected: this note owns only message chrome, clocks, and mount gating; boundary selection, failure behavior, and switching semantics belong to the separate Web session fork actions, keeping presentation components from becoming a second home for session mutation.

Publish the calendar day through a chat store or inject hook. Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source.

Consequences

Each turn's last settled content answer exposes copy, branch, and the event clock as soon as the row mounts; mid-turn content and Think-only nodes stay chrome-free. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, the content-only assistant gate, the turn-tail seq gate, and the respective event seq values passed by the user and assistant branch buttons; the web e2e scenario pins the assembled IconActions chrome.

中文

问题

Web 聊天的用户气泡已有复制、分支、编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 harness 设计稿在回答结束后展示复制、分支、时钟。流式回复不得在逐 token 输出期间闪现该操作栏。经 memo 优化的行在跨午夜时仍保持稳定 props,因此一次性的 Date.now() 会让昨日消息一直卡在 HH:mm

决策

用户气泡在既有 IconActions 行的开头添加感知日期的本地时钟;每个轮次中最后一条带 text 内容的 assistant 在正文下追加带 margin-top: 16px 的复制、分支、时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。

assistant 一侧的座位由已完成轮次决策收紧:只有存在 turn/end 的轮次才授予该行,仍在产出步骤的轮次不把该行交给任何节点。user 一侧的分支控件被 user 气泡分支移除决策直接移除;user 行的 IconActions 只有时钟与复制。

两边都通过 formatMessageClock 格式化 node.time:同一日历日 → HH:mm,同年更早 → M月D日 HH:mm,跨年 → YYYY年M月D日 HH:mmuseCalendarDay 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架钩子。MessageItem 把标签放在复制之前(figma 388:20051)。ChatView 通过 assistantActionsSeqs 推导轮次尾部的 seq,并不为轮次中间的内容传入 timeAssistantMarkdown 把该行放在分支之后(figma 43:32997),且仅在 streaming 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、轮次中间的叙述与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 seq 交给同一个 fork 回调;真实 mutation 契约由 Web session fork 操作定义。剪贴板写入与时钟辅助函数放在 message-chrome.ts。组装后的界面由 apps/web/tests/message-actions.e2e.ts(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 {{clock}}

曾考虑的方案

在流式过程中展示 assistant IconActions。 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。

给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。

给多步骤轮次中的每一条带 text 内容的 assistant 都挂 IconActions。 否决:轮次中间的叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制、分支、时钟会打乱流程。只有该轮次中最后一条内容 assistant 拥有该座位。

在具备 hover 能力的指针上用 hover 才揭示操作行。 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。

由 IconActions 决策同时定义 session fork 语义。 否决:本笔记只拥有消息 chrome、时钟与挂载门控;边界选择、失败行为和切换语义属于独立的 Web session fork 操作,避免展示组件成为 session mutation 的第二正家。

通过 chat store 或 inject 钩子发布日历日。 否决:日刻度只是展示层本地状态,没有跨入口消费方;组件本地 timeout 符合「行为钩子可拥有不订阅外部源的状态」这一客户端规则。

后果

每个轮次中最后一条已定稿的内容回答在行挂载后立刻暴露复制、分支与事件时钟;轮次中间的内容与纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天、跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中记录的暂缓 footer 功能位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控、轮次尾部 seq 门控,以及 user/assistant 分支按钮各自传递的事件 seq;Web e2e 场景钉住组装后的 IconActions chrome。