From 4bf09d508c75dacf014cdf35b4bcea68b99f3f39 Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Thu, 30 Jul 2026 19:35:38 +0800
Subject: [PATCH] fix: cr
---
...b-message-icon-actions-and-clock.i18n.yaml | 4 +-
...7-29-web-message-icon-actions-and-clock.md | 10 +++--
...9-web-message-icon-actions-and-clock.zh.md | 10 +++--
apps/web/tests/message-actions.e2e.ts | 28 ++++++-------
.../snapshots/message-actions/seed.jsonl | 36 ++++++++++++++++
.../snapshots/message-actions/ui.expected.md | 1 +
.../client/ui-conversation/README.i18n.yaml | 4 +-
packages/client/ui-conversation/README.md | 2 +-
packages/client/ui-conversation/README.zh.md | 2 +-
.../src/client/chat/ChatView.tsx | 16 +++++--
.../src/client/chat/chat-flow.ts | 31 +++++++++++++-
.../ui-conversation/tests/chat-view.spec.tsx | 42 ++++++++++++++++---
12 files changed, 146 insertions(+), 40 deletions(-)
create mode 100644 apps/web/tests/snapshots/message-actions/seed.jsonl
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
index 9e9755c0b5..0fcd9ae778 100644
--- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
@@ -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
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
index f3a5a0caf7..b408a3c6d6 100644
--- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
@@ -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.
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
index 9c51ec931a..4440d6cacd 100644
--- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
@@ -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 中间叙述且无第三个复制控件。
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index 2f5f8f7abf..e9d1447e93 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -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'])
})
})
diff --git a/apps/web/tests/snapshots/message-actions/seed.jsonl b/apps/web/tests/snapshots/message-actions/seed.jsonl
new file mode 100644
index 0000000000..d8d2f54463
--- /dev/null
+++ b/apps/web/tests/snapshots/message-actions/seed.jsonl
@@ -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":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"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":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"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"}}}
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index 19ba02d99d..b955730c7e 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -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"
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 807205c03b..3564e426ab 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 3c15fe73c242ee63b9bbc93b602653b0f5f14af6
-README.zh.md: 61718fe8bdd73054057f7ce0ad6ba7ae8ce308a1
+README.md: 1dbaaf7cabcd172ab37aef82ba9ca6a2c7a70d01
+README.zh.md: f21a5819057af2f899dbdf039df35544627153d0
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 3c15fe73c2..1dbaaf7cab 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -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.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 61718fe8bd..f21a581905 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -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 条没有换行或展开入口,完整文本无法在行内读完。
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index 16fa25f969..59719624c7 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -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(null)
const atBottomRef = useRef(true)
diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts
index ad98d3aaad..7e75a99be3 100644
--- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts
+++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts
@@ -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 {
+export function assistantActionsSeqs(
+ nodes: readonly ConversationNode[],
+ withholdTurn: number | null = null,
+): ReadonlySet {
const lastByTurn = new Map()
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())
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index 453e6a8f0f..4cd835694f 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -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()
+ // 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)] })