This commit is contained in:
07akioni
2026-07-30 19:59:50 +08:00
parent 20db6ca4d5
commit 4bf09d508c
12 changed files with 146 additions and 40 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
2026-07-29-web-message-icon-actions-and-clock.md: f3a5a0caf75627f5f6b37f0e9b9e5e4b5c6ac6c3
2026-07-29-web-message-icon-actions-and-clock.zh.md: 9c51ec931a2e45a2d19122b7dddd31d1bb9d1299
2026-07-29-web-message-icon-actions-and-clock.md: b408a3c6d6bf3e0c11764c73c0ea7a859ce0adec
2026-07-29-web-message-icon-actions-and-clock.zh.md: 4440d6cacd3d2f75d758fa00315a653507c16928
@@ -10,9 +10,9 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo
## Decision
**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.**
**User bubbles prepend a date-aware local clock to the existing IconActions row; the last content-text assistant of each *settled* turn appends a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and 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`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds `time` for mid-turn content; `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. 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}}`.
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`). `ChatView` derives turn-tail seqs via `assistantActionsSeqs` and withholds a still-running turn through `withholdActionsTurn` (streaming `partial.turn`, else the first `runningCalls` turn; a bare `running` bit before the first step does not strip a prior settled seat). Selectors return a primitive turn so chunk storms do not re-render the list parent. `AssistantMarkdown` places the row after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content. Think-only nodes, mid-turn narration, an active turn's content, and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (owned cold seed with mid-turn narration text + aria golden that places copy only under the user bubble and the turn-tail `DONE`); aria normalization collapses every clock shape to `{{clock}}`.
## Alternatives considered
@@ -20,7 +20,9 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day
**Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat.
**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of the turn owns the seat.
**Put IconActions under every content-text assistant in a multi-step turn.** Rejected: mid-turn narration (text before tools) is not the settled answer; repeating copy/branch/clock under each step clutters the flow. Only the last content assistant of a settled turn owns the seat.
**Derive the running-turn withhold from `running` plus the max turn among finalized nodes alone.** Rejected: after step-1 text lands and tools run for seconds that tip is temporarily "last content," so chrome would flash on then off; `partial` / `runningCalls` name the open turn without that flicker, and a bare `running` before the first step must leave the prior settled answer's seat alone.
**Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate.
@@ -30,4 +32,4 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day
## Consequences
Each turn's last settled content answer exposes copy and the event clock as soon as the row mounts; mid-turn content and Think-only nodes stay chrome-free; 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, the midnight widen, the content-only gate, and the turn-tail seq gate; the web e2e scenario pins the assembled IconActions chrome.
Each settled turn's last content answer exposes copy and the event clock as soon as the row mounts; mid-turn content, an active turn's content, and Think-only nodes stay chrome-free; 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, the midnight widen, the content-only gate, the turn-tail seq gate, and the running-turn withhold; the web e2e scenario pins the assembled IconActions chrome including mid-turn narration without a third copy control.
@@ -10,9 +10,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
## 决策
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个 turn 最后一条带 text 内容的 assistant 在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。**
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个*已结束* turn 最后一条带 text 内容的 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`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并对 turn 中间内容不传 `time``AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm``useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`ChatView` 通过 `assistantActionsSeqs` 推导 turn 尾部 seq,并`withholdActionsTurn` 扣留仍在运行的 turn(优先流式 `partial.turn`,否则第一条 `runningCalls` 的 turn;在第一步出现前仅有 `running` 时不得撤掉上一回合已定稿答案的座位)。选择器返回原始 turn 值,因此 token 风暴不会让列表父级重渲染。`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、turn 中间叙述、活跃 turn 的内容与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`自有冷 seed,含 turn 中间叙述文本 + aria golden,仅在用户气泡与 turn 尾部 `DONE` 下放置复制)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`
## 曾考虑的方案
@@ -20,7 +20,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
**给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。
**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有 turn 最后一条内容 assistant 拥有该座位。
**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有已结束 turn 最后一条内容 assistant 拥有该座位。
**仅用 `running` 加上已定稿节点中的最大 turn 推导运行中 turn 的扣留。** 否决:step-1 文本落地后工具可能跑数秒,该 tip 暂时就是「最后一条 content」,chrome 会先出现再消失;`partial``runningCalls` 能指名开放 turn 且无此闪烁,而第一步前仅有 `running` 时必须保留上一回合已定稿答案的座位。
**在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。
@@ -30,4 +32,4 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
## 后果
每个 turn 最后一条已定稿内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控turn 尾部 seq 门控;Web e2e 场景钉住组装后的 IconActions chrome。
每个已结束 turn 最后一条内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容、活跃 turn 的内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控turn 尾部 seq 门控与运行中 turn 扣留;Web e2e 场景钉住组装后的 IconActions chrome,含 turn 中间叙述且无第三个复制控件
+14 -14
View File
@@ -1,7 +1,8 @@
// 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).
// Web e2e scenario: message IconActions + clocks. Cold-seeds a closed turn that
// includes mid-turn narration text (so the turn-tail chrome gate is exercised)
// 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'
@@ -15,9 +16,7 @@ import {
import { newEnglishPage, 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 SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-web-e2e'
@@ -37,7 +36,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
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])
expect(fixtureUserPrompts(raw), 'seed must carry the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -51,7 +50,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
it.skipIf(MODE === 'record')('lists the seeded session and reveals turn-tail IconActions only', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
@@ -60,15 +59,16 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText("I'll read both files.").count(), { timeout: 10_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
// hover/focus-within). User has three actions; each turn's last content
// assistant has copy + branch.
// hover/focus-within). Exactly one user row + one settled turn-tail
// assistant: mid-turn narration must not add a third copy control.
const copyButtons = page.getByRole('button', { name: '复制' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
await copyButtons.first().focus()
await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
.toBe(2)
await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
}, 60_000)
@@ -88,6 +88,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
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'])
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
})
})
@@ -0,0 +1,36 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","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."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"38f072be-5254-4cb7-b76e-d612b2ae3b3a"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784974101296,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784974101297,"data":{"turn":1,"step":1,"index":0,"dt":[125,30,0,1,0,0,30,1,0,0,0,0,30,1,0,0,30,0,0,0,0,1,30,1,0,0],"texts":["The"," user"," wants"," me"," to"," read"," a",".txt"," and"," b",".txt",","," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," do"," both"," reads"," in"," parallel","."]}}
{"type":"assistant/chunk","seq":33,"time":1784974101666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":34,"time0":1784974101667,"data":{"turn":1,"step":1,"index":1,"dt":[30,0,0,0,0,0,29,1,0,29,1],"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","args":["","{","\"","file","_path","\"",": ","\"","a",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":46,"time":1784974101821,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":47,"time0":1784974101822,"data":{"turn":1,"step":1,"index":2,"dt":[27,0,0,0,1,31,0,1,0,26,1],"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","args":["","{","\"","file","_path","\"",": ","\"","b",".txt","\"","}"]}}
{"type":"assistant/chunk","seq":59,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}}}
{"type":"assistant/chunk","seq":60,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}}
{"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
{"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"text","text":"I'll read both files."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ec076738-f75d-4525-ba99-c8fc16acf955"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
{"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
{"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"<path>{{cwd}}/workspace/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"ea2294eb-8652-4492-8a08-9c24d3f8a60f"}},"sourceEventSeqs":[65],"surfaceOp":"append"}
{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"<path>{{cwd}}/workspace/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"f02b3acf-4e03-4b4d-beeb-1a564c9c6d61"}},"sourceEventSeqs":[66],"surfaceOp":"append"}
{"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":72,"time0":1784974102397,"data":{"turn":1,"step":2,"index":0,"dt":[108,29,1,0,0,30,30,1,0,0,0,29,0,0,0,0,1,30,0,0,0,33,1,0,26,1,31,1],"texts":["Both"," files"," have"," been"," read","."," a",".txt"," contains"," \"","alpha","\""," and"," b",".txt"," contains"," \"","beta","\"."," I","'ll"," now"," reply"," with"," D","ONE"," as"," instructed","."]}}
{"type":"assistant/chunk","seq":101,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":102,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":103,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":104,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."}}}}
{"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}}
{"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"07627a5e-4cb2-47ef-9b50-88893aac7406"}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"}
{"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -16,6 +16,7 @@
- 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.
- paragraph: I'll read both files.
- img
- text: Read
- button "a.txt"
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 3c15fe73c242ee63b9bbc93b602653b0f5f14af6
README.zh.md: 61718fe8bdd73054057f7ce0ad6ba7ae8ce308a1
README.md: 1dbaaf7cabcd172ab37aef82ba9ca6a2c7a70d01
README.zh.md: f21a5819057af2f899dbdf039df35544627153d0
+1 -1
View File
@@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free; branch remains a chrome stub.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each settled turn only; mid-turn narration, an active turn's content, and Think-only nodes stay chrome-free; 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 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.
+1 -1
View File
@@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述与纯 Think 节点不带 chrome;分支仍是 chrome stub。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个已结束 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述、活跃 turn 的内容与纯 Think 节点不带 chrome;分支仍是 chrome stub。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
@@ -30,7 +30,9 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import {
assistantActionsSeqs, deriveChatFlow, withholdActionsTurn, type ChatFlowItem,
} from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -236,6 +238,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
// Primitive turn (or null): stable across chunk storms so this parent does
// not re-render per token the way a partial.blocks subscribe would.
const withholdTurn = useSession(s => withholdActionsTurn(s.running, s.partial, s.runningCalls))
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
@@ -244,9 +249,12 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
// Settled turn-tail content only; a running turn withholds its whole seat so
// mid-turn narration does not flash copy/branch/clock while tools run.
const actionSeqs = useMemo(
() => assistantActionsSeqs(nodes, withholdTurn),
[nodes, withholdTurn],
)
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
@@ -31,16 +31,43 @@ function rendersNothing(node: ConversationNode): boolean {
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Turn whose content assistants must stay chrome-free while the turn is still
* running. Prefers the streaming partial, else the first in-flight tool call;
* returns null when `running` is false or neither signal exists yet (a brand-new
* turn before the first step must not strip a prior settled answer's seat).
* @param running - snapshot `running` bit.
* @param partial - in-flight assistant partial, or null.
* @param runningCalls - in-flight tool rows (same turn while tools execute).
* @returns Turn to withhold, or null.
*/
export function withholdActionsTurn(
running: boolean,
partial: { turn: number } | null,
runningCalls: readonly { turn: number }[],
): number | null {
if (!running) return null
if (partial !== null) return partial.turn
return runningCalls[0]?.turn ?? null
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
* in each *settled* turn. Mid-turn narration and every content assistant of a
* still-running turn stay chrome-free (no flash while tools run or the next
* step streams).
* @param nodes - snapshot nodes (surface order).
* @param withholdTurn - active turn from {@link withholdActionsTurn}, or null.
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
*/
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
export function assistantActionsSeqs(
nodes: readonly ConversationNode[],
withholdTurn: number | null = null,
): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
for (const node of nodes) {
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
if (withholdTurn !== null && node.turn === withholdTurn) continue
lastByTurn.set(node.turn, node.seq)
}
return new Set(lastByTurn.values())
@@ -16,7 +16,9 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
import {
assistantActionsSeqs, deriveChatFlow, flowKeys, withholdActionsTurn,
} from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
@@ -155,12 +157,12 @@ describe('chat-flow derivation', () => {
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
it('assistantActionsSeqs keeps only the last content assistant per settled turn', () => {
const thinkOnly: AssistantMessageNode = {
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
blocks: [{ kind: 'reasoning', text: 'planning' }],
}
const seqs = assistantActionsSeqs([
const nodes: ConversationNode[] = [
user(1, 'hi'),
assistant(2, 'looking', 1),
thinkOnly,
@@ -168,8 +170,18 @@ describe('chat-flow derivation', () => {
assistant(5, 'done', 1),
user(6, 'again'),
assistant(7, 'second turn', 2),
])
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
]
expect([...assistantActionsSeqs(nodes)].sort((a, b) => a - b)).toEqual([5, 7])
// While turn 1 is still running, its tip content must not own the seat.
expect([...assistantActionsSeqs(nodes, 1)].sort((a, b) => a - b)).toEqual([7])
})
it('withholdActionsTurn follows partial, then runningCalls, and ignores a bare running bit', () => {
expect(withholdActionsTurn(false, { turn: 2 }, [{ turn: 2 }])).toBeNull()
expect(withholdActionsTurn(true, { turn: 3 }, [{ turn: 2 }])).toBe(3)
expect(withholdActionsTurn(true, null, [{ turn: 2 }])).toBe(2)
// Turn accepted but no step output yet: do not strip a prior settled seat.
expect(withholdActionsTurn(true, null, [])).toBeNull()
})
})
@@ -211,7 +223,7 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('shows assistant IconActions only on the last content message of each turn', () => {
it('shows assistant IconActions only on the last content message of each settled turn', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
@@ -228,6 +240,24 @@ describe('ChatView', () => {
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
})
it('withholds IconActions for a running turn while tools are in flight', () => {
const h = makeHarness({
running: true,
runningCalls: [{ ...runningCall('a'), turn: 2 }],
nodes: [
user(1, 'first'),
assistant(2, 'previous answer', 1),
user(3, 'second'),
assistant(4, 'mid-turn text', 2),
],
})
const view = render(<h.ChatView {...h.props} />)
// 2 user + 1 settled turn-tail; running turn's mid-turn text stays chrome-free.
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
expect(view.getByText('mid-turn text')).toBeTruthy()
expect(view.getByText('previous answer')).toBeTruthy()
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })