Merge pull request #880 from deepseek-harness/feat/agent-action
feat(ui-conversation): implement finalized IconActions with clock for user and assistant messages
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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。
|
||||||
93
apps/web/tests/message-actions.e2e.ts
Normal file
93
apps/web/tests/message-actions.e2e.ts
Normal file
@@ -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<typeof watchConsole>
|
||||||
|
|
||||||
|
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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -389,6 +389,11 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
|||||||
.split(base).join('{{workspace}}')
|
.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(/[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}}')
|
.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(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: "Think The user wants me to write a single `run_code` program that:"
|
- text: "Think The user wants me to write a single `run_code` program that:"
|
||||||
|
- button "复制":
|
||||||
|
- img
|
||||||
|
- button "在新对话中分支":
|
||||||
|
- img
|
||||||
|
- text: {{clock}}
|
||||||
- button:
|
- button:
|
||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
@@ -28,7 +33,11 @@
|
|||||||
- img
|
- img
|
||||||
- text: Think The program ran successfully. Let me now reply DONE as instructed.
|
- text: Think The program ran successfully. Let me now reply DONE as instructed.
|
||||||
- paragraph: DONE
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: "Think The user wants me to:"
|
- text: "Think The user wants me to:"
|
||||||
|
- button "复制":
|
||||||
|
- img
|
||||||
|
- button "在新对话中分支":
|
||||||
|
- img
|
||||||
|
- text: {{clock}}
|
||||||
- button:
|
- button:
|
||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
@@ -24,6 +29,11 @@
|
|||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
|
- 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]:
|
- button [expanded]:
|
||||||
- img
|
- img
|
||||||
- text: Mount temporary Plugin typescript
|
- text: Mount temporary Plugin typescript
|
||||||
@@ -33,6 +43,11 @@
|
|||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
|
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
|
||||||
|
- button "复制":
|
||||||
|
- img
|
||||||
|
- button "在新对话中分支":
|
||||||
|
- img
|
||||||
|
- text: {{clock}}
|
||||||
- button:
|
- button:
|
||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
@@ -42,7 +57,11 @@
|
|||||||
- img
|
- img
|
||||||
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
|
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
|
||||||
- paragraph: CORDIS_UI_DONE
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
||||||
|
- button "复制":
|
||||||
|
- img
|
||||||
|
- button "在新对话中分支":
|
||||||
|
- img
|
||||||
|
- text: {{clock}}
|
||||||
- img
|
- img
|
||||||
- text: Bash Echo the test string
|
- text: Bash Echo the test string
|
||||||
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
||||||
@@ -23,7 +28,11 @@
|
|||||||
- img
|
- img
|
||||||
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
|
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
|
||||||
- paragraph: 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- tab "Waterfall"
|
||||||
- text: Reply with the single word LIGHTHOUSE and stop.
|
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||||
- button "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -17,7 +17,11 @@
|
|||||||
- img
|
- img
|
||||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||||
- paragraph: LIGHTHOUSE
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -13,7 +13,12 @@
|
|||||||
- button "编辑":
|
- button "编辑":
|
||||||
- img
|
- img
|
||||||
- paragraph: partial
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -17,7 +17,11 @@
|
|||||||
- img
|
- 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.
|
- 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.
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
52
apps/web/tests/snapshots/message-actions/ui.expected.md
Normal file
52
apps/web/tests/snapshots/message-actions/ui.expected.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
- banner:
|
||||||
|
- navigation "Session hierarchy":
|
||||||
|
- button "Use the read tool twice" [disabled]
|
||||||
|
- 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. {{clock}}"
|
||||||
|
- button "复制":
|
||||||
|
- img
|
||||||
|
- tooltip "复制"
|
||||||
|
- 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 "复制":
|
||||||
|
- img
|
||||||
|
- button "在新对话中分支":
|
||||||
|
- img
|
||||||
|
- 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
|
||||||
|
- 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"
|
||||||
|
- option "Workspace Write"
|
||||||
|
- option "Danger Full Access" [selected]
|
||||||
|
- button "选择模型,当前 deepseek-v4-flash":
|
||||||
|
- text: deepseek-v4-flash
|
||||||
|
- img
|
||||||
|
- button "Send message" [disabled]
|
||||||
@@ -1,32 +1,50 @@
|
|||||||
- banner:
|
- banner:
|
||||||
- navigation "Session hierarchy":
|
- navigation "Session hierarchy":
|
||||||
- button "Use the read tool twice" [disabled]
|
- button "Use the read tool twice" [disabled]
|
||||||
- text: · 1 turns
|
|
||||||
- tablist:
|
- tablist:
|
||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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.":
|
- 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
|
- 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.
|
- 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
|
- img
|
||||||
- text: Read a.txt
|
- button "在新对话中分支":
|
||||||
- button:
|
|
||||||
- img
|
- 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.":
|
- 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
|
- 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.
|
- 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
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
- text: Danger Full Access
|
||||||
- combobox "Access mode":
|
- combobox "Access mode":
|
||||||
- option "Read-only" [selected]
|
- option "Read Only"
|
||||||
- option "Read-write"
|
- option "Workspace Write"
|
||||||
|
- option "Danger Full Access" [selected]
|
||||||
- button "选择模型,当前 deepseek-v4-flash":
|
- button "选择模型,当前 deepseek-v4-flash":
|
||||||
- text: deepseek-v4-flash
|
- text: deepseek-v4-flash
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -16,12 +16,15 @@
|
|||||||
- img
|
- img
|
||||||
- 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.
|
- 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:
|
- button:
|
||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)"
|
- 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"
|
||||||
- button "▸ 问题内容"
|
|
||||||
- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps
|
|
||||||
- region "Ready to continue?":
|
- region "Ready to continue?":
|
||||||
- text: Checkpoint
|
- text: Checkpoint
|
||||||
- heading "Ready to continue?" [level=2]
|
- heading "Ready to continue?" [level=2]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- tab "Chat" [selected]
|
- tab "Chat" [selected]
|
||||||
- tab "Trajectory"
|
- tab "Trajectory"
|
||||||
- tab "Waterfall"
|
- 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 "复制":
|
- button "复制":
|
||||||
- img
|
- img
|
||||||
- button "在新对话中分支":
|
- button "在新对话中分支":
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
- img
|
- img
|
||||||
- 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.
|
- 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:
|
- button:
|
||||||
- img
|
- img
|
||||||
- img
|
- img
|
||||||
@@ -25,7 +30,11 @@
|
|||||||
- img
|
- img
|
||||||
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
|
- 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!
|
- 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"
|
- textbox "Message the agent"
|
||||||
- button "Add attachment":
|
- button "Add attachment":
|
||||||
- img
|
- img
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
"tests/seeded-history.e2e.ts",
|
"tests/seeded-history.e2e.ts",
|
||||||
"tests/sidebar-scrollbar.e2e.ts",
|
"tests/sidebar-scrollbar.e2e.ts",
|
||||||
"tests/code-mode-round.e2e.ts",
|
"tests/code-mode-round.e2e.ts",
|
||||||
"tests/cordis-tool-round.e2e.ts"
|
"tests/cordis-tool-round.e2e.ts",
|
||||||
|
"tests/message-actions.e2e.ts"
|
||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||||
README.md: b68b2ee0b816d0a4ffb440f05592a21772392b77
|
README.md: e2148cfca658196540e3800912dccd0568ae8d0e
|
||||||
README.zh.md: 06a324830e1900b02765853f4c31c53b44657dca
|
README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9
|
||||||
|
|||||||
@@ -34,7 +34,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.
|
- **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.
|
- **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 / clock) 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.
|
- **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.
|
||||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
|||||||
|
|
||||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||||
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
|
||||||
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
|
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||||
|
|||||||
@@ -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 {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
|
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
|
||||||
.stopped {
|
.stopped {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -19,3 +27,18 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
|
||||||
|
.actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.root:hover .actions,
|
||||||
|
.root:focus-within .actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
// view groups them into tool rows through its keyed toolview slot (figma
|
// view groups them into tool rows through its keyed toolview slot (figma
|
||||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
// 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.
|
// 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 } from 'react'
|
||||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
import {
|
||||||
|
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||||
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
|
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||||
import { ToolRow } from './ToolRow.tsx'
|
import { ToolRow } from './ToolRow.tsx'
|
||||||
import css from './AssistantMarkdown.module.css'
|
import css from './AssistantMarkdown.module.css'
|
||||||
|
|
||||||
@@ -16,6 +20,8 @@ export interface AssistantMarkdownProps {
|
|||||||
streaming: boolean
|
streaming: boolean
|
||||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
||||||
interrupted?: boolean | undefined
|
interrupted?: boolean | undefined
|
||||||
|
/** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
|
||||||
|
time?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstLine(text: string): string {
|
function firstLine(text: string): string {
|
||||||
@@ -23,6 +29,15 @@ function firstLine(text: string): string {
|
|||||||
return nl === -1 ? text : text.slice(0, nl)
|
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). */
|
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||||
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +53,9 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
|
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||||
|
blocks, streaming, interrupted, time,
|
||||||
|
}: AssistantMarkdownProps) {
|
||||||
const last = blocks.length - 1
|
const last = blocks.length - 1
|
||||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
// 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
|
// a node that is only those heads (or empty) would paint an empty root
|
||||||
@@ -47,18 +64,30 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
|||||||
|| interrupted === true
|
|| interrupted === true
|
||||||
|| blocks.some(block => block.kind !== 'tool-call')
|
|| blocks.some(block => block.kind !== 'tool-call')
|
||||||
if (!hasVisible) return null
|
if (!hasVisible) return null
|
||||||
|
// Footer only after the turn settles with a known event time; streaming omits it.
|
||||||
|
const showActions = !streaming && time !== undefined
|
||||||
return (
|
return (
|
||||||
<div className={css.root} data-streaming={streaming || undefined}>
|
<div className={css.root} data-streaming={streaming || undefined}>
|
||||||
{blocks.map((block, i) => {
|
<div className={css.body}>
|
||||||
switch (block.kind) {
|
{blocks.map((block, i) => {
|
||||||
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
switch (block.kind) {
|
||||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
|
||||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||||
case 'tool-call': return null
|
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
case 'tool-call': return null
|
||||||
}
|
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||||
})}
|
}
|
||||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
})}
|
||||||
|
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||||
|
</div>
|
||||||
|
{showActions && (
|
||||||
|
<MessageIconActions
|
||||||
|
text={copyText(blocks)}
|
||||||
|
time={time}
|
||||||
|
clock="end"
|
||||||
|
className={css.actions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -330,7 +330,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
|||||||
}
|
}
|
||||||
const node: ConversationNode = item.node
|
const node: ConversationNode = item.node
|
||||||
if (node.kind === 'assistant') {
|
if (node.kind === 'assistant') {
|
||||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
return (
|
||||||
|
<AssistantMarkdown
|
||||||
|
key={item.key}
|
||||||
|
blocks={node.blocks}
|
||||||
|
streaming={false}
|
||||||
|
interrupted={node.interrupted}
|
||||||
|
time={node.time}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (node.kind === 'command') {
|
if (node.kind === 'command') {
|
||||||
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||||
|
{formatMessageClock(time, day)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
|
||||||
|
{clock === 'start' ? clockEl : null}
|
||||||
|
<Tooltip label="复制" side="bottom">
|
||||||
|
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||||
|
<IconCopyOutline16 />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="在新对话中分支" side="bottom">
|
||||||
|
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||||
|
<IconBranchOutline16 />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{edit === true && (
|
||||||
|
<Tooltip label="编辑" side="bottom">
|
||||||
|
<button type="button" className={css.action} aria-label="编辑">
|
||||||
|
<IconEditOutline16 />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{clock === 'end' ? clockEl : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,46 +20,14 @@
|
|||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
|
||||||
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). */
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.actions {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.userRow:hover .actions,
|
.userRow:hover .actions,
|
||||||
.userRow:focus-within .actions {
|
.userRow:focus-within .actions {
|
||||||
opacity: 1;
|
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 {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
// 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
|
// injection and unknown-surface JSON rows. Props are frozen node slices off
|
||||||
// the snapshot cache; memo holds across streaming because unchanged nodes
|
// the snapshot cache; memo holds across streaming because unchanged nodes
|
||||||
// keep their references.
|
// keep their references.
|
||||||
|
|
||||||
import { memo, useCallback } from 'react'
|
import { memo } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type {
|
import type {
|
||||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import {
|
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
|
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||||
JsonBlock, MessageText, Tooltip,
|
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
||||||
import css from './MessageItem.module.css'
|
import css from './MessageItem.module.css'
|
||||||
|
|
||||||
export interface MessageItemProps {
|
export interface MessageItemProps {
|
||||||
@@ -30,42 +28,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
|||||||
return { text: texts.join(''), rest }
|
return { text: texts.join(''), rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
|
||||||
async function writeClipboard(text: string): Promise<void> {
|
|
||||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
|
||||||
// that runtime gap is exactly what this guard detects.
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
} catch {
|
|
||||||
// Denied permissions / iframe policy.
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// execCommand('copy') is the only clipboard fallback where the async API
|
|
||||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
|
||||||
/* eslint-disable @typescript-eslint/no-deprecated */
|
|
||||||
const exec = typeof document.execCommand === 'function'
|
|
||||||
? document.execCommand.bind(document)
|
|
||||||
: undefined
|
|
||||||
if (exec === undefined) return
|
|
||||||
const el = document.createElement('textarea')
|
|
||||||
el.value = text
|
|
||||||
el.setAttribute('readonly', '')
|
|
||||||
el.style.position = 'fixed'
|
|
||||||
el.style.left = '-9999px'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
el.select()
|
|
||||||
try {
|
|
||||||
exec('copy')
|
|
||||||
} catch {
|
|
||||||
// Clipboard unavailable; the button stays idle.
|
|
||||||
}
|
|
||||||
/* eslint-enable @typescript-eslint/no-deprecated */
|
|
||||||
el.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display projection of reference forms in a user bubble (free geometry — no
|
* Display projection of reference forms in a user bubble (free geometry — no
|
||||||
* textarea alignment constraint here); everything else stays plain text. The
|
* textarea alignment constraint here); everything else stays plain text. The
|
||||||
@@ -98,32 +60,6 @@ function projectUserText(text: string): ReactNode {
|
|||||||
return <>{parts}</>
|
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 (
|
|
||||||
<div className={css.actions}>
|
|
||||||
<Tooltip label="复制" side="bottom">
|
|
||||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
|
||||||
<IconCopyOutline16 />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="在新对话中分支" side="bottom">
|
|
||||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
|
||||||
<IconBranchOutline16 />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="编辑" side="bottom">
|
|
||||||
<button type="button" className={css.action} aria-label="编辑">
|
|
||||||
<IconEditOutline16 />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'user': {
|
case 'user': {
|
||||||
@@ -134,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
|||||||
{projectUserText(text)}
|
{projectUserText(text)}
|
||||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||||
</div>
|
</div>
|
||||||
<UserActions text={text} />
|
<MessageIconActions
|
||||||
|
text={text}
|
||||||
|
time={node.time}
|
||||||
|
clock="start"
|
||||||
|
edit
|
||||||
|
className={css.actions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
||||||
|
// that runtime gap is exactly what this guard detects.
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
// Denied permissions / iframe policy.
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// execCommand('copy') is the only clipboard fallback where the async API
|
||||||
|
// is missing (insecure contexts); deprecated but deliberately retained.
|
||||||
|
/* eslint-disable @typescript-eslint/no-deprecated */
|
||||||
|
const exec = typeof document.execCommand === 'function'
|
||||||
|
? document.execCommand.bind(document)
|
||||||
|
: undefined
|
||||||
|
if (exec === undefined) return
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = text
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.position = 'fixed'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
try {
|
||||||
|
exec('copy')
|
||||||
|
} catch {
|
||||||
|
// Clipboard unavailable; the button stays idle.
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-deprecated */
|
||||||
|
el.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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}`
|
||||||
|
}
|
||||||
@@ -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<typeof setTimeout>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@
|
|||||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||||
// renderSlot.)
|
// renderSlot.)
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-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 { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.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)
|
afterEach(cleanup)
|
||||||
|
|
||||||
describe('MessageItem arms', () => {
|
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)
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: { writeText },
|
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(
|
render(
|
||||||
<MessageItem node={{
|
<MessageItem node={{
|
||||||
kind: 'user', seq: 1,
|
kind: 'user', seq: 1, time,
|
||||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||||
} as never}
|
source: null,
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
|
expect(screen.getByText('14:24')).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
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(
|
render(
|
||||||
<MessageItem node={{
|
<MessageItem node={{
|
||||||
kind: 'user', seq: 1,
|
kind: 'user', seq: 1, time: 1_000,
|
||||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||||
} as never}
|
source: null,
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||||
@@ -69,9 +78,10 @@ describe('MessageItem arms', () => {
|
|||||||
})
|
})
|
||||||
render(
|
render(
|
||||||
<MessageItem node={{
|
<MessageItem node={{
|
||||||
kind: 'user', seq: 1,
|
kind: 'user', seq: 1, time: 1_000,
|
||||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||||
} as never}
|
source: null,
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
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(
|
||||||
|
<MessageItem node={{
|
||||||
|
kind: 'user', seq: 1, time,
|
||||||
|
content: [{ type: 'text', text: 'night bubble' }] as never,
|
||||||
|
source: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('14:24')).toBeTruthy()
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(msUntilNextLocalMidnight(dayStart) + 1)
|
||||||
|
})
|
||||||
|
expect(screen.getByText('7月29日 14:24')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('small branch tails', () => {
|
describe('small branch tails', () => {
|
||||||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||||||
const view = render(
|
const view = render(
|
||||||
@@ -117,6 +177,35 @@ describe('small branch tails', () => {
|
|||||||
expect(view.getByText('one-liner')).toBeTruthy()
|
expect(view.getByText('one-liner')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText },
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||||
|
const settled = render(
|
||||||
|
<AssistantMarkdown
|
||||||
|
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||||
|
streaming={false}
|
||||||
|
time={time}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(settled.getByText('14:24')).toBeTruthy()
|
||||||
|
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||||
|
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||||
|
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||||
|
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||||
|
settled.unmount()
|
||||||
|
|
||||||
|
const streaming = render(
|
||||||
|
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||||
|
)
|
||||||
|
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||||
|
expect(streaming.queryByText('14:24')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
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
|
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||||||
// output accounting) — any input makes it a real 0%.
|
// output accounting) — any input makes it a real 0%.
|
||||||
|
|||||||
@@ -211,4 +211,3 @@ export const sessionCancelRequestSchema = z.object({
|
|||||||
export const sessionCancelValueSchema = z.object({
|
export const sessionCancelValueSchema = z.object({
|
||||||
accepted: z.literal(true),
|
accepted: z.literal(true),
|
||||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>
|
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"apps/web/tests/sidebar-scrollbar.e2e.ts",
|
"apps/web/tests/sidebar-scrollbar.e2e.ts",
|
||||||
"apps/web/tests/code-mode-round.e2e.ts",
|
"apps/web/tests/code-mode-round.e2e.ts",
|
||||||
"apps/web/tests/cordis-tool-round.e2e.ts",
|
"apps/web/tests/cordis-tool-round.e2e.ts",
|
||||||
|
"apps/web/tests/message-actions.e2e.ts",
|
||||||
"apps/cli/tests/**/*.ts",
|
"apps/cli/tests/**/*.ts",
|
||||||
"examples/*/src/**/*.ts",
|
"examples/*/src/**/*.ts",
|
||||||
"examples/*/start.ts",
|
"examples/*/start.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user