소스 검색

Merge pull request #994 from deepseek-harness/fix/turn-actions

feat(web): add favicon and update IconActions behavior
07akioni 1 개월 전
부모
커밋
c61a05ca5f

+ 2 - 2
.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: ccc9b127d2fcf276fa415ec0ede8b062e45f3df5
-2026-07-29-web-message-icon-actions-and-clock.zh.md: fb4b82a22478b2de38755368ec48a5dca420e79d
+2026-07-29-web-message-icon-actions-and-clock.md: f43f7f9c9687e4494993d7e225d11cf6446a9954
+2026-07-29-web-message-icon-actions-and-clock.zh.md: 866fae79f6ad3ea2cb80e5443d2cf5f763562d29

+ 5 - 3
.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; finalized assistant *content* nodes (non-empty text blocks) append 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 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`). `AssistantMarkdown` places it 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 and the streaming tail omit the row. Copy writes joined text blocks. Both message rows pass their event's `seq` to the same fork callback; [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the real mutation contract. 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 `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. Both message rows pass their event's `seq` to the same fork callback; [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the real mutation contract. 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
 
@@ -20,6 +20,8 @@ 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.
+
 **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.
 
 **Let the IconActions decision also define session fork semantics.** Rejected: this note owns only message chrome, clocks, and mount gating; boundary selection, failure behavior, and switching semantics belong to the separate [Web session fork actions](2026-07-27-web-session-fork-actions.md), keeping presentation components from becoming a second home for session mutation.
@@ -28,4 +30,4 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day 
 
 ## Consequences
 
-Settled assistant content answers expose copy, branch, and the event clock as soon as the row mounts; Think-only nodes stay chrome-free. 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 assistant gate, and the respective event `seq` values passed by the user and assistant branch buttons; the web e2e scenario pins the assembled IconActions chrome.
+Each turn's last settled content answer exposes copy, branch, and the event clock as soon as the row mounts; mid-turn content and Think-only nodes stay chrome-free. 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 assistant gate, the turn-tail seq gate, and the respective event `seq` values passed by the user and assistant branch buttons; the web e2e scenario pins the assembled IconActions chrome.

+ 5 - 3
.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md

@@ -10,9 +10,9 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
 
 ## 决策
 
-**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。**
+**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;每个轮次中最后一条带 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`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `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` 推导轮次尾部的 seq,并不为轮次中间的内容传入 `time`;`AssistantMarkdown` 把该行放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染。纯 Think 节点、轮次中间的叙述与流式尾部省略该行。复制写入拼接后的 text 块。两种消息行都把自己的事件 `seq` 交给同一个 fork 回调;真实 mutation 契约由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
 
 ## 曾考虑的方案
 
@@ -20,6 +20,8 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
 
 **给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。
 
+**给多步骤轮次中的每一条带 text 内容的 assistant 都挂 IconActions。** 否决:轮次中间的叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有该轮次中最后一条内容 assistant 拥有该座位。
+
 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。
 
 **由 IconActions 决策同时定义 session fork 语义。** 否决:本笔记只拥有消息 chrome、时钟与挂载门控;边界选择、失败行为和切换语义属于独立的 [Web session fork 操作](2026-07-27-web-session-fork-actions.md),避免展示组件成为 session mutation 的第二正家。
@@ -28,4 +30,4 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
 
 ## 后果
 
-已定稿的 assistant 内容回答在行挂载后立刻暴露复制、分支与事件时钟;纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。
+每个轮次中最后一条已定稿的内容回答在行挂载后立刻暴露复制、分支与事件时钟;轮次中间的内容与纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控、轮次尾部 seq 门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。

+ 1 - 0
apps/web/index.html

@@ -3,6 +3,7 @@
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <title>DeepSeek Harness</title>
   </head>
   <body>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
apps/web/public/favicon.svg


+ 2 - 2
apps/web/tests/message-actions.e2e.ts

@@ -64,8 +64,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     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.
+    // hover/focus-within). User has three actions; each turn's last content
+    // assistant has copy + branch.
     const copyButtons = page.getByRole('button', { name: '复制' })
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
     await copyButtons.first().focus()

+ 0 - 5
apps/web/tests/snapshots/plan-review/approved.expected.md

@@ -20,11 +20,6 @@
   - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with
   - code: exit_plan_mode
   - text: .
-- button "复制":
-  - img
-- button "在新对话中分支":
-  - img
-- text: {{clock}}
 - button:
   - img
   - img

+ 2 - 2
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: 60000c35c3e30883c4b29cc8410d30e58021318c
-README.zh.md: 8f9fe7278d2d4bf5251582867de44290d0727710
+README.md: bfb56dc52406e1377cd84c87866a644ade10293a
+README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c

+ 2 - 2
packages/client/ui-conversation/README.md

@@ -18,7 +18,7 @@ A tool call declaring the `terminal` render intent renders its command output in
 
 Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
 
-The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
+The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
 
 `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.
 
@@ -40,7 +40,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 text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
+- **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 forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
 - **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.

+ 2 - 2
packages/client/ui-conversation/README.zh.md

@@ -18,7 +18,7 @@
 
 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
 
-todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
+todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
 
 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
 
@@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 
 - **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
-- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
+- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。

+ 5 - 3
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx

@@ -4,8 +4,9 @@
 // view groups them into tool rows through its keyed toolview slot (figma
 // step-summary flow). Shared by finalized nodes and the streaming partial;
 // the turn-level loading dots live in the chat view's tail, not here.
-// Finalized content (text) nodes append IconActions once streaming ends;
-// Think / tool-head-only nodes stay chrome-free.
+// Finalized turn-tail content (text) nodes append IconActions once streaming
+// ends (`time` is omitted for mid-turn narration); Think / tool-head-only
+// nodes stay chrome-free.
 
 import { memo } from 'react'
 import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -21,7 +22,8 @@ export interface AssistantMarkdownProps {
   streaming: boolean
   /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
   interrupted?: boolean | undefined
-  /** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
+  /** Unix epoch ms for the IconActions clock; omitted while streaming or when
+   *  the parent withholds chrome (mid-turn content assistants). */
   time?: number | undefined
   /** Event sequence used as the fork boundary; omitted while streaming. */
   seq?: number | undefined

+ 5 - 2
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -30,7 +30,7 @@ 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 { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
+import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
 import { AssistantMarkdown } from './AssistantMarkdown.tsx'
 import { GenericCommandCard } from './GenericCommandCard.tsx'
 import { GenericToolCard } from './GenericToolCard.tsx'
@@ -244,6 +244,9 @@ 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])
 
   const listRef = useRef<HTMLDivElement | null>(null)
   const atBottomRef = useRef(true)
@@ -376,7 +379,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
           blocks={node.blocks}
           streaming={false}
           interrupted={node.interrupted}
-          time={node.time}
+          time={actionSeqs.has(node.seq) ? node.time : undefined}
           seq={node.seq}
           onFork={forkAt}
         />

+ 26 - 2
packages/client/ui-conversation/src/client/chat/chat-flow.ts

@@ -3,15 +3,24 @@
  * results group into consecutive-run tool groups (figma step-summary flow,
  * VERTICAL gap10) alternating with narration; everything else passes through.
  * Item identity keys are stable across snapshots so the list parent can
- * subscribe to keys only while rows subscribe to content.
+ * subscribe to keys only while rows subscribe to content. IconActions ownership
+ * (last content assistant per turn) is derived here too so ChatView and the
+ * flow share one gate.
  */
-import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  AssistantBlock, ConversationNode, ToolResultNode,
+} from '@deepseek-ai/dsh-client-runtime/client'
 
 /** One renderable flow item; key is the React key and the parent's identity unit. */
 export type ChatFlowItem =
   | { kind: 'node'; key: string; node: ConversationNode }
   | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
 
+/** True when the node has model-visible text content worth IconActions chrome. */
+function hasContentText(blocks: readonly AssistantBlock[]): boolean {
+  return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
+}
+
 /** An assistant node that renders nothing: only tool-call heads (rows render
  *  via the grouping pass) and blank text/reasoning. Skipped by the flow so it
  *  neither costs column gaps nor splits a tool-row run. Interrupted nodes
@@ -22,6 +31,21 @@ function rendersNothing(node: ConversationNode): boolean {
       || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
 }
 
+/**
+ * Seq set of assistants that own IconActions: the last content-text assistant
+ * in each turn. Mid-turn narration (text before tools) stays chrome-free.
+ * @param nodes - snapshot nodes (surface order).
+ * @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
+ */
+export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
+  const lastByTurn = new Map<number, number>()
+  for (const node of nodes) {
+    if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
+    lastByTurn.set(node.turn, node.seq)
+  }
+  return new Set(lastByTurn.values())
+}
+
 /**
  * Group finalized nodes into the step-summary flow.
  * @param nodes - snapshot nodes (surface order).

+ 37 - 3
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -16,7 +16,7 @@ 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 { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
+import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
 
 afterEach(cleanup)
 // Keyless create() persists under the bare declared key; clear between cases
@@ -61,8 +61,8 @@ const user = (seq: number, text: string): UserMessageNode => ({
   content: [{ type: 'text', text }] as never,
   source: null,
 })
-const assistant = (seq: number, text: string): AssistantMessageNode => ({
-  kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
+const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
+  kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
 })
 const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
   kind: 'tool-result', seq, time: seq * 1_000, callId,
@@ -156,6 +156,23 @@ describe('chat-flow derivation', () => {
     expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
     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', () => {
+    const thinkOnly: AssistantMessageNode = {
+      kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
+      blocks: [{ kind: 'reasoning', text: 'planning' }],
+    }
+    const seqs = assistantActionsSeqs([
+      user(1, 'hi'),
+      assistant(2, 'looking', 1),
+      thinkOnly,
+      toolResult(4, 'a'),
+      assistant(5, 'done', 1),
+      user(6, 'again'),
+      assistant(7, 'second turn', 2),
+    ])
+    expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
+  })
 })
 
 describe('ChatView', () => {
@@ -196,6 +213,23 @@ describe('ChatView', () => {
     expect(view.getByText('run a')).toBeTruthy()
   })
 
+  it('shows assistant IconActions only on the last content message of each turn', () => {
+    const h = makeHarness({
+      nodes: [
+        user(1, 'hi'),
+        assistant(2, 'mid-turn text'),
+        toolResult(3, 'a'),
+        assistant(4, 'final answer'),
+        user(5, 'next'),
+        assistant(6, 'second turn', 2),
+      ],
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    // 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free.
+    expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
+    expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
+  })
+
   it('forks from both user and finalized assistant message actions at their event seq', () => {
     const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
     const view = render(<h.ChatView {...h.props} />)

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.