diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml new file mode 100644 index 0000000000..1800464cd2 --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md new file mode 100644 index 0000000000..ac0c2624d4 --- /dev/null +++ b/.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). diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md new file mode 100644 index 0000000000..8ec7d8a3d9 --- /dev/null +++ b/.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 块)。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 49e43861f3..a33f296d5a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 85cf040a48..5f57080426 100644 --- a/packages/client/ui-conversation/README.md +++ b/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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 305258e286..fd6f8603d0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/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 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index 9177d721c3..24bc4c0e49 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/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); +} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index e3573b14f5..68f5d5e179 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/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 ( +
+ + + + + + + {formatMessageClock(time)} +
+ ) +} + +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 (
- {blocks.map((block, i) => { - switch (block.kind) { - case 'text': return - case 'reasoning': return - // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. - case 'tool-call': return null - default: return - } - })} - {interrupted && 已停止} +
+ {blocks.map((block, i) => { + switch (block.kind) { + case 'text': return + case 'reasoning': return + // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. + case 'tool-call': return null + default: return + } + })} + {interrupted && 已停止} +
+ {showActions && }
) }) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index e9503b5677..71ee48bbc6 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/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 + return ( + + ) } if (node.kind === 'command') { return diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 22537f9cfe..a2c14fdc7a 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/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) { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 571104dac8..b942937f47 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/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 { - // 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 (
+ {formatMessageClock(time)}
- + ) } diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts new file mode 100644 index 0000000000..641399e093 --- /dev/null +++ b/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 { + // 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}` +} diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bf1266981e..1219b8b4cb 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/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( , ) + 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( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) @@ -73,9 +80,10 @@ describe('MessageItem arms', () => { }) render( , ) 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( + , + ) + 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( + , + ) + 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%.