From 15ef747cc0625ed52fc841b92483f38d91bb86fe Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 13:44:26 +0800
Subject: [PATCH 1/4] feat(ui-conversation): implement finalized IconActions
with clock for user and assistant messages
---
...b-message-icon-actions-and-clock.i18n.yaml | 6 ++
...7-29-web-message-icon-actions-and-clock.md | 25 +++++++
...9-web-message-icon-actions-and-clock.zh.md | 25 +++++++
.../client/ui-conversation/README.i18n.yaml | 4 +-
packages/client/ui-conversation/README.md | 2 +-
packages/client/ui-conversation/README.zh.md | 2 +-
.../client/chat/AssistantMarkdown.module.css | 65 ++++++++++++++++-
.../src/client/chat/AssistantMarkdown.tsx | 71 +++++++++++++++----
.../src/client/chat/ChatView.tsx | 10 ++-
.../src/client/chat/MessageItem.module.css | 9 +++
.../src/client/chat/MessageItem.tsx | 46 ++----------
.../src/client/chat/message-chrome.ts | 66 +++++++++++++++++
.../tests/chat-branch-tails.spec.tsx | 67 +++++++++++++++--
13 files changed, 331 insertions(+), 67 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
create mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
create mode 100644 packages/client/ui-conversation/src/client/chat/message-chrome.ts
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%.
From 4aa1aa4a66eff99d48b3cb7808d8130afb309b96 Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 14:30:09 +0800
Subject: [PATCH 2/4] test(web): refresh aria goldens for message IconActions
and clocks
Settled history now exposes user/assistant chrome (including date-aware
clocks) in the accessibility tree; collapse clocks via scaffold and update
keyless scenario goldens.
---
.../snapshots/code-mode-round/ui.expected.md | 13 +++++--
.../cordis-tool-round/ui.expected.md | 23 ++++++++++--
.../snapshots/fresh-round-trip/ui.expected.md | 13 +++++--
.../lifecycle-chrome/reloaded.expected.md | 8 +++--
.../live-interactions/cancel.expected.md | 9 +++--
.../live-interactions/error-auth.expected.md | 2 +-
.../live-interactions/retry.expected.md | 8 +++--
.../snapshots/seeded-history/ui.expected.md | 36 ++++++++++++++-----
.../snapshots/steering/mid-steer.expected.md | 11 +++---
.../snapshots/steering/settled.expected.md | 13 +++++--
10 files changed, 108 insertions(+), 28 deletions(-)
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index 4847924619..8701c6fb19 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
+- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
- button "复制":
- img
- button "在新对话中分支":
@@ -16,6 +16,11 @@
- img
- img
- text: "Think The user wants me to write a single `run_code` program that:"
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- button:
- img
- img
@@ -28,7 +33,11 @@
- img
- text: Think The program ran successfully. Let me now reply DONE as instructed.
- paragraph: DONE
-- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
index e5e5626be3..77038546c0 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."
+- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
- button "复制":
- img
- button "在新对话中分支":
@@ -16,6 +16,11 @@
- img
- img
- text: "Think The user wants me to:"
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- button:
- img
- img
@@ -24,6 +29,11 @@
- img
- img
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- button [expanded]:
- img
- text: Mount temporary Plugin typescript
@@ -33,6 +43,11 @@
- img
- img
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- button:
- img
- img
@@ -42,7 +57,11 @@
- img
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
- paragraph: CORDIS_UI_DONE
-- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps
- textbox "Message the agent"
- button "Add attachment":
- img
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index 6a827420c4..7ea4c95e2b 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
+- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
- button "复制":
- img
- button "在新对话中分支":
@@ -16,6 +16,11 @@
- img
- img
- text: Think The user wants me to run a simple bash command and reply with "DONE".
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- img
- text: Bash Echo the test string
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
@@ -23,7 +28,11 @@
- img
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
- paragraph: DONE
-- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index 33d1f7e6bf..17f79759ca 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: Reply with the single word LIGHTHOUSE and stop.
+- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
- button "复制":
- img
- button "在新对话中分支":
@@ -17,7 +17,11 @@
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
-- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index 3d092b17ec..665221e6b4 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: Reply with a one-sentence description of event sourcing, then stop.
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "复制":
- img
- button "在新对话中分支":
@@ -13,7 +13,12 @@
- button "编辑":
- img
- paragraph: partial
-- text: 已停止 0 tokens · 1 turns · 1 steps
+- text: 已停止
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} 0 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
index 5272bcf2d1..27b4a6c1c1 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: Reply with a one-sentence description of event sourcing, then stop.
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "复制":
- img
- button "在新对话中分支":
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 5935872557..9e051feea3 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: Reply with a one-sentence description of event sourcing, then stop.
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
- button "复制":
- img
- button "在新对话中分支":
@@ -17,7 +17,11 @@
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
-- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps
- textbox "Message the agent"
- button "Add attachment":
- img
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index 4f9181f702..db71bd6696 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -1,32 +1,50 @@
- banner:
- navigation "Session hierarchy":
- button "Use the read tool twice" [disabled]
- - text: · 1 turns
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- button "编辑":
+ - img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
+ - img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- button:
+- button "复制":
- img
-- text: Read a.txt
-- button:
+- button "在新对话中分支":
- img
-- text: Read b.txt
+- text: {{clock}}
+- img
+- text: Read
+- button "a.txt"
+- img
+- text: Read
+- button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
+ - img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
-- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
+- text: Danger Full Access
- combobox "Access mode":
- - option "Read-only" [selected]
- - option "Read-write"
+ - option "Read Only"
+ - option "Workspace Write"
+ - option "Danger Full Access" [selected]
- button "选择模型,当前 deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md
index 8d33ea6283..87ed907abd 100644
--- a/apps/web/tests/snapshots/steering/mid-steer.expected.md
+++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "复制":
- img
- button "在新对话中分支":
@@ -16,12 +16,15 @@
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- button:
- img
- img
-- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)"
-- button "▸ 问题内容"
-- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps
+- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} cache hit 98% · 7,946 tokens · 1 turns · 1 steps"
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index f08fc518e8..7a2f9ae0e6 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -5,7 +5,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
-- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "复制":
- img
- button "在新对话中分支":
@@ -16,6 +16,11 @@
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}}
- button:
- img
- img
@@ -25,7 +30,11 @@
- img
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
- paragraph: Great, let's move forward. BANANA!
-- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps
+- button "复制":
+ - img
+- button "在新对话中分支":
+ - img
+- text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
From 972b3f3a30fa587ac85603b8a0611d0d86c92b5a Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 14:47:26 +0800
Subject: [PATCH 3/4] fix(ui-conversation): document writeClipboard @param for
export JSDoc gate
---
.../client/ui-conversation/src/client/chat/message-chrome.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts
index 6e912c3e14..cf376473d0 100644
--- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts
+++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts
@@ -1,7 +1,10 @@
// 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). */
+/**
+ * Best-effort clipboard write; rejections stay swallowed (no success chrome).
+ * @param text - Plain text to place on the clipboard.
+ */
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.
From c8ea9a5204e9f8371d73e32fbda0ff957b6340bc Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 15:11:06 +0800
Subject: [PATCH 4/4] fix(ui-conversation): share MessageIconActions to clear
jscpd clone
User and assistant chrome both rendered copy/branch buttons; one shared
row owns the chrome and keeps clock placement / edit as props.
---
.../client/chat/AssistantMarkdown.module.css | 42 +-----------
.../src/client/chat/AssistantMarkdown.tsx | 40 ++++--------
.../client/chat/MessageIconActions.module.css | 53 +++++++++++++++
.../src/client/chat/MessageIconActions.tsx | 65 +++++++++++++++++++
.../src/client/chat/MessageItem.module.css | 43 +-----------
.../src/client/chat/MessageItem.tsx | 46 +++----------
6 files changed, 142 insertions(+), 147 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css
create mode 100644 packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx
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 24bc4c0e49..d988cf52f8 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
@@ -28,55 +28,17 @@
line-height: 18px;
}
-/* Finalized footer: copy / branch / clock (figma 43:32997). */
+/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
.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). */
+/* Hover-capable pointers: reveal shared actions on root hover/focus. */
@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 a3a05b7af9..e5a89a9e88 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -6,14 +6,12 @@
// 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, useCallback } from 'react'
+import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import {
- IconBranchOutline16, IconCopyOutline16, IconThinkOutline14,
- JsonBlock, MarkdownText, Tooltip,
+ IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
-import { formatMessageClock, writeClipboard } from './message-chrome.ts'
-import { useCalendarDay } from './use-calendar-day.ts'
+import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './AssistantMarkdown.module.css'
@@ -55,29 +53,6 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
)
}
-/** Finalized assistant IconActions (figma 43:32997): copy live; branch stub; clock. */
-function AssistantActions({ text, time }: { text: string; time: number }) {
- const day = useCalendarDay()
- const onCopy = useCallback(() => {
- void writeClipboard(text)
- }, [text])
- return (
-