diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml new file mode 100644 index 0000000000..976dcc1d1d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +2026-07-27-web-session-fork-actions.md: 90d2f9a8085cf1f1d2656bb442eee1e4116bb91a +2026-07-27-web-session-fork-actions.zh.md: f0642d17fdb6030dea16ff1440c730c605fec13d diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md new file mode 100644 index 0000000000..90d2f9a808 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -0,0 +1,33 @@ +# Agent Note: Web session fork actions + +Status: implemented + +English | [中文](2026-07-27-web-session-fork-actions.zh.md) + +## Problem + +The Session store already provides a fork primitive that creates a child session from a completed-turn prefix, but the Web client has no unified interaction contract. The Session-row menu can express only “branch from the latest completed turn,” while message IconActions need to express “branch from the turn containing this message”; if the two entry points independently interpret the boundary, switching, and failure behavior, the same user action acquires two sets of semantics. Nesting a fork child beneath its source session also makes the newly selected child visible only while its ancestors are expanded and weakens the workspace manual-order model. + +## Decision + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq }`, so it forks at the turn containing that event. On success, the client first adds the child session to its local list and then opens it; on failure, it leaves the source session and current selection unchanged. + +`forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. + +Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage, tool presentation, and later queries, but does not control session-list visibility. + +## Alternatives considered + +**Wire only the Session-row menu.** Rejected: at a message, the user has already selected more precise context; forcing them back to the list can only degrade the boundary to the latest completed turn, while the visible message branch icon would remain non-responsive. + +**Allow branching only from user messages.** Rejected: settled assistant content also has a stable event `seq`, and the host places it in its containing completed turn; making only one of two visually identical branch buttons work would create an invisible behavioral difference. + +**Nest fork children beneath their source by `parentId`.** Rejected: lineage is not navigation ownership; nesting requires automatic ancestor expansion to reveal the current item and prevents children from participating in the workspace's peer manual order. + +**Call the session service directly from message components.** Rejected: client components must not touch `ctx` or business services; injected callbacks keep mutation in the apply world and leave components driven purely by props. + +## Consequences + +Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. + +Fork failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md new file mode 100644 index 0000000000..f0642d17fd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web session fork 操作 + +Status: implemented + +[English](2026-07-27-web-session-fork-actions.md) | 中文 + +## Problem + +Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 Web 端没有一份统一的交互契约。Session 行菜单只能表达「从最新完成轮分支」,消息 IconActions 还需要表达「从这条消息所在轮分支」;如果两处各自解释边界、切换与失败行为,同一个用户动作会形成两套语义。把 fork 子会话嵌套在源会话下还会让新选中的子会话依赖祖先展开态才能看见,并削弱 workspace 的手动排序模型。 + +## Decision + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq }`,因此在包含该事件的轮次处分支。成功后 client 先把子会话纳入本地列表,再打开子会话;失败时保持源会话与当前选择不变。 + +`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 + +Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage、工具呈现和后续查询,但不控制 session 列表可见性。 + +## Alternatives considered + +**只接 session 行菜单。** 否决:用户在消息处已经选择了更精确的上下文,强迫其回到列表只能退化为最新完成轮,且已展示的消息分支图标会成为无响应控件。 + +**只允许用户消息分支。** 否决:已定稿 assistant 内容同样有稳定事件 `seq`,host 会把它归入所属完成轮;让两个外观相同的分支按钮只有一个可用会制造不可见的行为差异。 + +**按 `parentId` 把 fork 子会话嵌套在源会话下。** 否决:lineage 不是导航所有权;嵌套要求自动展开祖先才能看见当前项,并让子会话无法参与 workspace 的同级手动排序。 + +**由消息组件直接调用 session 服务。** 否决:client 组件不得接触 `ctx` 或业务服务;注入回调让 mutation 留在 apply 世界,组件保持纯 props。 + +## Consequences + +用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 + +Fork 失败保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq` 与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 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 de8869a96a..f2e1183fe5 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: e79662056792c3ab413468ad038dec40455be767 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5 +2026-07-29-web-message-icon-actions-and-clock.md: ccc9b127d2fcf276fa415ec0ede8b062e45f3df5 +2026-07-29-web-message-icon-actions-and-clock.zh.md: fb4b82a22478b2de38755368ec48a5dca420e79d 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 e796620567..ccc9b127d2 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 @@ -12,7 +12,7 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo **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.** -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`). `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}}`. ## Alternatives considered @@ -22,10 +22,10 @@ Both seats format `node.time` through `formatMessageClock`: same calendar day **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. +**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. **Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source. ## Consequences -Settled assistant 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. +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. 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 72d3b4e0cd..fb4b82a224 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 @@ -12,7 +12,7 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `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`)。`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}}`。 ## 曾考虑的方案 @@ -22,10 +22,10 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 **在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 -**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 +**由 IconActions 决策同时定义 session fork 语义。** 否决:本笔记只拥有消息 chrome、时钟与挂载门控;边界选择、失败行为和切换语义属于独立的 [Web session fork 操作](2026-07-27-web-session-fork-actions.md),避免展示组件成为 session mutation 的第二正家。 **通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 ## 后果 -已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。 +已定稿的 assistant 内容回答在行挂载后立刻暴露复制、分支与事件时钟;纯 Think 节点不带 chrome。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽、assistant 仅内容门控,以及 user/assistant 分支按钮各自传递的事件 `seq`;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 679fdfbc65..9b02c5bc73 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -89,7 +89,9 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - await page.getByRole('button', { name: '在新对话中分支' }).first().click() + // Exercise the assistant action specifically; package coverage pins the + // user action separately at its own event seq. + await page.getByRole('button', { name: '在新对话中分支' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), { timeout: 15_000 }, @@ -104,7 +106,9 @@ describe('web e2e: message IconActions and clocks on settled history', () => { ).toBe(1) // The row action owns a distinct ui-workspace injection from the message // action above, so exercise both through the loaded app before capture. - const sourceRow = page.locator('[role="treeitem"][aria-expanded="true"]').last() + const sourceRow = page.locator('[role="treeitem"]') + .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) + .last() const rowBox = await sourceRow.boundingBox() if (rowBox === null) throw new Error('fork source row has no layout box') const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]') diff --git a/apps/web/tests/snapshots/message-actions/fork.expected.md b/apps/web/tests/snapshots/message-actions/fork.expected.md index 5255632c3a..8e07129bfd 100644 --- a/apps/web/tests/snapshots/message-actions/fork.expected.md +++ b/apps/web/tests/snapshots/message-actions/fork.expected.md @@ -2,9 +2,6 @@ - treeitem "Ungrouped 3 sessions" [expanded]: - img - text: Ungrouped 3 sessions - - treeitem "Collapse Use the read tool twice 1min" [expanded]: - - button "Collapse": - - img - - text: Use the read tool twice 1min - treeitem "Use the read tool twice now" [selected] - treeitem "Use the read tool twice now" + - treeitem "Use the read tool twice 1min" diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 904aeee0d8..7de69083ac 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -23,6 +23,10 @@ export interface AssistantMarkdownProps { interrupted?: boolean | undefined /** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */ time?: number | undefined + /** Event sequence used as the fork boundary; omitted while streaming. */ + seq?: number | undefined + /** Fork the session through the turn containing this finalized message. */ + onFork?: ((seq: number) => void) | undefined } function firstLine(text: string): string { @@ -60,7 +64,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, + blocks, streaming, interrupted, time, seq, onFork, }: AssistantMarkdownProps) { const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so @@ -91,6 +95,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ text={copyText(blocks)} time={time} clock="end" + onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} className={css.actions} /> )} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 68b9b71cb8..2941348eb1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -377,6 +377,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio streaming={false} interrupted={node.interrupted} time={node.time} + seq={node.seq} + onFork={forkAt} /> ) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 124371f2da..3075c258a6 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,5 +1,5 @@ // Shared IconActions chrome for user and assistant messages: copy live, -// branch wired through onBranch (stub without it), date-aware clock, +// branch wired through onBranch, date-aware clock, // optional edit stub. import { useCallback } from 'react' @@ -19,7 +19,7 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** When true, append the stub edit control (user bubble). */ edit?: boolean | undefined - /** Fork the session at this message; absent leaves the branch control a stub. */ + /** Fork the session at this message. */ onBranch?: (() => void) | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 960f954d61..59025b6b21 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -196,6 +196,16 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + 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() + const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(buttons).toHaveLength(2) + fireEvent.click(buttons[0]!) + fireEvent.click(buttons[1]!) + expect(h.forkAt.mock.calls).toEqual([[1], [2]]) + }) + 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)] })