Procházet zdrojové kódy

feat(web): add favicon and update IconActions behavior

07akioni před 2 měsíci
rodič
revize
20db6ca4d5

+ 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: e79662056792c3ab413468ad038dec40455be767
-2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5
+2026-07-29-web-message-icon-actions-and-clock.md: f3a5a0caf75627f5f6b37f0e9b9e5e4b5c6ac6c3
+2026-07-29-web-message-icon-actions-and-clock.zh.md: 9c51ec931a2e45a2d19122b7dddd31d1bb9d1299

+ 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. 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 `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}}`.
 
 ## 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.
 
 **Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
@@ -28,4 +30,4 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day 
 
 ## Consequences
 
-Settled assistant content answers expose copy and the event clock as soon as the row mounts; 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, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome.
+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.

+ 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 行前追加感知日期的本地时钟;每个 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`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 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,并对 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}}`。
 
 ## 曾考虑的方案
 
@@ -20,6 +20,8 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
 
 **给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。
 
+**给多步 turn 中每一条带 text 的 assistant 都挂 IconActions。** 否决:turn 中间叙述(工具调用前的 text)不是已定稿答案;在每一步下重复复制/分支/时钟会打乱流程。只有该 turn 最后一条内容 assistant 拥有该座位。
+
 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。
 
 **把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
@@ -28,4 +30,4 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
 
 ## 后果
 
-已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。
+每个 turn 最后一条已定稿内容回答在行挂载后立刻暴露复制与事件时钟;turn 中间内容与纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、仅内容门控与 turn 尾部 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>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
apps/web/public/favicon.svg


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

@@ -62,8 +62,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()

+ 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: fc466190a744a1c13094ca6ebf62755d5bf49c98
-README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79
+README.md: 3c15fe73c242ee63b9bbc93b602653b0f5f14af6
+README.zh.md: 61718fe8bdd73054057f7ce0ad6ba7ae8ce308a1

+ 1 - 1
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 text output only; 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 turn only; mid-turn narration 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
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 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。
+- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述与纯 Think 节点不带 chrome;分支仍是 chrome stub。
 - **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
 }
 

+ 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}
         />
       )
     }

+ 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,
@@ -154,6 +154,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', () => {
@@ -194,6 +211,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('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)] })

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů