Просмотр исходного кода

feat(ui-conversation): implement finalized IconActions with clock for user and assistant messages

07akioni 2 месяцев назад
Родитель
Сommit
15ef747cc0

+ 6 - 0
.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
+2026-07-29-web-message-icon-actions-and-clock.md: ac0c2624d4980f33a01d67cf699259b5913cb274
+2026-07-29-web-message-icon-actions-and-clock.zh.md: 8ec7d8a3d93a51e4a4d315caa067a649ec483120

+ 25 - 0
.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md

@@ -0,0 +1,25 @@
+# Agent Note: Web message IconActions and clocks
+
+Status: implemented
+
+English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md)
+
+## 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.
+
+## Decision
+
+**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`.**
+
+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`. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helper live in `message-chrome.ts`.
+
+## 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.
+
+**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
+
+## Consequences
+
+Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules. Per-message paging remains a deferred footer seat in the package README. Tests pin the three clock shapes, assistant footer presence only when not streaming, and copy payload (text blocks only).

+ 25 - 0
.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Web 消息 IconActions 与时钟
+
+Status: implemented
+
+[English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文
+
+## 问题
+
+Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。
+
+## 决策
+
+**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟。**
+
+两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。
+
+## 曾考虑的方案
+
+**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。
+
+**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
+
+## 后果
+
+已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则。逐消息分页仍是包 README 中的暂缓 footer 座位。测试钉住三种时钟形态、仅在非流式时出现 assistant footer,以及复制载荷(仅 text 块)。

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051
-README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d
+README.md: 5f5708042639a8c8d87b8f09c8e06a2c87bc7117
+README.zh.md: fd6f8603d056112d62edf18ecfd1bcd3721b1c84

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -32,7 +32,7 @@ None; this package neither assembles nor sends a provider request.
 
 - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
 - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
-- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
+- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch) ships; branch remains a chrome stub.
 - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
 - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
 - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -32,7 +32,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 
 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
 - **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
-- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
+- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支)已落地;分支仍是 chrome stub。
 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。

+ 63 - 2
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css

@@ -1,14 +1,22 @@
-/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
+/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
+   IconActions sit below the body with an explicit 16px top margin (figma
+   43:32997) — separate from the body's internal gap so the footer spacing
+   stays fixed when the body is a single block. */
 
 .root {
   display: flex;
   flex-direction: column;
-  gap: 16px;
   font-size: 16px;
   line-height: 28px;
   color: var(--dsw-alias-label-primary);
 }
 
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
 /* Interrupted-turn terminal marker: quiet inline tag, no animation. */
 .stopped {
   align-self: flex-start;
@@ -19,3 +27,56 @@
   font-size: 11px;
   line-height: 18px;
 }
+
+/* Finalized footer: copy / branch / clock (figma 43:32997). */
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  height: 28px;
+  margin-top: 16px;
+  /* Optical align with 28px icon hit targets that pad 6px past the glyph. */
+  margin-left: -6px;
+}
+
+/* Clock after the icon buttons; pl 12 separates it from branch. */
+.time {
+  padding-left: 12px;
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}
+
+/* Hover-capable pointers: hide until the root is hovered/focused. Touch /
+   hover:none keeps actions visible (opacity:0 still hit-tests). */
+@media (hover: hover) {
+  .actions {
+    opacity: 0;
+    transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
+  }
+
+  .root:hover .actions,
+  .root:focus-within .actions {
+    opacity: 1;
+  }
+}
+
+.action {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
+  border: none;
+  border-radius: 28px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.action:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
+}

+ 58 - 13
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx

@@ -4,10 +4,15 @@
 // view groups them into tool rows through its keyed toolview slot (figma
 // step-summary flow). Shared by finalized nodes and the streaming partial;
 // the turn-level loading dots live in the chat view's tail, not here.
+// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
 
-import { memo } from 'react'
+import { memo, useCallback } from 'react'
 import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
-import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import {
+  IconBranchOutline16, IconCopyOutline16, IconThinkOutline14,
+  JsonBlock, MarkdownText, Tooltip,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import { formatMessageClock, writeClipboard } from './message-chrome.ts'
 import { ToolRow } from './ToolRow.tsx'
 import css from './AssistantMarkdown.module.css'
 
@@ -16,6 +21,8 @@ export interface AssistantMarkdownProps {
   streaming: boolean
   /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
   interrupted?: boolean | undefined
+  /** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
+  time?: number | undefined
 }
 
 function firstLine(text: string): string {
@@ -23,6 +30,15 @@ function firstLine(text: string): string {
   return nl === -1 ? text : text.slice(0, nl)
 }
 
+/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
+function copyText(blocks: readonly AssistantBlock[]): string {
+  const parts: string[] = []
+  for (const block of blocks) {
+    if (block.kind === 'text') parts.push(block.text)
+  }
+  return parts.join('')
+}
+
 /** Reasoning block as the Think variant summary row (figma 39:28304). */
 function ThinkRow({ text, running }: { text: string; running: boolean }) {
   return (
@@ -38,7 +54,31 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
   )
 }
 
-export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
+/** Finalized assistant IconActions (figma 43:32997): copy live; branch stub; clock. */
+function AssistantActions({ text, time }: { text: string; time: number }) {
+  const onCopy = useCallback(() => {
+    void writeClipboard(text)
+  }, [text])
+  return (
+    <div className={css.actions}>
+      <Tooltip label="复制" side="bottom">
+        <button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
+          <IconCopyOutline16 />
+        </button>
+      </Tooltip>
+      <Tooltip label="在新对话中分支" side="bottom">
+        <button type="button" className={css.action} aria-label="在新对话中分支">
+          <IconBranchOutline16 />
+        </button>
+      </Tooltip>
+      <span className={css.time}>{formatMessageClock(time)}</span>
+    </div>
+  )
+}
+
+export const AssistantMarkdown = memo(function AssistantMarkdown({
+  blocks, streaming, interrupted, time,
+}: AssistantMarkdownProps) {
   const last = blocks.length - 1
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // a node that is only those heads (or empty) would paint an empty root
@@ -47,18 +87,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
     || interrupted === true
     || blocks.some(block => block.kind !== 'tool-call')
   if (!hasVisible) return null
+  // Footer only after the turn settles with a known event time; streaming omits it.
+  const showActions = !streaming && time !== undefined
   return (
     <div className={css.root} data-streaming={streaming || undefined}>
-      {blocks.map((block, i) => {
-        switch (block.kind) {
-          case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
-          case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
-          // Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
-          case 'tool-call': return null
-          default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
-        }
-      })}
-      {interrupted && <span className={css.stopped}>已停止</span>}
+      <div className={css.body}>
+        {blocks.map((block, i) => {
+          switch (block.kind) {
+            case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
+            case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
+            // Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
+            case 'tool-call': return null
+            default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
+          }
+        })}
+        {interrupted && <span className={css.stopped}>已停止</span>}
+      </div>
+      {showActions && <AssistantActions text={copyText(blocks)} time={time} />}
     </div>
   )
 })

+ 9 - 1
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -332,7 +332,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
     }
     const node: ConversationNode = item.node
     if (node.kind === 'assistant') {
-      return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
+      return (
+        <AssistantMarkdown
+          key={item.key}
+          blocks={node.blocks}
+          streaming={false}
+          interrupted={node.interrupted}
+          time={node.time}
+        />
+      )
     }
     if (node.kind === 'command') {
       return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />

+ 9 - 0
packages/client/ui-conversation/src/client/chat/MessageItem.module.css

@@ -27,6 +27,15 @@
   height: 28px;
 }
 
+/* Clock before the icon buttons (figma 388:20051); pr 12 separates it from copy. */
+.time {
+  padding-right: 12px;
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}
+
 /* Hover-capable pointers: hide until the row is hovered/focused. Touch /
    hover:none keeps actions visible (opacity:0 still hit-tests). */
 @media (hover: hover) {

+ 6 - 40
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -1,5 +1,5 @@
 // MessageItem: the four simple node kinds — user bubble (right-aligned, with
-// copy / branch / edit IconActions), steering (badged bubble), context
+// clock + copy / branch / edit IconActions), steering (badged bubble), context
 // injection and unknown-surface JSON rows. Props are frozen node slices off
 // the snapshot cache; memo holds across streaming because unchanged nodes
 // keep their references.
@@ -13,6 +13,7 @@ import {
   IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
   JsonBlock, MessageText, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { formatMessageClock, writeClipboard } from './message-chrome.ts'
 import css from './MessageItem.module.css'
 
 export interface MessageItemProps {
@@ -30,42 +31,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
   return { text: texts.join(''), rest }
 }
 
-/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
-async function writeClipboard(text: string): Promise<void> {
-  // lib.dom types clipboard non-optional, but insecure contexts omit it —
-  // that runtime gap is exactly what this guard detects.
-  /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
-  if (navigator.clipboard?.writeText) {
-    try {
-      await navigator.clipboard.writeText(text)
-    } catch {
-      // Denied permissions / iframe policy.
-    }
-    return
-  }
-  // execCommand('copy') is the only clipboard fallback where the async API
-  // is missing (insecure contexts); deprecated but deliberately retained.
-  /* eslint-disable @typescript-eslint/no-deprecated */
-  const exec = typeof document.execCommand === 'function'
-    ? document.execCommand.bind(document)
-    : undefined
-  if (exec === undefined) return
-  const el = document.createElement('textarea')
-  el.value = text
-  el.setAttribute('readonly', '')
-  el.style.position = 'fixed'
-  el.style.left = '-9999px'
-  document.body.appendChild(el)
-  el.select()
-  try {
-    exec('copy')
-  } catch {
-    // Clipboard unavailable; the button stays idle.
-  }
-  /* eslint-enable @typescript-eslint/no-deprecated */
-  el.remove()
-}
-
 /**
  * Display projection of reference forms in a user bubble (free geometry — no
  * textarea alignment constraint here); everything else stays plain text. The
@@ -98,13 +63,14 @@ function projectUserText(text: string): ReactNode {
   return <>{parts}</>
 }
 
-/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
-function UserActions({ text }: { text: string }) {
+/** User-bubble IconActions (figma 388:20051): clock + copy live; branch/edit stubs. */
+function UserActions({ text, time }: { text: string; time: number }) {
   const onCopy = useCallback(() => {
     void writeClipboard(text)
   }, [text])
   return (
     <div className={css.actions}>
+      <span className={css.time}>{formatMessageClock(time)}</span>
       <Tooltip label="复制" side="bottom">
         <button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
           <IconCopyOutline16 />
@@ -134,7 +100,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
             {projectUserText(text)}
             {rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
           </div>
-          <UserActions text={text} />
+          <UserActions text={text} time={node.time} />
         </div>
       )
     }

+ 66 - 0
packages/client/ui-conversation/src/client/chat/message-chrome.ts

@@ -0,0 +1,66 @@
+// Shared chrome helpers for user/assistant IconActions rows: clipboard write
+// and the compact date+clock label from a session-event epoch.
+
+/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
+export async function writeClipboard(text: string): Promise<void> {
+  // lib.dom types clipboard non-optional, but insecure contexts omit it —
+  // that runtime gap is exactly what this guard detects.
+  /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
+  if (navigator.clipboard?.writeText) {
+    try {
+      await navigator.clipboard.writeText(text)
+    } catch {
+      // Denied permissions / iframe policy.
+    }
+    return
+  }
+  // execCommand('copy') is the only clipboard fallback where the async API
+  // is missing (insecure contexts); deprecated but deliberately retained.
+  /* eslint-disable @typescript-eslint/no-deprecated */
+  const exec = typeof document.execCommand === 'function'
+    ? document.execCommand.bind(document)
+    : undefined
+  if (exec === undefined) return
+  const el = document.createElement('textarea')
+  el.value = text
+  el.setAttribute('readonly', '')
+  el.style.position = 'fixed'
+  el.style.left = '-9999px'
+  document.body.appendChild(el)
+  el.select()
+  try {
+    exec('copy')
+  } catch {
+    // Clipboard unavailable; the button stays idle.
+  }
+  /* eslint-enable @typescript-eslint/no-deprecated */
+  el.remove()
+}
+
+function pad2(n: number): string {
+  return String(n).padStart(2, '0')
+}
+
+/**
+ * Compact local timestamp for message IconActions.
+ * Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`;
+ * other years → `YYYY年M月D日 HH:mm`.
+ * @param time - Unix epoch ms from the source session event.
+ * @param now - Reference instant for the day/year cut (defaults to wall clock).
+ * @returns Date-aware clock string (24-hour, zero-padded time).
+ */
+export function formatMessageClock(time: number, now: number = Date.now()): string {
+  const d = new Date(time)
+  const n = new Date(now)
+  const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
+  if (
+    d.getFullYear() === n.getFullYear()
+    && d.getMonth() === n.getMonth()
+    && d.getDate() === n.getDate()
+  ) {
+    return clock
+  }
+  const md = `${d.getMonth() + 1}月${d.getDate()}日`
+  if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}`
+  return `${d.getFullYear()}年${md} ${clock}`
+}

+ 60 - 7
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -11,6 +11,7 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import { formatMessageClock } from '../src/client/chat/message-chrome.ts'
 import { MessageItem } from '../src/client/chat/MessageItem.tsx'
 import { PendingCard } from '../src/client/chat/PendingCard.tsx'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
@@ -19,19 +20,24 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
 afterEach(cleanup)
 
 describe('MessageItem arms', () => {
-  it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
+  it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => {
     const writeText = vi.fn().mockResolvedValue(undefined)
     Object.defineProperty(navigator, 'clipboard', {
       configurable: true,
       value: { writeText },
     })
+    // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
+    const now = new Date()
+    const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
     render(
       <MessageItem node={{
-        kind: 'user', seq: 1,
+        kind: 'user', seq: 1, time,
         content: [{ type: 'text', text: 'hello bubble' }] as never,
-      } as never}
+        source: null,
+      }}
       />,
     )
+    expect(screen.getByText('14:24')).toBeTruthy()
     expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
     expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
     expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
@@ -51,9 +57,10 @@ describe('MessageItem arms', () => {
     })
     render(
       <MessageItem node={{
-        kind: 'user', seq: 1,
+        kind: 'user', seq: 1, time: 1_000,
         content: [{ type: 'text', text: 'fallback body' }] as never,
-      } as never}
+        source: null,
+      }}
       />,
     )
     fireEvent.click(screen.getByRole('button', { name: '复制' }))
@@ -73,9 +80,10 @@ describe('MessageItem arms', () => {
     })
     render(
       <MessageItem node={{
-        kind: 'user', seq: 1,
+        kind: 'user', seq: 1, time: 1_000,
         content: [{ type: 'text', text: 'quiet' }] as never,
-      } as never}
+        source: null,
+      }}
       />,
     )
     fireEvent.click(screen.getByRole('button', { name: '复制' }))
@@ -113,6 +121,22 @@ describe('MessageItem arms', () => {
   })
 })
 
+describe('formatMessageClock', () => {
+  const now = new Date(2026, 6, 29, 10, 0).getTime()
+
+  it('keeps HH:mm on the same calendar day', () => {
+    expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24')
+  })
+
+  it('prefixes month and day across days in the same year', () => {
+    expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24')
+  })
+
+  it('prefixes year, month, and day across years', () => {
+    expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05')
+  })
+})
+
 describe('small branch tails', () => {
   it('PendingCard approval reason renders when present', () => {
     const view = render(
@@ -128,6 +152,35 @@ describe('small branch tails', () => {
     expect(view.getByText('one-liner')).toBeTruthy()
   })
 
+  it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText },
+    })
+    const now = new Date()
+    const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
+    const settled = render(
+      <AssistantMarkdown
+        blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
+        streaming={false}
+        time={time}
+      />,
+    )
+    expect(settled.getByText('14:24')).toBeTruthy()
+    expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
+    expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
+    fireEvent.click(settled.getByRole('button', { name: '复制' }))
+    expect(writeText).toHaveBeenCalledWith('answer body')
+    settled.unmount()
+
+    const streaming = render(
+      <AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
+    )
+    expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
+    expect(streaming.queryByText('14:24')).toBeNull()
+  })
+
   it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
     // cacheHitPct is null only when input+cacheRead are both zero (pure
     // output accounting) — any input makes it a real 0%.