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..e0fc203ce6 --- /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: e0072458e4c0d3e37998b5564ad14ce17aa41515 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9 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..e0072458e4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -0,0 +1,27 @@ +# 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. Memoized rows also keep stable props across midnight, so a one-shot `Date.now()` would leave yesterday's messages stuck on `HH:mm`. + +## Decision + +**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.** + +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `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 helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. + +## Alternatives considered + +**Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer. + +**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. + +**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source. + +## Consequences + +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 and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome. 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..1cc25a9656 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Web 消息 IconActions 与时钟 + +Status: implemented + +[English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文 + +## 问题 + +Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。经 memo 的行在跨午夜时仍保持稳定 props,因此一次性的 `Date.now()` 会让昨日消息一直卡在 `HH:mm`。 + +## 决策 + +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。** + +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 + +## 曾考虑的方案 + +**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。 + +**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 + +**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 + +## 后果 + +已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts new file mode 100644 index 0000000000..a453d53cbc --- /dev/null +++ b/apps/web/tests/message-actions.e2e.ts @@ -0,0 +1,93 @@ +// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history +// fixture (zero model calls) and pins the settled conversation aria after the +// user/assistant footers are focus-revealed — the surface package jsdom tests +// cannot substitute for (docs/testing.md snapshot rule). +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) +// Borrowed read-only: this scenario needs any settled user+assistant pair, not +// a new recording (workspace-management / sidebar-scrollbar pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'message-actions-web-e2e' + +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' + +describe('web e2e: message IconActions and clocks on settled history', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const sessionCwd = join(scaffold.workspaceCwd, 'workspace') + await mkdir(sessionCwd, { recursive: true }) + await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') + await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') + const raw = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + await seedSession(scaffold, raw, SEED_ID) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Focus-reveal the footers (hover:hover keeps them opacity-hidden until + // hover/focus-within). User has three actions; each finalized assistant + // text node has copy + branch. + const copyButtons = page.getByRole('button', { name: '复制' }) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await copyButtons.first().focus() + await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(2) + await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1) + }, 60_000) + + it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) + await page.getByRole('button', { + name: '选择模型,当前 deepseek-v4-flash', + }).waitFor({ timeout: 10_000 }) + // Keep a footer focused so opacity-hidden actions stay in the a11y tree + // as an active/focused control during the capture. + await page.getByRole('button', { name: '复制' }).first().focus() + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f722ead61b..753cdc2953 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -389,6 +389,11 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}') + // Message IconActions clocks widen by calendar day/year; collapse every + // shape so goldens stay stable across midnight and year boundaries. + .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/(? 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 1cbb00a4ce..0f6b3dc5ae 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -330,7 +330,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/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css new file mode 100644 index 0000000000..30d6920609 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -0,0 +1,53 @@ +/* Shared message IconActions row (user + assistant). Parent modules own + hover-reveal selectors and layout offsets via the composed className. */ + +.actions { + display: flex; + align-items: center; + gap: 10px; + height: 28px; +} + +/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */ +.timeStart { + padding-right: 12px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +.timeEnd { + padding-left: 12px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */ +@media (hover: hover) { + .actions { + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); + } +} + +.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/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx new file mode 100644 index 0000000000..7579a4c249 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -0,0 +1,65 @@ +// Shared IconActions chrome for user and assistant messages: copy / branch +// live (branch still a stub), date-aware clock, optional edit stub. + +import { useCallback } from 'react' +import { + IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { formatMessageClock, writeClipboard } from './message-chrome.ts' +import { useCalendarDay } from './use-calendar-day.ts' +import css from './MessageIconActions.module.css' + +export interface MessageIconActionsProps { + /** Plain text the copy action writes. */ + text: string + /** Unix epoch ms for the clock label. */ + time: number + /** Clock before icons (user) or after (assistant). */ + clock: 'start' | 'end' + /** When true, append the stub edit control (user bubble). */ + edit?: boolean | undefined + /** Parent layout / hover-reveal class composed onto the actions row. */ + className?: string | undefined +} + +/** + * Copy / branch (/ clock) IconActions row shared by user and assistant chrome. + * @param props - Copy text, event time, clock side, optional edit, className. + * @returns The actions row element. + */ +export function MessageIconActions({ + text, time, clock, edit, className, +}: MessageIconActionsProps) { + const day = useCalendarDay() + const onCopy = useCallback(() => { + void writeClipboard(text) + }, [text]) + const clockEl = ( + + {formatMessageClock(time, day)} + + ) + return ( +
+ {clock === 'start' ? clockEl : null} + + + + + + + {edit === true && ( + + + + )} + {clock === 'end' ? clockEl : null} +
+ ) +} 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..260382d530 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -20,46 +20,14 @@ color: var(--dsw-alias-label-primary); } -.actions { - display: flex; - align-items: center; - gap: 10px; - height: 28px; -} - -/* Hover-capable pointers: hide until the row is hovered/focused. Touch / - hover:none keeps actions visible (opacity:0 still hit-tests). */ +/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */ @media (hover: hover) { - .actions { - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); - } - .userRow:hover .actions, .userRow: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); -} - .badge { display: inline-block; margin-bottom: 4px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 571104dac8..a149d37337 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,18 +1,16 @@ // 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. -import { memo, useCallback } from 'react' +import { memo } from 'react' import type { ReactNode } from 'react' import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { - IconBranchOutline16, IconCopyOutline16, IconEditOutline16, - JsonBlock, MessageText, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' +import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -30,42 +28,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,32 +60,6 @@ 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 }) { - const onCopy = useCallback(() => { - void writeClipboard(text) - }, [text]) - return ( -
- - - - - - - - - -
- ) -} - export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { case 'user': { @@ -134,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {projectUserText(text)} {rest.map((block, i) => )} - + ) } 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..cf376473d0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -0,0 +1,91 @@ +// 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). + * @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. + /* 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') +} + +/** + * Local calendar-day epoch (ms at local midnight) for an instant. + * @param ms - Unix epoch ms. + * @returns Midnight of that local calendar day. + */ +export function startOfLocalDay(ms: number): number { + const d = new Date(ms) + d.setHours(0, 0, 0, 0) + return d.getTime() +} + +/** + * Delay until the next local midnight after `ms` (at least 1ms). + * @param ms - Unix epoch ms. + * @returns Milliseconds until the following local midnight. + */ +export function msUntilNextLocalMidnight(ms: number): number { + const next = new Date(ms) + next.setHours(24, 0, 0, 0) + return Math.max(next.getTime() - ms, 1) +} + +/** + * 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/src/client/chat/use-calendar-day.ts b/packages/client/ui-conversation/src/client/chat/use-calendar-day.ts new file mode 100644 index 0000000000..29e5311383 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/use-calendar-day.ts @@ -0,0 +1,25 @@ +// Component-local calendar-day tick: memoized message rows keep stable props +// across midnight, so the IconActions clock needs a local day seat that +// re-fires at the next local midnight without reaching for framework hooks. + +import { useEffect, useState } from 'react' +import { msUntilNextLocalMidnight, startOfLocalDay } from './message-chrome.ts' + +/** + * Local calendar-day epoch that advances at each local midnight. + * @returns Midnight ms for the current local day; updates after the boundary. + */ +export function useCalendarDay(): number { + const [day, setDay] = useState(() => startOfLocalDay(Date.now())) + useEffect(() => { + let timer: ReturnType + const arm = (): void => { + const now = Date.now() + setDay(startOfLocalDay(now)) + timer = setTimeout(arm, msUntilNextLocalMidnight(now)) + } + timer = setTimeout(arm, msUntilNextLocalMidnight(Date.now())) + return () => { clearTimeout(timer) } + }, []) + return day +} 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 9fb33d1d24..9bb6ba539a 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -5,9 +5,12 @@ // with the keyed-slot machinery specs since the tool ring dissolved into // renderSlot.) -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { + formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, +} from '../src/client/chat/message-chrome.ts' import { MessageItem } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' @@ -15,19 +18,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() @@ -47,9 +55,10 @@ describe('MessageItem arms', () => { }) render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) @@ -69,9 +78,10 @@ describe('MessageItem arms', () => { }) render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) @@ -109,6 +119,56 @@ 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') + }) + + it('arms the next local midnight from an in-day instant', () => { + const noon = new Date(2026, 6, 29, 12, 0).getTime() + expect(startOfLocalDay(noon)).toBe(new Date(2026, 6, 29).getTime()) + expect(msUntilNextLocalMidnight(noon)).toBe(12 * 3_600_000) + }) +}) + +describe('useCalendarDay boundary refresh', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('widens a same-day user clock after local midnight', () => { + const dayStart = new Date(2026, 6, 29, 23, 50).getTime() + vi.setSystemTime(dayStart) + const time = new Date(2026, 6, 29, 14, 24).getTime() + render( + , + ) + expect(screen.getByText('14:24')).toBeTruthy() + act(() => { + vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1) + }) + expect(screen.getByText('7月29日 14:24')).toBeTruthy() + }) +}) + describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( @@ -117,6 +177,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%. diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index d1bd4b9a2d..81c42b8a56 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -211,4 +211,3 @@ export const sessionCancelRequestSchema = z.object({ export const sessionCancelValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> - diff --git a/tsconfig.host.json b/tsconfig.host.json index 599931c2d6..2287d21a9a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -23,6 +23,7 @@ "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", + "apps/web/tests/message-actions.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",