From ebd3a87834fd163109ca9f1dd9e07478dac55d6a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 15:24:50 +0800 Subject: [PATCH] fix(web): stabilize compact card snapshots --- apps/web/tests/scaffold.ts | 8 ++++++-- .../seeded-history/command-row.expected.md | 4 +--- .../snapshots/seeded-history/ui.expected.md | 4 +--- .../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 | 2 +- .../src/client/chat/CompactionCommandCard.tsx | 4 ++-- .../src/client/chat/CompactionItem.tsx | 2 +- .../src/client/chat/chat-flow.ts | 1 - .../ui-conversation/tests/chat-view.spec.tsx | 19 +++++++++++++++++++ packages/ui/commands/tests/invariant.spec.ts | 2 +- 12 files changed, 36 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 11ba23a99f..6a8ff82ff1 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -620,8 +620,9 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id } /** - * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and - * decode-throughput volatility collapse to stable tokens. + * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, + * decode-throughput, and path-sensitive compaction estimates collapse to + * stable tokens. * * Throughput needs a token for the same reason durations do, and no fixture * can supply one: the figure divides a replayed step's output tokens by the @@ -648,6 +649,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { duration => duration.startsWith('约') ? duration : '{{duration}}', ) .replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}') + // Seeded compaction prices realized file paths, whose length differs + // between local worktrees and CI scratch directories. + .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2') // Message IconActions clocks widen by calendar day/year; collapse every // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 21b9cefeec..3aa9e2d738 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -31,9 +31,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "compact Compacted 5 history items (~247 tokens)": - - img - - text: compact Compacted 5 history items (~247 tokens) +- button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 2502d90f90..a30ae29e1e 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -31,9 +31,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "compact Compacted 5 history items (~247 tokens)": - - img - - text: compact Compacted 5 history items (~247 tokens) +- button "compact Compacted 5 history items (~{{tokens}} tokens)" - button "Context injection AGENTS.md": - img - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d29623d0f2..db7db5db5f 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: 985c78e97a8c7f46451252e67095c91e990aa694 -README.zh.md: 36a2f7a13c3f60692f9547ccf5a51ad6356d26d8 +README.md: 6b541b840ed67ee6fd735a0643dde8c60f1ec22d +README.zh.md: 01692c395cdb0f50e0fd41ab92f51d9e3ceecb4f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 985c78e97a..6b541b840e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key, showing the replaced-item and estimated-token counts and disclosing the summary on click. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. +Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable. The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 36a2f7a13c..01692c395c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,7 +4,7 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行,显示被替换条目数量和估算 token 数量,并可点击展开摘要。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b15bc20cdb..18fba234de 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -275,7 +275,7 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: t: ChatViewSlotProps['t'] }) { const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) - const fallback = node.name === 'compact' || compaction !== undefined + const fallback = node.name === 'compact' ? : return ( diff --git a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx index d2401e49c6..8012834541 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionCommandCard.tsx @@ -19,7 +19,7 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand return ( @@ -31,7 +31,7 @@ export function CompactionCommandCard({ node, compaction, t }: CompactionCommand t={t} variant="others" icon={} - title={node.name ?? 'compact'} + title="compact" summary={t('message.compaction.running')} body={null} state="running" diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx index 5e5f0c87b7..5bd5a081cf 100644 --- a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -21,7 +21,7 @@ interface CompactionItemProps { node: CompactionSummaryNode /** Optional command title for a manual compaction folded into this marker. */ title?: string - /** Command settlement text used only when the summary provenance page is absent. */ + /** Command settlement text used when structured compaction counts are unavailable. */ fallbackSummary?: string | null /** The owning view's locale seat. */ t: ChatViewSlotProps['t'] 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 22146ddb31..a9b12cbe2d 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -155,7 +155,6 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem for (const node of nodes) { if (rendersNothing(node)) continue if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { - group = null continue } if (node.kind === 'compaction') { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b16a0b9317..f42aed6731 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -249,6 +249,25 @@ describe('chat-flow derivation', () => { }) }) + it('does not split adjacent tool results around a folded /compact command', () => { + const folded = command({ + seq: 2, + commandId: 'cmd-compact' as CommandNode['commandId'], + name: 'compact', + outcome: { kind: 'success', sourceEventSeq: 4 }, + }) + const items = deriveChatFlow([ + toolResult(1, 'a'), + folded, + toolResult(3, 'b'), + compaction({ seq: 5, summaryEventSeq: 4 }), + ]) + expect(flowKeys(items)).toBe('g1|ccmd-compact') + expect( + items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId), + ).toEqual(['a', 'b']) + }) + it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') diff --git a/packages/ui/commands/tests/invariant.spec.ts b/packages/ui/commands/tests/invariant.spec.ts index 8772a3b71e..556be701e8 100644 --- a/packages/ui/commands/tests/invariant.spec.ts +++ b/packages/ui/commands/tests/invariant.spec.ts @@ -38,7 +38,7 @@ describe('command lifecycle invariants', () => { }).not.toThrow() }) - it.each([-1, 1.5, 1])('rejects invalid or command-owned sourceEventSeq %s', async (sourceEventSeq) => { + it.each([-1, 1.5, 1])('rejects invalid or non-prior sourceEventSeq %s', async (sourceEventSeq) => { const { session } = await mount() appendRun(session, 'cmd-invalid')