From cb748837f985fb936b11c54c5f10d5043ef61d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E9=91=AB?= <673638712@qq.com> Date: Sat, 9 May 2026 08:52:10 +0800 Subject: [PATCH 1/6] feat(web): group consecutive tool-use cards Add a web-only visible projection that groups consecutive root-level execution tools into expandable cards. Keep approval and question tools standalone, reuse older-history loading on expand, and add regression coverage for grouping and UI behavior. --- .../plans/2026-05-09-web-tool-use-grouping.md | 114 ++++++ web/src/chat/toolGroups.test.ts | 187 +++++++++ web/src/chat/toolGroups.ts | 272 ++++++++++++++ .../components/AssistantChat/HappyThread.tsx | 5 +- web/src/components/AssistantChat/context.tsx | 3 + .../AssistantChat/messages/ToolMessage.tsx | 21 ++ web/src/components/SessionChat.tsx | 18 +- web/src/components/ToolCard/ToolCard.tsx | 79 ++-- .../ToolCard/ToolGroupCard.test.tsx | 147 ++++++++ web/src/components/ToolCard/ToolGroupCard.tsx | 355 ++++++++++++++++++ web/src/lib/assistant-runtime.ts | 32 +- web/src/lib/locales/en.ts | 22 ++ web/src/lib/locales/zh-CN.ts | 22 ++ 13 files changed, 1237 insertions(+), 40 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-09-web-tool-use-grouping.md create mode 100644 web/src/chat/toolGroups.test.ts create mode 100644 web/src/chat/toolGroups.ts create mode 100644 web/src/components/ToolCard/ToolGroupCard.test.tsx create mode 100644 web/src/components/ToolCard/ToolGroupCard.tsx diff --git a/docs/superpowers/plans/2026-05-09-web-tool-use-grouping.md b/docs/superpowers/plans/2026-05-09-web-tool-use-grouping.md new file mode 100644 index 0000000000..d52cdc5aec --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-web-tool-use-grouping.md @@ -0,0 +1,114 @@ +# Web Tool Use Grouping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reduce chat noise in `web` by grouping root-level consecutive tool-use chains into expandable timeline cards while preserving chronological readability and complete detail access. + +**Architecture:** Keep the existing raw message fetch / pagination contract unchanged in phase A. Build a new Web-only visible projection layer on top of reconciled `ChatBlock[]`: contiguous eligible root-level execution tool calls become one `ToolGroupBlock`, always collapsed by default, while interactive tool cards such as approvals and user questions remain standalone timeline items and act as hard grouping boundaries. Historical groups that start at the current oldest visible boundary are marked as incomplete and, when expanded, automatically request older pages until the group boundary is complete or history ends. + +**Tech Stack:** React 19, TypeScript, assistant-ui external runtime, existing `ChatBlock` reducer pipeline, Tailwind UI, Vitest, Testing Library, project i18n dictionaries. + +--- + +## Agreed Product Decisions + +- Grouping boundary: same assistant-side contiguous root-level tool chain +- Default state: collapsed +- No auto-expand for grouped execution tools, including running / error states +- Interactive tool cards such as approval / `AskUserQuestion` / `request_user_input` stay standalone and break grouping +- Group header priority: target objects first (files, commands, touched targets), not raw tool counts first +- Expanded body: compact row list first; per row click opens detail +- History policy: grouped timeline should be retained; do not add a second-stage “keep only latest N groups” trim +- Phase A scope: do not change hub API semantics; optimize the Web visible layer first +- Incomplete historical group: auto-load older pages on expand + +## File Structure + +- New: `web/src/chat/toolGroups.ts` — group eligible root-level tool calls into visible `ToolGroupBlock`s; compute summary, auto-open state, and incomplete-history markers +- New: `web/src/chat/toolGroups.test.ts` — grouping boundary, eligibility, stable-id, auto-open, and incomplete-history regression tests +- New: `web/src/components/ToolCard/ToolGroupCard.tsx` — expandable grouped tool card with compact rows, inline interactive rows, and auto-hydration state +- New: `web/src/components/ToolCard/ToolGroupCard.test.tsx` — grouped card rendering / expansion / loading / interactive-row regressions +- Modify: `web/src/lib/assistant-runtime.ts` — accept grouped visible blocks and emit grouped tool artifacts to assistant-ui +- Modify: `web/src/components/AssistantChat/messages/ToolMessage.tsx` — render grouped artifact vs single tool artifact +- Modify: `web/src/components/AssistantChat/context.tsx` — expose older-history loader + has-more state to grouped tool UI +- Modify: `web/src/components/AssistantChat/HappyThread.tsx` — provide scroll-preserving older-page loader that grouped cards can reuse when expanding incomplete history +- Modify: `web/src/components/SessionChat.tsx` — project reconciled blocks into grouped visible blocks before building runtime / outline state +- Modify: `web/src/lib/locales/en.ts` — grouped tool card copy +- Modify: `web/src/lib/locales/zh-CN.ts` — grouped tool card copy + +## Eligibility Rules + +The grouping layer should group only **root-level, non-subagent, non-plan, non-summary** tool cards that primarily represent execution noise: + +- Include: read/search/bash/edit/write/mcp-like execution tools and equivalent plain tool cards +- Exclude: subagent launch / wait / close cards, plan/update-plan cards, task/team orchestration cards, and other cards that already act as high-signal standalone milestones +- Keep single eligible tool cards standalone; only collapse runs with length `>= 2` +- Interactive rows (`pending permission`, `AskUserQuestion`, `request_user_input`) are hard boundaries: they stay standalone and are never absorbed into an execution-tool group + +### Task 1: Visible grouping projection + +**Files:** +- Create: `web/src/chat/toolGroups.ts` +- Test: `web/src/chat/toolGroups.test.ts` +- Modify: `web/src/components/SessionChat.tsx` + +- [ ] Define `ToolGroupBlock` and `VisibleChatBlock` in `toolGroups.ts` rather than mutating the core reducer `ChatBlock` union +- [ ] Add `isEligibleForToolGrouping(block: ToolCallBlock): boolean` with the agreed exclusions (`isSubagentToolName`, plan-like cards, other milestone cards) +- [ ] Add `buildVisibleChatBlocks(blocks, options)` that scans reconciled root blocks once, groups contiguous eligible tool runs, and leaves every other block unchanged +- [ ] Use `firstToolId` as the stable UI key for normal groups and `lastToolId` as the stable UI key when the group is truncated on the older-history edge, so append and prepend flows do not constantly remount the card +- [ ] Compute `historyState` / `needsOlderHistory` only for the oldest visible grouped run when `hasMoreMessages === true`; do not mark mid-thread groups incomplete +- [ ] Set grouped execution-tool cards to collapsed-by-default with no state-based auto-open behavior +- [ ] Compute summary metadata that favors targets first: touched file paths, command previews, URL / query labels, then fallback to tool names and counts +- [ ] Wire `SessionChat` to build grouped visible blocks **after** `reconcileChatBlocks(...)` and use grouped blocks for assistant runtime rendering while keeping the existing outline source behavior unchanged for user-message anchors + +### Task 2: Grouped card UI + detail access + +**Files:** +- Create: `web/src/components/ToolCard/ToolGroupCard.tsx` +- Test: `web/src/components/ToolCard/ToolGroupCard.test.tsx` +- Modify: `web/src/components/AssistantChat/messages/ToolMessage.tsx` +- Modify: `web/src/lib/assistant-runtime.ts` +- Modify: `web/src/lib/locales/en.ts` +- Modify: `web/src/lib/locales/zh-CN.ts` + +- [ ] Extend the assistant runtime converter so a grouped visible block becomes one assistant-ui tool message whose artifact is the full `ToolGroupBlock` +- [ ] In `ToolMessage.tsx`, branch on artifact shape: existing `ToolCallBlock` path stays untouched; new grouped artifact path renders `ToolGroupCard` +- [ ] Render the collapsed header with target-centric copy such as “3 files read”, “2 commands”, “edited `foo.ts` +2”, plus status badges for running / error / pending +- [ ] Keep the header clickable; collapsed state should not render heavy input/result payloads into the visible DOM +- [ ] Expanded state should render a compact row list first; each non-interactive row opens a dialog that reuses existing single-tool detail rendering expectations (input, trace, result) +- [ ] Ensure approval / question tool cards continue through the existing standalone rendering path and never appear inside grouped execution-tool cards +- [ ] Add i18n keys for grouped card labels: tool activity, load details, loading older details, incomplete history, more rows, row status labels, and empty fallbacks + +### Task 3: Auto-load older history for incomplete groups + +**Files:** +- Modify: `web/src/components/AssistantChat/context.tsx` +- Modify: `web/src/components/AssistantChat/HappyThread.tsx` +- Modify: `web/src/components/ToolCard/ToolGroupCard.tsx` +- Modify: `web/src/components/SessionChat.tsx` + +- [ ] Extend `HappyChatContextValue` with the minimal grouped-history contract, e.g. `hasMoreMessages`, `loadOlderForToolGroup(anchorId)` and any loading flag needed by the card +- [ ] Reuse `HappyThread`’s existing scroll-preserving older-page loader instead of inventing a second pagination path +- [ ] Implement `loadOlderForToolGroup(anchorId)` so one expansion can loop page-by-page until the matching grouped run is no longer marked `needsOlderHistory` or `hasMoreMessages` becomes false +- [ ] Preserve user scroll anchor during auto-hydration, exactly like manual “load older” already does +- [ ] Preserve the group’s open state while older pages prepend; the stable-id strategy from Task 1 should keep React state from collapsing on every hydration step +- [ ] Surface a small inline loading affordance while older details are being hydrated; if history ends and the group is still partial, replace the loader with a terminal hint instead of retrying forever + +### Task 4: Verification + +**Files:** +- No additional source files beyond the tests above unless verification exposes defects + +- [ ] Add regression coverage in `web/src/chat/toolGroups.test.ts` for: boundary splitting on assistant text, single-tool passthrough, exclusion of subagent / plan / interactive cards, collapsed-default behavior, target-summary extraction, and incomplete oldest-group detection +- [ ] Add UI coverage in `web/src/components/ToolCard/ToolGroupCard.test.tsx` for: collapsed target-first header, expand/collapse behavior, compact row rendering, standalone interactive-card separation, and auto-load indicator states +- [ ] If the grouped-history loader touches thread behavior, extend `web/src/components/AssistantChat/HappyThread.test.tsx` with one regression that verifies grouped expansion reuses scroll-preserving older loads +- [ ] Run: `cd web && bun run test -- src/chat/toolGroups.test.ts src/components/ToolCard/ToolGroupCard.test.tsx src/components/AssistantChat/HappyThread.test.tsx` +- [ ] Run: `cd web && bun run typecheck` +- [ ] Manual smoke in browser: long read/search/edit chain collapses into one card and stays collapsed by default; approval / question cards remain standalone; expanding an oldest historical group auto-fetches older pages and keeps scroll stable + +## Notes / Non-Goals for Phase A + +- Do **not** change hub `/messages` API shape, pagination cursor format, or server-side persistence in this task +- Do **not** add a second trimming rule that discards older grouped tool runs after grouping +- Do **not** refactor nested subagent child timelines in this change; keep grouping limited to root-level Web chat noise +- Do **not** create one-off temporary tests; keep only durable regression coverage that protects grouping, interaction, and history loading behavior diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts new file mode 100644 index 0000000000..cdd1606802 --- /dev/null +++ b/web/src/chat/toolGroups.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import type { ChatBlock, ToolCallBlock } from '@/chat/types' +import { buildVisibleChatBlocks, getToolGroupActionKind, isEligibleForToolGrouping, isToolGroupBlock } from '@/chat/toolGroups' + +function makeToolBlock( + id: string, + name: string, + input: unknown = {}, + overrides: Partial = {} +): ToolCallBlock { + return { + kind: 'tool-call', + id, + localId: null, + createdAt: 1, + invokedAt: null, + tool: { + id, + name, + state: 'completed', + input, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: null, + permission: undefined, + }, + children: [], + ...overrides, + } +} + +function makeTextBlock(id: string, text = 'note'): ChatBlock { + return { + kind: 'agent-text', + id, + localId: null, + createdAt: 1, + text, + } +} + +describe('getToolGroupActionKind', () => { + it('classifies common execution tools', () => { + expect(getToolGroupActionKind(makeToolBlock('read-1', 'Read'))).toBe('read') + expect(getToolGroupActionKind(makeToolBlock('grep-1', 'Grep'))).toBe('search') + expect(getToolGroupActionKind(makeToolBlock('bash-1', 'Bash'))).toBe('command') + expect(getToolGroupActionKind(makeToolBlock('edit-1', 'Edit'))).toBe('mutation') + }) +}) + +describe('isEligibleForToolGrouping', () => { + it('excludes interactive, subagent, and plan cards', () => { + expect(isEligibleForToolGrouping(makeToolBlock('read-1', 'Read'))).toBe(true) + expect(isEligibleForToolGrouping(makeToolBlock('task-1', 'Task'))).toBe(false) + expect(isEligibleForToolGrouping(makeToolBlock('plan-1', 'update_plan'))).toBe(false) + expect(isEligibleForToolGrouping(makeToolBlock('ask-1', 'AskUserQuestion'))).toBe(false) + expect(isEligibleForToolGrouping(makeToolBlock('perm-1', 'Bash', {}, { + tool: { + id: 'perm-1', + name: 'Bash', + state: 'pending', + input: {}, + createdAt: 1, + startedAt: null, + completedAt: null, + description: null, + permission: { + id: 'perm-1', + status: 'pending' + } + } + }))).toBe(false) + }) +}) + +describe('buildVisibleChatBlocks', () => { + it('groups contiguous eligible root tool cards', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + ], { hasMoreMessages: false }) + + expect(visible).toHaveLength(1) + expect(isToolGroupBlock(visible[0])).toBe(true) + if (!isToolGroupBlock(visible[0])) { + throw new Error('expected tool group') + } + expect(visible[0].tools.map((tool) => tool.id)).toEqual(['read-1', 'bash-1', 'edit-1']) + expect(visible[0].defaultOpen).toBe(false) + expect(visible[0].summary.fileTargets).toEqual(['src/a.ts']) + expect(visible[0].summary.commandTargets).toEqual(['bun test']) + }) + + it('splits groups on assistant text boundaries', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + makeTextBlock('text-1', 'located the issue'), + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + makeToolBlock('write-1', 'Write', { file_path: 'src/b.ts' }), + ], { hasMoreMessages: false }) + + expect(visible).toHaveLength(3) + expect(isToolGroupBlock(visible[0])).toBe(true) + expect(visible[1].kind).toBe('agent-text') + expect(isToolGroupBlock(visible[2])).toBe(true) + }) + + it('keeps single eligible tool cards standalone', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeTextBlock('text-1'), + makeToolBlock('edit-1', 'Edit', { file_path: 'src/b.ts' }), + ], { hasMoreMessages: false }) + + expect(visible).toHaveLength(3) + expect(visible.every((block) => !isToolGroupBlock(block))).toBe(true) + }) + + it('keeps interactive cards standalone and uses them as hard boundaries', () => { + const interactive = makeToolBlock('ask-1', 'request_user_input') + const visible = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + interactive, + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + makeToolBlock('write-1', 'Write', { file_path: 'src/b.ts' }), + ], { hasMoreMessages: false }) + + expect(visible).toHaveLength(3) + expect(isToolGroupBlock(visible[0])).toBe(true) + expect(visible[1]).toBe(interactive) + expect(isToolGroupBlock(visible[2])).toBe(true) + }) + + it('marks only the oldest visible grouped run as needing older history', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + makeTextBlock('text-1'), + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + makeToolBlock('write-1', 'Write', { file_path: 'src/b.ts' }), + ], { hasMoreMessages: true }) + + expect(isToolGroupBlock(visible[0]) && visible[0].needsOlderHistory).toBe(true) + expect(isToolGroupBlock(visible[2]) && visible[2].needsOlderHistory).toBe(false) + }) + + it('reuses a previous group id when the first tool changes after prepend', () => { + const previous = buildVisibleChatBlocks([ + makeToolBlock('read-2', 'Read', { file_path: 'src/b.ts' }), + makeToolBlock('bash-2', 'Bash', { command: 'bun test' }), + ], { hasMoreMessages: true }) + + const next = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('read-2', 'Read', { file_path: 'src/b.ts' }), + makeToolBlock('bash-2', 'Bash', { command: 'bun test' }), + ], { + hasMoreMessages: false, + previousGroups: previous.filter(isToolGroupBlock) + }) + + expect(isToolGroupBlock(previous[0]) && isToolGroupBlock(next[0]) && previous[0].id === next[0].id).toBe(true) + }) + + it('reuses a previous group id when the last tool changes after append', () => { + const previous = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + ], { hasMoreMessages: false }) + + const next = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + ], { + hasMoreMessages: false, + previousGroups: previous.filter(isToolGroupBlock) + }) + + expect(isToolGroupBlock(previous[0]) && isToolGroupBlock(next[0]) && previous[0].id === next[0].id).toBe(true) + }) +}) \ No newline at end of file diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts new file mode 100644 index 0000000000..628ba7e322 --- /dev/null +++ b/web/src/chat/toolGroups.ts @@ -0,0 +1,272 @@ +import type { ChatBlock, ToolCallBlock } from '@/chat/types' +import { isSubagentToolName } from '@/chat/subagentTool' +import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion' +import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput' +import { getInputStringAny } from '@/lib/toolInputUtils' + +export type ToolGroupActionKind = 'read' | 'search' | 'command' | 'mutation' | 'web' | 'other' + +export type ToolGroupSummary = { + totalTools: number + countsByKind: Record + fileTargets: string[] + commandTargets: string[] + searchTargets: string[] + urlTargets: string[] + otherTargets: string[] + errorCount: number + runningCount: number + pendingCount: number +} + +export type ToolGroupBlock = { + kind: 'tool-group' + id: string + createdAt: number + invokedAt?: number | null + firstToolId: string + lastToolId: string + tools: ToolCallBlock[] + defaultOpen: boolean + historyState: 'complete' | 'needs-older-history' + needsOlderHistory: boolean + summary: ToolGroupSummary +} + +export type VisibleChatBlock = ChatBlock | ToolGroupBlock + +type ToolGroupingOptions = { + hasMoreMessages: boolean + previousGroups?: ToolGroupBlock[] +} + +const PLAN_TOOL_NAMES = new Set([ + 'TodoWrite', + 'update_plan', + 'ExitPlanMode', + 'exit_plan_mode', + 'CodexReasoning' +]) + +const MILESTONE_TOOL_NAMES = new Set([ + 'Task', + 'Agent', + 'CodexAgent', + 'TeamCreate', + 'TeamDelete', + 'SendMessage', + 'Skill', + 'spawn_agent', + 'send_input', + 'resume_agent', + 'wait_agent', + 'close_agent' +]) + +function pushUnique(target: string[], value: string | null): void { + if (!value) return + if (target.includes(value)) return + target.push(value) +} + +function normalizeCommandInput(input: unknown): string | null { + const direct = getInputStringAny(input, ['command', 'cmd']) + if (direct) return direct + + if (!input || typeof input !== 'object') return null + const command = (input as { command?: unknown }).command + if (!Array.isArray(command)) return null + + const parts = command.filter((part): part is string => typeof part === 'string' && part.length > 0) + return parts.length > 0 ? parts.join(' ') : null +} + +export function getToolGroupActionKind(block: ToolCallBlock): ToolGroupActionKind { + const name = block.tool.name + + if (name === 'Read' || name === 'NotebookRead') return 'read' + if (name === 'Grep' || name === 'Glob' || name === 'LS') return 'search' + if (name === 'Bash' || name === 'CodexBash' || name === 'shell_command') return 'command' + if (name === 'Edit' || name === 'MultiEdit' || name === 'Write' || name === 'NotebookEdit' || name === 'CodexPatch' || name === 'CodexDiff') { + return 'mutation' + } + if (name === 'WebFetch' || name === 'WebSearch') return 'web' + return 'other' +} + +function getPrimaryFileTarget(block: ToolCallBlock): string | null { + return getInputStringAny(block.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path', 'name']) +} + +function getPrimarySearchTarget(block: ToolCallBlock): string | null { + return getInputStringAny(block.tool.input, ['pattern', 'query']) +} + +function getPrimaryUrlTarget(block: ToolCallBlock): string | null { + return getInputStringAny(block.tool.input, ['url']) +} + +function getPrimaryOtherTarget(block: ToolCallBlock): string | null { + const fileTarget = getPrimaryFileTarget(block) + if (fileTarget) return fileTarget + + const searchTarget = getPrimarySearchTarget(block) + if (searchTarget) return searchTarget + + const commandTarget = normalizeCommandInput(block.tool.input) + if (commandTarget) return commandTarget + + const urlTarget = getPrimaryUrlTarget(block) + if (urlTarget) return urlTarget + + return block.tool.name +} + +function summarizeToolGroup(tools: ToolCallBlock[]): ToolGroupSummary { + const countsByKind: Record = { + read: 0, + search: 0, + command: 0, + mutation: 0, + web: 0, + other: 0 + } + const fileTargets: string[] = [] + const commandTargets: string[] = [] + const searchTargets: string[] = [] + const urlTargets: string[] = [] + const otherTargets: string[] = [] + let errorCount = 0 + let runningCount = 0 + let pendingCount = 0 + + for (const tool of tools) { + const kind = getToolGroupActionKind(tool) + countsByKind[kind] += 1 + + if (tool.tool.state === 'error') { + errorCount += 1 + } else if (tool.tool.state === 'running') { + runningCount += 1 + } else if (tool.tool.state === 'pending') { + pendingCount += 1 + } + + if (kind === 'read' || kind === 'mutation') { + pushUnique(fileTargets, getPrimaryFileTarget(tool)) + continue + } + if (kind === 'search') { + pushUnique(searchTargets, getPrimarySearchTarget(tool)) + continue + } + if (kind === 'command') { + pushUnique(commandTargets, normalizeCommandInput(tool.tool.input)) + continue + } + if (kind === 'web') { + pushUnique(urlTargets, getPrimaryUrlTarget(tool) ?? getPrimarySearchTarget(tool)) + continue + } + pushUnique(otherTargets, getPrimaryOtherTarget(tool)) + } + + return { + totalTools: tools.length, + countsByKind, + fileTargets, + commandTargets, + searchTargets, + urlTargets, + otherTargets, + errorCount, + runningCount, + pendingCount, + } +} + +function isInteractiveToolBlock(block: ToolCallBlock): boolean { + return Boolean(block.tool.permission) + || isAskUserQuestionToolName(block.tool.name) + || isRequestUserInputToolName(block.tool.name) +} + +export function isEligibleForToolGrouping(block: ToolCallBlock): boolean { + if (isSubagentToolName(block.tool.name)) return false + if (PLAN_TOOL_NAMES.has(block.tool.name)) return false + if (MILESTONE_TOOL_NAMES.has(block.tool.name)) return false + if (isInteractiveToolBlock(block)) return false + return true +} + +function createToolGroupId( + tools: ToolCallBlock[], + needsOlderHistory: boolean, + previousGroups: ToolGroupBlock[] +): string { + const firstToolId = tools[0]?.id ?? 'unknown' + const lastToolId = tools[tools.length - 1]?.id ?? firstToolId + + const previous = previousGroups.find((group) => group.firstToolId === firstToolId || group.lastToolId === lastToolId) + if (previous) { + return previous.id + } + + return needsOlderHistory + ? `tool-group:${lastToolId}` + : `tool-group:${firstToolId}` +} + +export function isToolGroupBlock(block: VisibleChatBlock | ChatBlock): block is ToolGroupBlock { + return block.kind === 'tool-group' +} + +export function buildVisibleChatBlocks( + blocks: ChatBlock[], + options: ToolGroupingOptions +): VisibleChatBlock[] { + const visibleBlocks: VisibleChatBlock[] = [] + const previousGroups = options.previousGroups ?? [] + + for (let index = 0; index < blocks.length; index += 1) { + const block = blocks[index] + if (block.kind !== 'tool-call' || !isEligibleForToolGrouping(block)) { + visibleBlocks.push(block) + continue + } + + const tools: ToolCallBlock[] = [block] + let cursor = index + 1 + while (cursor < blocks.length) { + const candidate = blocks[cursor] + if (candidate.kind !== 'tool-call' || !isEligibleForToolGrouping(candidate)) { + break + } + tools.push(candidate) + cursor += 1 + } + + if (tools.length < 2) { + visibleBlocks.push(block) + continue + } + + const needsOlderHistory = options.hasMoreMessages && index === 0 + visibleBlocks.push({ + kind: 'tool-group', + id: createToolGroupId(tools, needsOlderHistory, previousGroups), + createdAt: tools[0].createdAt, + invokedAt: tools[0].invokedAt, + firstToolId: tools[0].id, + lastToolId: tools[tools.length - 1].id, + tools, + defaultOpen: false, + historyState: needsOlderHistory ? 'needs-older-history' : 'complete', + needsOlderHistory, + summary: summarizeToolGroup(tools) + }) + index = cursor - 1 + } + + return visibleBlocks +} \ No newline at end of file diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 8c4d1dbeb0..71b6e5713c 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -678,7 +678,10 @@ export function HappyThread(props: { metadata: props.metadata, disabled: props.disabled, onRefresh: props.onRefresh, - onRetryMessage: props.onRetryMessage + onRetryMessage: props.onRetryMessage, + hasMoreMessages: props.hasMoreMessages, + isLoadingMoreMessages: props.isLoadingMoreMessages, + loadOlderMessagesPreservingScroll: loadOlderPreservingScroll }}> void onRetryMessage?: (localId: string) => void + hasMoreMessages: boolean + isLoadingMoreMessages: boolean + loadOlderMessagesPreservingScroll: () => Promise } const HappyChatContext = createContext(null) diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index fb0ce38b03..76cc1c5c0e 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,8 +1,10 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { ToolCallBlock } from '@/chat/types' +import type { ToolGroupBlock } from '@/chat/toolGroups' import { isObject, safeStringify } from '@hapi/protocol' import { isSubagentToolName } from '@/chat/subagentTool' +import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard' import { getEventPresentation } from '@/chat/presentation' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' @@ -27,6 +29,14 @@ function isToolCallBlock(value: unknown): value is ToolCallBlock { return true } +function isToolGroupBlock(value: unknown): value is ToolGroupBlock { + if (!isObject(value)) return false + if (value.kind !== 'tool-group') return false + if (typeof value.id !== 'string') return false + if (!Array.isArray(value.tools)) return false + return true +} + function isPendingPermissionBlock(block: ChatBlock): boolean { return block.kind === 'tool-call' && block.tool.permission?.status === 'pending' } @@ -163,6 +173,17 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { const ctx = useHappyChatContext() const artifact = props.artifact + if (isToolGroupBlock(artifact)) { + return ( +
+ +
+ ) + } + if (!isToolCallBlock(artifact)) { const argsText = typeof props.argsText === 'string' ? props.argsText.trim() : '' const hasArgsText = argsText.length > 0 diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 5561b05bfd..e89868289e 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -16,6 +16,7 @@ import { normalizeDecryptedMessage } from '@/chat/normalize' import { reduceChatBlocks } from '@/chat/reducer' import { reconcileChatBlocks } from '@/chat/reconcile' import { buildConversationOutline } from '@/chat/outline' +import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups' import { isQueuedForInvocation } from '@/lib/messages' import { HappyComposer } from '@/components/AssistantChat/HappyComposer' import { HappyThread } from '@/components/AssistantChat/HappyThread' @@ -74,6 +75,7 @@ export function SessionChat(props: { const terminalSupported = isRemoteTerminalSupported(props.session.metadata) const normalizedCacheRef = useRef>(new Map()) const blocksByIdRef = useRef>(new Map()) + const visibleGroupsRef = useRef([]) const [forceScrollToken, setForceScrollToken] = useState(0) const [outlineOpen, setOutlineOpen] = useState(false) const agentFlavor = props.session.metadata?.flavor ?? null @@ -224,6 +226,7 @@ export function SessionChat(props: { useEffect(() => { normalizedCacheRef.current.clear() blocksByIdRef.current.clear() + visibleGroupsRef.current = [] setOutlineOpen(false) }, [props.session.id]) @@ -241,6 +244,7 @@ export function SessionChat(props: { if (prevSessionIdRef.current !== null && prevSessionIdRef.current !== props.session.id) { normalizedCacheRef.current.clear() blocksByIdRef.current.clear() + visibleGroupsRef.current = [] } prevSessionIdRef.current = props.session.id @@ -279,6 +283,18 @@ export function SessionChat(props: { blocksByIdRef.current = reconciled.byId }, [reconciled.byId]) + const visibleBlocks = useMemo( + () => buildVisibleChatBlocks(reconciled.blocks, { + hasMoreMessages: props.hasMoreMessages, + previousGroups: visibleGroupsRef.current + }), + [reconciled.blocks, props.hasMoreMessages] + ) + + useEffect(() => { + visibleGroupsRef.current = visibleBlocks.filter(isToolGroupBlock) + }, [visibleBlocks]) + const outlineItems = useMemo( () => buildConversationOutline(reconciled.blocks), [reconciled.blocks] @@ -386,7 +402,7 @@ export function SessionChat(props: { const runtime = useHappyRuntime({ session: props.session, - blocks: reconciled.blocks, + blocks: visibleBlocks, isSending: props.isSending, onSendMessage: handleSend, onAbort: handleAbort, diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 529d346ccd..82049581bb 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -208,7 +208,7 @@ function renderToolInput(block: ToolCallBlock, surface: 'inline' | 'dialog' = 'i return } -function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { +export function ToolStatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { if (props.state === 'completed') { return ( @@ -241,7 +241,7 @@ function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { ) } -function statusColorClass(state: ToolCallBlock['tool']['state']): string { +export function toolStatusColorClass(state: ToolCallBlock['tool']['state']): string { if (state === 'completed') return 'text-emerald-600' if (state === 'error') return 'text-red-600' if (state === 'pending') return 'text-amber-600' @@ -265,6 +265,45 @@ type ToolCardProps = { block: ToolCallBlock } +export function ToolDetailDialogContent(props: { + block: ToolCallBlock + metadata: SessionMetadataSummary | null +}) { + const { t } = useTranslation() + const toolName = props.block.tool.name + const FullToolView = getToolFullViewComponent(toolName) + const ResultToolView = getToolResultViewComponent(toolName) + const permission = props.block.tool.permission + const isAskUserQuestion = isAskUserQuestionToolName(toolName) + const isRequestUserInput = isRequestUserInputToolName(toolName) + const isQuestionTool = isAskUserQuestion || isRequestUserInput + const isQuestionToolWithAnswers = isQuestionTool + && permission?.answers + && Object.keys(permission.answers).length > 0 + + return ( +
+
+
+ {isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')} +
+ {FullToolView ? ( + + ) : ( + renderToolInput(props.block, 'dialog') + )} +
+ + {!isQuestionToolWithAnswers ? ( +
+
{t('tool.result')}
+ +
+ ) : null} +
+ ) +} + function ToolCardInner(props: ToolCardProps) { const { t } = useTranslation() const presentation = useMemo(() => getToolPresentation({ @@ -291,19 +330,17 @@ function ToolCardInner(props: ToolCardProps) { const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt const showInline = !presentation.minimal && !isSubagentToolName(toolName) const CompactToolView = showInline ? getToolViewComponent(toolName) : null - const FullToolView = getToolFullViewComponent(toolName) const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) - const isQuestionTool = isAskUserQuestion || isRequestUserInput const isCodexAgentCard = toolName === 'CodexAgent' const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' || ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason)) )) const hasBody = showInline || taskSummary !== null || showsPermissionFooter - const stateColor = statusColorClass(props.block.tool.state) + const stateColor = toolStatusColorClass(props.block.tool.state) const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing() const header = ( @@ -337,7 +374,7 @@ function ToolCardInner(props: ToolCardProps) { )}> - + @@ -364,37 +401,11 @@ function ToolCardInner(props: ToolCardProps) { {header} - + {toolTitle} - {(() => { - const isQuestionToolWithAnswers = isQuestionTool - && permission?.answers - && Object.keys(permission.answers).length > 0 - - return ( -
-
-
- {isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')} -
- {FullToolView ? ( - - ) : ( - renderToolInput(props.block, 'dialog') - )} -
- - {!isQuestionToolWithAnswers && ( -
-
{t('tool.result')}
- -
- )} -
- ) - })()} +
diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx new file mode 100644 index 0000000000..351055f81e --- /dev/null +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -0,0 +1,147 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ToolCallBlock } from '@/chat/types' +import type { ToolGroupBlock } from '@/chat/toolGroups' +import { HappyChatProvider } from '@/components/AssistantChat/context' +import { ToolGroupCard } from '@/components/ToolCard/ToolGroupCard' +import { I18nProvider } from '@/lib/i18n-context' + +function makeToolBlock(id: string, name: string, input: unknown = {}): ToolCallBlock { + return { + kind: 'tool-call', + id, + localId: null, + createdAt: 1, + invokedAt: null, + tool: { + id, + name, + state: 'completed', + input, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: { content: 'done' }, + permission: undefined, + }, + children: [], + } +} + +function makeGroup(overrides: Partial = {}): ToolGroupBlock { + const tools = overrides.tools ?? [ + makeToolBlock('read-1', 'Read', { file_path: 'repo/src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }) + ] + return { + kind: 'tool-group', + id: 'tool-group:read-1', + createdAt: 1, + invokedAt: null, + firstToolId: tools[0].id, + lastToolId: tools[tools.length - 1].id, + tools, + defaultOpen: false, + historyState: 'complete', + needsOlderHistory: false, + summary: { + totalTools: tools.length, + countsByKind: { + read: 1, + search: 0, + command: 1, + mutation: 0, + web: 0, + other: 0, + }, + fileTargets: ['repo/src/a.ts'], + commandTargets: ['bun test'], + searchTargets: [], + urlTargets: [], + otherTargets: [], + errorCount: 0, + runningCount: 0, + pendingCount: 0, + }, + ...overrides, + } +} + +function renderCard(block: ToolGroupBlock, options?: { loadOlder?: () => Promise; hasMore?: boolean; isLoadingMore?: boolean }) { + const loadOlderMessagesPreservingScroll = options?.loadOlder ?? vi.fn(async () => false) + return render( + + + + + + ) +} + +describe('ToolGroupCard', () => { + afterEach(() => { + cleanup() + }) + + it('renders a collapsed target-first header', () => { + renderCard(makeGroup()) + + expect(screen.getByRole('button', { name: /src\/a.ts/i })).toBeInTheDocument() + expect(screen.getByText('Read 1 · Run 1')).toBeInTheDocument() + expect(screen.queryByText('2 tool calls')).not.toBeInTheDocument() + }) + + it('expands to show compact rows and opens a detail dialog per row', async () => { + const view = renderCard(makeGroup()) + const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + + fireEvent.click(groupToggle) + expect(screen.getByText('2 tool calls')).toBeInTheDocument() + + const firstRowButton = within(view.container) + .getAllByRole('button') + .find((button) => button !== groupToggle) + + expect(firstRowButton).toBeDefined() + fireEvent.click(firstRowButton!) + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + const dialog = screen.getByRole('dialog') + expect(screen.getAllByText('src/a.ts')[0]).toBeInTheDocument() + expect(within(dialog).getAllByText('Input').length).toBeGreaterThan(0) + expect(within(dialog).getAllByText('Result').length).toBeGreaterThan(0) + }) + + it('auto-loads older history after expand when the group is incomplete', async () => { + const loadOlder = vi.fn(async () => false) + const block = makeGroup({ + id: 'tool-group:bash-1', + historyState: 'needs-older-history', + needsOlderHistory: true, + }) + + const view = renderCard(block, { loadOlder, hasMore: true }) + const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + + fireEvent.click(groupToggle) + + await waitFor(() => { + expect(loadOlder).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(screen.getByText('Earlier tool activity is unavailable.')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx new file mode 100644 index 0000000000..09e0960c47 --- /dev/null +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -0,0 +1,355 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ToolGroupBlock } from '@/chat/toolGroups' +import type { ToolCallBlock } from '@/chat/types' +import type { SessionMetadataSummary } from '@/types/api' +import { useHappyChatContext } from '@/components/AssistantChat/context' +import { ToolDetailDialogContent, ToolStatusIcon, toolStatusColorClass } from '@/components/ToolCard/ToolCard' +import { getToolPresentation } from '@/components/ToolCard/knownTools' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { basename, resolveDisplayPath } from '@/utils/path' +import { getInputStringAny, truncate } from '@/lib/toolInputUtils' +import { cn } from '@/lib/utils' +import { useTranslation } from '@/lib/use-translation' + +function DetailsIcon() { + return ( + + + + ) +} + +function SummaryBadge(props: { className: string; text: string }) { + return ( + + {props.text} + + ) +} + +function RowStatusBadge(props: { block: ToolCallBlock }) { + const { t } = useTranslation() + if (props.block.tool.state === 'error') { + return + } + if (props.block.tool.state === 'running') { + return + } + if (props.block.tool.state === 'pending') { + return + } + return null +} + +function formatPrimaryTitle(block: ToolGroupBlock, metadata: SessionMetadataSummary | null, t: (key: string, params?: Record) => string): string { + const fileTargets = block.summary.fileTargets + if (fileTargets.length > 0) { + const display = resolveDisplayPath(fileTargets[0], metadata) + return fileTargets.length === 1 + ? display + : t('toolGroup.primary.fileTargets', { target: display, n: fileTargets.length - 1 }) + } + + const commandTargets = block.summary.commandTargets + if (commandTargets.length > 0) { + const command = truncate(commandTargets[0], 72) + return commandTargets.length === 1 + ? command + : t('toolGroup.primary.commandTargets', { target: command, n: commandTargets.length - 1 }) + } + + const searchTargets = block.summary.searchTargets + if (searchTargets.length > 0) { + const target = truncate(searchTargets[0], 72) + return searchTargets.length === 1 + ? target + : t('toolGroup.primary.searchTargets', { target, n: searchTargets.length - 1 }) + } + + const urlTargets = block.summary.urlTargets + if (urlTargets.length > 0) { + const target = truncate(urlTargets[0], 72) + return urlTargets.length === 1 + ? target + : t('toolGroup.primary.urlTargets', { target, n: urlTargets.length - 1 }) + } + + const otherTargets = block.summary.otherTargets + if (otherTargets.length > 0) { + const target = truncate(otherTargets[0], 72) + return otherTargets.length === 1 + ? target + : t('toolGroup.primary.otherTargets', { target, n: otherTargets.length - 1 }) + } + + return t('toolGroup.title') +} + +function formatActionSummary(block: ToolGroupBlock, t: (key: string, params?: Record) => string): string | null { + const parts: string[] = [] + const { countsByKind } = block.summary + + if (countsByKind.mutation > 0) { + parts.push(t('toolGroup.summary.mutation', { n: countsByKind.mutation })) + } + if (countsByKind.read > 0) { + parts.push(t('toolGroup.summary.read', { n: countsByKind.read })) + } + if (countsByKind.command > 0) { + parts.push(t('toolGroup.summary.command', { n: countsByKind.command })) + } + if (countsByKind.search > 0) { + parts.push(t('toolGroup.summary.search', { n: countsByKind.search })) + } + if (countsByKind.web > 0) { + parts.push(t('toolGroup.summary.web', { n: countsByKind.web })) + } + if (countsByKind.other > 0) { + parts.push(t('toolGroup.summary.other', { n: countsByKind.other })) + } + + return parts.length > 0 ? parts.join(' · ') : null +} + +function RowLabel(props: { block: ToolCallBlock; metadata: SessionMetadataSummary | null }) { + const { t } = useTranslation() + const presentation = useMemo(() => getToolPresentation({ + toolName: props.block.tool.name, + input: props.block.tool.input, + result: props.block.tool.result, + childrenCount: props.block.children.length, + description: props.block.tool.description, + metadata: props.metadata + }, t), [props.block, props.metadata, t]) + + return ( +
+
+
+ {presentation.icon} +
+
+ {presentation.title} +
+
+ {presentation.subtitle ? ( +
+ {truncate(presentation.subtitle, 120)} +
+ ) : null} +
+ ) +} + +export function ToolGroupCard(props: { + block: ToolGroupBlock + metadata: SessionMetadataSummary | null +}) { + const { t } = useTranslation() + const ctx = useHappyChatContext() + const [open, setOpen] = useState(props.block.defaultOpen) + const [selectedToolId, setSelectedToolId] = useState(null) + const [isHydratingHistory, setIsHydratingHistory] = useState(false) + const [historyExhausted, setHistoryExhausted] = useState(false) + + useEffect(() => { + setOpen(props.block.defaultOpen) + setSelectedToolId(null) + setIsHydratingHistory(false) + setHistoryExhausted(false) + }, [props.block.id, props.block.defaultOpen]) + + useEffect(() => { + if (!open) { + setIsHydratingHistory(false) + setHistoryExhausted(false) + return + } + if (!props.block.needsOlderHistory) { + setIsHydratingHistory(false) + setHistoryExhausted(false) + return + } + if (isHydratingHistory || ctx.isLoadingMoreMessages) { + return + } + if (!ctx.hasMoreMessages) { + setHistoryExhausted(true) + return + } + if (historyExhausted) { + return + } + + let cancelled = false + setHistoryExhausted(false) + setIsHydratingHistory(true) + void ctx.loadOlderMessagesPreservingScroll() + .then((loaded) => { + if (cancelled) return + setIsHydratingHistory(false) + if (!loaded) { + setHistoryExhausted(true) + } + }) + .catch(() => { + if (cancelled) return + setIsHydratingHistory(false) + setHistoryExhausted(true) + }) + + return () => { + cancelled = true + } + }, [ + open, + props.block.needsOlderHistory, + ctx.hasMoreMessages, + ctx.isLoadingMoreMessages, + ctx.loadOlderMessagesPreservingScroll, + historyExhausted, + ]) + + const selectedTool = useMemo( + () => props.block.tools.find((tool) => tool.id === selectedToolId) ?? null, + [props.block.tools, selectedToolId] + ) + const selectedPresentation = useMemo(() => { + if (!selectedTool) return null + return getToolPresentation({ + toolName: selectedTool.tool.name, + input: selectedTool.tool.input, + result: selectedTool.tool.result, + childrenCount: selectedTool.children.length, + description: selectedTool.tool.description, + metadata: props.metadata + }, t) + }, [selectedTool, props.metadata, t]) + + const primaryTitle = formatPrimaryTitle(props.block, props.metadata, t) + const subtitle = formatActionSummary(props.block, t) + const fileCount = props.block.summary.fileTargets.length + + return ( + + + + + + {open ? ( + +
+ {t('toolGroup.toolCount', { n: props.block.tools.length })} +
+ +
+ {props.block.tools.map((tool) => { + const filePath = getInputStringAny(tool.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path']) + const resolvedPath = filePath ? resolveDisplayPath(filePath, props.metadata) : null + return ( + + ) + })} +
+ + {isHydratingHistory ? ( +
+ {t('toolGroup.loadingOlderHistory')} +
+ ) : null} + {!isHydratingHistory && historyExhausted && props.block.needsOlderHistory ? ( +
+ {t('toolGroup.historyUnavailable')} +
+ ) : null} +
+ ) : null} + + { + if (!nextOpen) { + setSelectedToolId(null) + } + }}> + + {selectedTool && selectedPresentation ? ( + <> + + {selectedPresentation.title} + + + + ) : null} + + +
+ ) +} diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index bcd0a33838..00fb42f663 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -5,6 +5,7 @@ import { safeStringify } from '@hapi/protocol' import { renderEventLabel } from '@/chat/presentation' import type { ChatBlock, CliOutputBlock, UsageData } from '@/chat/types' import type { AgentEvent, ToolCallBlock } from '@/chat/types' +import type { ToolGroupBlock, VisibleChatBlock } from '@/chat/toolGroups' import type { AttachmentMetadata, MessageStatus as HappyMessageStatus, Session } from '@/types/api' export type HappyChatMessageMetadata = { @@ -22,7 +23,7 @@ export type HappyChatMessageMetadata = { model?: string | null } -function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { +function toThreadMessageLike(block: VisibleChatBlock): ThreadMessageLike { if (block.kind === 'user-text') { const messageId = `user:${block.id}` return { @@ -119,6 +120,29 @@ function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { } } + if (block.kind === 'tool-group') { + const groupBlock: ToolGroupBlock = block + return { + role: 'assistant', + id: `tool:${groupBlock.id}`, + createdAt: new Date(groupBlock.createdAt), + content: [{ + type: 'tool-call', + toolCallId: groupBlock.id, + toolName: 'ToolGroup', + argsText: '', + artifact: groupBlock + }], + metadata: { + custom: { + kind: 'tool', + toolCallId: groupBlock.id, + invokedAt: groupBlock.invokedAt ?? null + } satisfies HappyChatMessageMetadata + } + } + } + const toolBlock: ToolCallBlock = block const messageId = `tool:${toolBlock.id}` const inputText = safeStringify(toolBlock.tool.input) @@ -206,7 +230,7 @@ function extractMessageContent(message: AppendMessage): { text: string; attachme export function useHappyRuntime(props: { session: Session - blocks: readonly ChatBlock[] + blocks: readonly VisibleChatBlock[] isSending: boolean onSendMessage: (text: string, attachments?: AttachmentMetadata[]) => void onAbort: () => Promise @@ -215,9 +239,9 @@ export function useHappyRuntime(props: { }) { // Use cached message converter for performance optimization // This prevents re-converting all messages on every render - const convertedMessages = useExternalMessageConverter({ + const convertedMessages = useExternalMessageConverter({ callback: toThreadMessageLike, - messages: props.blocks as ChatBlock[], + messages: props.blocks as VisibleChatBlock[], isRunning: props.session.thinking, }) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 8f09b929e6..25092ca1e3 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -231,6 +231,28 @@ export default { 'tool.requestUserInput.textPlaceholder': 'Type your answer…', 'tool.requestUserInput.noteLabel': 'Additional note (optional)', 'tool.requestUserInput.notePlaceholder': 'Add a note…', + 'toolGroup.title': 'Tool activity', + 'toolGroup.primary.fileTargets': '{target} +{n}', + 'toolGroup.primary.commandTargets': '{target} +{n}', + 'toolGroup.primary.searchTargets': '{target} +{n}', + 'toolGroup.primary.urlTargets': '{target} +{n}', + 'toolGroup.primary.otherTargets': '{target} +{n}', + 'toolGroup.summary.read': 'Read {n}', + 'toolGroup.summary.mutation': 'Edit {n}', + 'toolGroup.summary.command': 'Run {n}', + 'toolGroup.summary.search': 'Search {n}', + 'toolGroup.summary.web': 'Web {n}', + 'toolGroup.summary.other': 'Tool {n}', + 'toolGroup.badge.running': '{n} running', + 'toolGroup.badge.pending': '{n} pending', + 'toolGroup.badge.error': '{n} error', + 'toolGroup.badge.fileTargets': '{n} files', + 'toolGroup.toolCount': '{n} tool calls', + 'toolGroup.loadingOlderHistory': 'Loading earlier tool activity…', + 'toolGroup.historyUnavailable': 'Earlier tool activity is unavailable.', + 'toolGroup.rowStatus.running': 'Running', + 'toolGroup.rowStatus.pending': 'Pending', + 'toolGroup.rowStatus.error': 'Error', // Composer buttons 'composer.settings': 'Settings', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index fb8850a6bc..160edafbfa 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -233,6 +233,28 @@ export default { 'tool.requestUserInput.textPlaceholder': '输入您的答案…', 'tool.requestUserInput.noteLabel': '补充说明(可选)', 'tool.requestUserInput.notePlaceholder': '添加备注…', + 'toolGroup.title': '工具活动', + 'toolGroup.primary.fileTargets': '{target} 等 +{n}', + 'toolGroup.primary.commandTargets': '{target} 等 +{n}', + 'toolGroup.primary.searchTargets': '{target} 等 +{n}', + 'toolGroup.primary.urlTargets': '{target} 等 +{n}', + 'toolGroup.primary.otherTargets': '{target} 等 +{n}', + 'toolGroup.summary.read': '读取 {n}', + 'toolGroup.summary.mutation': '编辑 {n}', + 'toolGroup.summary.command': '执行 {n}', + 'toolGroup.summary.search': '搜索 {n}', + 'toolGroup.summary.web': '访问 {n}', + 'toolGroup.summary.other': '工具 {n}', + 'toolGroup.badge.running': '运行中 {n}', + 'toolGroup.badge.pending': '等待中 {n}', + 'toolGroup.badge.error': '错误 {n}', + 'toolGroup.badge.fileTargets': '{n} 文件', + 'toolGroup.toolCount': '{n} 次 tool use', + 'toolGroup.loadingOlderHistory': '正在补加载更早的工具活动…', + 'toolGroup.historyUnavailable': '更早的工具活动已不可用。', + 'toolGroup.rowStatus.running': '运行中', + 'toolGroup.rowStatus.pending': '等待中', + 'toolGroup.rowStatus.error': '错误', // Composer buttons 'composer.settings': '设置', From cd6f0b57576f5263176fd3ab3da0c036bda0fe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E9=91=AB?= <673638712@qq.com> Date: Sat, 9 May 2026 10:54:36 +0800 Subject: [PATCH 2/6] fix(web): hydrate oldest visible tool group Mark needsOlderHistory on the first visible grouped tool run even when earlier visible blocks are non-tool content, and add regression coverage for the boundary. --- web/src/chat/toolGroups.test.ts | 17 ++++++++++++++++- web/src/chat/toolGroups.ts | 6 ++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index cdd1606802..bd7b76d601 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -149,6 +149,21 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(visible[2]) && visible[2].needsOlderHistory).toBe(false) }) + it('marks the first visible group for older history even when earlier blocks are non-tools', () => { + const visible = buildVisibleChatBlocks([ + makeTextBlock('text-1', 'prepended assistant note'), + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + makeTextBlock('text-2', 'next section'), + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + makeToolBlock('write-1', 'Write', { file_path: 'src/b.ts' }), + ], { hasMoreMessages: true }) + + expect(visible[0].kind).toBe('agent-text') + expect(isToolGroupBlock(visible[1]) && visible[1].needsOlderHistory).toBe(true) + expect(isToolGroupBlock(visible[3]) && visible[3].needsOlderHistory).toBe(false) + }) + it('reuses a previous group id when the first tool changes after prepend', () => { const previous = buildVisibleChatBlocks([ makeToolBlock('read-2', 'Read', { file_path: 'src/b.ts' }), @@ -184,4 +199,4 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(previous[0]) && isToolGroupBlock(next[0]) && previous[0].id === next[0].id).toBe(true) }) -}) \ No newline at end of file +}) diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 628ba7e322..a1f39abfc5 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -227,6 +227,7 @@ export function buildVisibleChatBlocks( ): VisibleChatBlock[] { const visibleBlocks: VisibleChatBlock[] = [] const previousGroups = options.previousGroups ?? [] + let sawVisibleGroup = false for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index] @@ -251,7 +252,7 @@ export function buildVisibleChatBlocks( continue } - const needsOlderHistory = options.hasMoreMessages && index === 0 + const needsOlderHistory = options.hasMoreMessages && !sawVisibleGroup visibleBlocks.push({ kind: 'tool-group', id: createToolGroupId(tools, needsOlderHistory, previousGroups), @@ -265,8 +266,9 @@ export function buildVisibleChatBlocks( needsOlderHistory, summary: summarizeToolGroup(tools) }) + sawVisibleGroup = true index = cursor - 1 } return visibleBlocks -} \ No newline at end of file +} From e64ec84cbd1fc22047ce77d1c3d8740742963278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E9=91=AB?= <673638712@qq.com> Date: Sat, 9 May 2026 11:31:48 +0800 Subject: [PATCH 3/6] fix(web): continue grouped history hydration Decouple ToolGroupCard older-history chaining from the shared loading flag, invalidate stale hydration runs safely, and add regression coverage for multi-page hydration. --- .../ToolCard/ToolGroupCard.test.tsx | 56 +++++++++++++++++++ web/src/components/ToolCard/ToolGroupCard.tsx | 26 ++++----- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index 351055f81e..57a51f4faa 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -1,3 +1,4 @@ +import { useCallback, useState } from 'react' import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolCallBlock } from '@/chat/types' @@ -144,4 +145,59 @@ describe('ToolGroupCard', () => { expect(screen.getByText('Earlier tool activity is unavailable.')).toBeInTheDocument() }) }) + + it('continues hydrating incomplete history across multiple page loads', async () => { + let loadCount = 0 + + function Harness() { + const [isLoadingMore, setIsLoadingMore] = useState(false) + const loadOlderMessagesPreservingScroll = useCallback(() => { + const shouldContinue = loadCount === 0 + loadCount += 1 + setIsLoadingMore(true) + return new Promise((resolve) => { + setTimeout(() => { + setIsLoadingMore(false) + resolve(shouldContinue) + }, 0) + }) + }, []) + + return ( + + + + + + ) + } + + const view = render() + const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + + fireEvent.click(groupToggle) + + await waitFor(() => { + expect(loadCount).toBe(2) + }) + await waitFor(() => { + expect(screen.getByText('Earlier tool activity is unavailable.')).toBeInTheDocument() + }) + }) }) diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx index 09e0960c47..f24551d778 100644 --- a/web/src/components/ToolCard/ToolGroupCard.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { ToolGroupBlock } from '@/chat/toolGroups' import type { ToolCallBlock } from '@/chat/types' import type { SessionMetadataSummary } from '@/types/api' @@ -152,8 +152,10 @@ export function ToolGroupCard(props: { const [selectedToolId, setSelectedToolId] = useState(null) const [isHydratingHistory, setIsHydratingHistory] = useState(false) const [historyExhausted, setHistoryExhausted] = useState(false) + const hydrationRunRef = useRef(0) useEffect(() => { + hydrationRunRef.current += 1 setOpen(props.block.defaultOpen) setSelectedToolId(null) setIsHydratingHistory(false) @@ -162,53 +164,51 @@ export function ToolGroupCard(props: { useEffect(() => { if (!open) { + hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(false) return } if (!props.block.needsOlderHistory) { + hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(false) return } - if (isHydratingHistory || ctx.isLoadingMoreMessages) { + if (isHydratingHistory || historyExhausted) { return } if (!ctx.hasMoreMessages) { + hydrationRunRef.current += 1 + setIsHydratingHistory(false) setHistoryExhausted(true) return } - if (historyExhausted) { - return - } - let cancelled = false + const runId = hydrationRunRef.current + 1 + hydrationRunRef.current = runId setHistoryExhausted(false) setIsHydratingHistory(true) void ctx.loadOlderMessagesPreservingScroll() .then((loaded) => { - if (cancelled) return + if (hydrationRunRef.current !== runId) return setIsHydratingHistory(false) if (!loaded) { setHistoryExhausted(true) } }) .catch(() => { - if (cancelled) return + if (hydrationRunRef.current !== runId) return setIsHydratingHistory(false) setHistoryExhausted(true) }) - - return () => { - cancelled = true - } }, [ open, props.block.needsOlderHistory, ctx.hasMoreMessages, - ctx.isLoadingMoreMessages, ctx.loadOlderMessagesPreservingScroll, historyExhausted, + isHydratingHistory, ]) const selectedTool = useMemo( From 6f122b883b4322a6d0475885038efc7b5f489b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E9=91=AB?= <673638712@qq.com> Date: Sun, 10 May 2026 13:09:12 +0800 Subject: [PATCH 4/6] fix(web): harden grouped tool hydration - retry incomplete group hydration after transient pagination contention\n- keep approved and denied permissioned tool cards eligible for grouping\n- cover both regressions with targeted web tests --- web/src/chat/toolGroups.test.ts | 37 ++++++ web/src/chat/toolGroups.ts | 2 +- .../ToolCard/ToolGroupCard.test.tsx | 113 ++++++++++++++++-- web/src/components/ToolCard/ToolGroupCard.tsx | 36 +++++- 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index bd7b76d601..f4624e4805 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -73,6 +73,43 @@ describe('isEligibleForToolGrouping', () => { } }))).toBe(false) }) + + it('keeps completed permissioned execution cards eligible for grouping', () => { + expect(isEligibleForToolGrouping(makeToolBlock('approved-1', 'Bash', {}, { + tool: { + id: 'approved-1', + name: 'Bash', + state: 'completed', + input: {}, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + permission: { + id: 'approved-1', + status: 'approved' + } + } + }))).toBe(true) + + expect(isEligibleForToolGrouping(makeToolBlock('denied-1', 'Edit', {}, { + tool: { + id: 'denied-1', + name: 'Edit', + state: 'error', + input: {}, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + permission: { + id: 'denied-1', + status: 'denied', + reason: 'blocked' + } + } + }))).toBe(true) + }) }) describe('buildVisibleChatBlocks', () => { diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index a1f39abfc5..3cd982a544 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -186,7 +186,7 @@ function summarizeToolGroup(tools: ToolCallBlock[]): ToolGroupSummary { } function isInteractiveToolBlock(block: ToolCallBlock): boolean { - return Boolean(block.tool.permission) + return block.tool.permission?.status === 'pending' || isAskUserQuestionToolName(block.tool.name) || isRequestUserInputToolName(block.tool.name) } diff --git a/web/src/components/ToolCard/ToolGroupCard.test.tsx b/web/src/components/ToolCard/ToolGroupCard.test.tsx index 1c3879e79b..d5849411e2 100644 --- a/web/src/components/ToolCard/ToolGroupCard.test.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react' -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolCallBlock } from '@/chat/types' import type { ToolGroupBlock } from '@/chat/toolGroups' @@ -127,14 +127,43 @@ describe('ToolGroupCard', () => { }) it('auto-loads older history after expand when the group is incomplete', async () => { - const loadOlder = vi.fn(async () => false) - const block = makeGroup({ - id: 'tool-group:bash-1', - historyState: 'needs-older-history', - needsOlderHistory: true, - }) + const loadOlder = vi.fn() + + function Harness() { + const [hasMore, setHasMore] = useState(true) + const loadOlderMessagesPreservingScroll = useCallback(async () => { + loadOlder() + setHasMore(false) + return false + }, []) - const view = renderCard(block, { loadOlder, hasMore: true }) + return ( + + + + + + ) + } + + const view = render() const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) fireEvent.click(groupToggle) @@ -152,6 +181,7 @@ describe('ToolGroupCard', () => { function Harness() { const [isLoadingMore, setIsLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(true) const loadOlderMessagesPreservingScroll = useCallback(() => { const shouldContinue = loadCount === 0 loadCount += 1 @@ -159,6 +189,9 @@ describe('ToolGroupCard', () => { return new Promise((resolve) => { setTimeout(() => { setIsLoadingMore(false) + if (!shouldContinue) { + setHasMore(false) + } resolve(shouldContinue) }, 0) }) @@ -173,7 +206,7 @@ describe('ToolGroupCard', () => { terminalToolDisplayMode: 'detailed', disabled: false, onRefresh: vi.fn(), - hasMoreMessages: true, + hasMoreMessages: hasMore, isLoadingMoreMessages: isLoadingMore, loadOlderMessagesPreservingScroll, }}> @@ -202,4 +235,66 @@ describe('ToolGroupCard', () => { expect(screen.getByText('Earlier tool activity is unavailable.')).toBeInTheDocument() }) }) + + it('waits for an in-flight thread pagination to finish before retrying hydration', async () => { + const loadOlder = vi.fn(async () => false) + let releaseThreadLoad: (() => void) | null = null + + function Harness() { + const [hasMore, setHasMore] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(true) + + releaseThreadLoad = () => setIsLoadingMore(false) + + const loadOlderMessagesPreservingScroll = useCallback(async () => { + loadOlder() + setHasMore(false) + return false + }, []) + + return ( + + + + + + ) + } + + const view = render() + const groupToggle = within(view.container).getByRole('button', { name: /src\/a.ts/i }) + + fireEvent.click(groupToggle) + + expect(loadOlder).not.toHaveBeenCalled() + expect(screen.queryByText('Earlier tool activity is unavailable.')).not.toBeInTheDocument() + + await act(async () => { + releaseThreadLoad?.() + }) + + await waitFor(() => { + expect(loadOlder).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(screen.getByText('Earlier tool activity is unavailable.')).toBeInTheDocument() + }) + }) }) diff --git a/web/src/components/ToolCard/ToolGroupCard.tsx b/web/src/components/ToolCard/ToolGroupCard.tsx index f24551d778..1eae45958a 100644 --- a/web/src/components/ToolCard/ToolGroupCard.tsx +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -152,9 +152,20 @@ export function ToolGroupCard(props: { const [selectedToolId, setSelectedToolId] = useState(null) const [isHydratingHistory, setIsHydratingHistory] = useState(false) const [historyExhausted, setHistoryExhausted] = useState(false) + const [retryNonce, setRetryNonce] = useState(0) const hydrationRunRef = useRef(0) + const retryTimerRef = useRef | null>(null) + + function clearRetryTimer() { + if (retryTimerRef.current === null) { + return + } + clearTimeout(retryTimerRef.current) + retryTimerRef.current = null + } useEffect(() => { + clearRetryTimer() hydrationRunRef.current += 1 setOpen(props.block.defaultOpen) setSelectedToolId(null) @@ -162,14 +173,22 @@ export function ToolGroupCard(props: { setHistoryExhausted(false) }, [props.block.id, props.block.defaultOpen]) + useEffect(() => { + return () => { + clearRetryTimer() + } + }, []) + useEffect(() => { if (!open) { + clearRetryTimer() hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(false) return } if (!props.block.needsOlderHistory) { + clearRetryTimer() hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(false) @@ -178,6 +197,9 @@ export function ToolGroupCard(props: { if (isHydratingHistory || historyExhausted) { return } + if (ctx.isLoadingMoreMessages) { + return + } if (!ctx.hasMoreMessages) { hydrationRunRef.current += 1 setIsHydratingHistory(false) @@ -194,11 +216,21 @@ export function ToolGroupCard(props: { if (hydrationRunRef.current !== runId) return setIsHydratingHistory(false) if (!loaded) { - setHistoryExhausted(true) + if (!ctx.hasMoreMessages) { + setHistoryExhausted(true) + return + } + clearRetryTimer() + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null + if (hydrationRunRef.current !== runId) return + setRetryNonce((value) => value + 1) + }, 150) } }) .catch(() => { if (hydrationRunRef.current !== runId) return + clearRetryTimer() setIsHydratingHistory(false) setHistoryExhausted(true) }) @@ -206,9 +238,11 @@ export function ToolGroupCard(props: { open, props.block.needsOlderHistory, ctx.hasMoreMessages, + ctx.isLoadingMoreMessages, ctx.loadOlderMessagesPreservingScroll, historyExhausted, isHydratingHistory, + retryNonce, ]) const selectedTool = useMemo( From 21fd1cadb00465e17e3996bcae1bbe3678115045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E9=91=AB?= <673638712@qq.com> Date: Sun, 10 May 2026 13:21:03 +0800 Subject: [PATCH 5/6] fix(web): keep Codex permission cards standalone - treat CodexPermission as a semantic grouping boundary even after approval\n- keep permissioned execution tools groupable while preserving permission milestones\n- add regression coverage for Codex permission eligibility and boundary behavior --- web/src/chat/toolGroups.test.ts | 52 +++++++++++++++++++++++++++++++++ web/src/chat/toolGroups.ts | 7 ++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index f4624e4805..32004f1e96 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -110,6 +110,25 @@ describe('isEligibleForToolGrouping', () => { } }))).toBe(true) }) + + it('keeps Codex permission milestones standalone after completion', () => { + expect(isEligibleForToolGrouping(makeToolBlock('codex-perm-1', 'CodexPermission', {}, { + tool: { + id: 'codex-perm-1', + name: 'CodexPermission', + state: 'completed', + input: { tool: 'shell_command' }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + permission: { + id: 'codex-perm-1', + status: 'approved' + } + } + }))).toBe(false) + }) }) describe('buildVisibleChatBlocks', () => { @@ -173,6 +192,39 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(visible[2])).toBe(true) }) + it('keeps completed Codex permission cards as standalone grouping boundaries', () => { + const permission = makeToolBlock('perm-1', 'CodexPermission', { tool: 'shell_command' }, { + tool: { + id: 'perm-1', + name: 'CodexPermission', + state: 'completed', + input: { tool: 'shell_command' }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: 'Approved', + permission: { + id: 'perm-1', + status: 'approved', + decision: 'approved' + } + } + }) + const visible = buildVisibleChatBlocks([ + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + permission, + makeToolBlock('edit-1', 'Edit', { file_path: 'src/a.ts' }), + makeToolBlock('write-1', 'Write', { file_path: 'src/b.ts' }), + ], { hasMoreMessages: false }) + + expect(visible).toHaveLength(3) + expect(isToolGroupBlock(visible[0])).toBe(true) + expect(visible[1]).toBe(permission) + expect(isToolGroupBlock(visible[2])).toBe(true) + }) + it('marks only the oldest visible grouped run as needing older history', () => { const visible = buildVisibleChatBlocks([ makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 3cd982a544..02f24955b2 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -63,6 +63,10 @@ const MILESTONE_TOOL_NAMES = new Set([ 'close_agent' ]) +const INTERACTIVE_TOOL_NAMES = new Set([ + 'CodexPermission' +]) + function pushUnique(target: string[], value: string | null): void { if (!value) return if (target.includes(value)) return @@ -186,7 +190,8 @@ function summarizeToolGroup(tools: ToolCallBlock[]): ToolGroupSummary { } function isInteractiveToolBlock(block: ToolCallBlock): boolean { - return block.tool.permission?.status === 'pending' + return INTERACTIVE_TOOL_NAMES.has(block.tool.name) + || block.tool.permission?.status === 'pending' || isAskUserQuestionToolName(block.tool.name) || isRequestUserInputToolName(block.tool.name) } From d53139e799cfc00b116427271fe86648425d9d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E9=91=AB?= <673638712@qq.com> Date: Sun, 10 May 2026 13:27:27 +0800 Subject: [PATCH 6/6] fix(web): narrow incomplete tool-group hydration - only mark groups at the oldest visible boundary as needing older history\n- avoid auto-paginating complete groups behind text, standalone tools, or permission milestones\n- add regression coverage for the adjacent boundary cases --- web/src/chat/toolGroups.test.ts | 45 +++++++++++++++++++++++++++++++-- web/src/chat/toolGroups.ts | 5 ++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/web/src/chat/toolGroups.test.ts b/web/src/chat/toolGroups.test.ts index 32004f1e96..4e151d8b07 100644 --- a/web/src/chat/toolGroups.test.ts +++ b/web/src/chat/toolGroups.test.ts @@ -238,7 +238,7 @@ describe('buildVisibleChatBlocks', () => { expect(isToolGroupBlock(visible[2]) && visible[2].needsOlderHistory).toBe(false) }) - it('marks the first visible group for older history even when earlier blocks are non-tools', () => { + it('does not mark groups after leading non-tool blocks as needing older history', () => { const visible = buildVisibleChatBlocks([ makeTextBlock('text-1', 'prepended assistant note'), makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), @@ -249,10 +249,51 @@ describe('buildVisibleChatBlocks', () => { ], { hasMoreMessages: true }) expect(visible[0].kind).toBe('agent-text') - expect(isToolGroupBlock(visible[1]) && visible[1].needsOlderHistory).toBe(true) + expect(isToolGroupBlock(visible[1]) && visible[1].needsOlderHistory).toBe(false) expect(isToolGroupBlock(visible[3]) && visible[3].needsOlderHistory).toBe(false) }) + it('does not mark groups after a leading standalone tool as needing older history', () => { + const visible = buildVisibleChatBlocks([ + makeToolBlock('single-1', 'Read', { file_path: 'src/solo.ts' }), + makeTextBlock('text-1', 'boundary'), + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + ], { hasMoreMessages: true }) + + expect(visible[0].kind).toBe('tool-call') + expect(visible[1].kind).toBe('agent-text') + expect(isToolGroupBlock(visible[2]) && visible[2].needsOlderHistory).toBe(false) + }) + + it('does not mark groups after a standalone permission boundary as needing older history', () => { + const permission = makeToolBlock('perm-1', 'CodexPermission', { tool: 'shell_command' }, { + tool: { + id: 'perm-1', + name: 'CodexPermission', + state: 'completed', + input: { tool: 'shell_command' }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: 'Approved', + permission: { + id: 'perm-1', + status: 'approved' + } + } + }) + const visible = buildVisibleChatBlocks([ + permission, + makeToolBlock('read-1', 'Read', { file_path: 'src/a.ts' }), + makeToolBlock('bash-1', 'Bash', { command: 'bun test' }), + ], { hasMoreMessages: true }) + + expect(visible[0]).toBe(permission) + expect(isToolGroupBlock(visible[1]) && visible[1].needsOlderHistory).toBe(false) + }) + it('reuses a previous group id when the first tool changes after prepend', () => { const previous = buildVisibleChatBlocks([ makeToolBlock('read-2', 'Read', { file_path: 'src/b.ts' }), diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts index 02f24955b2..aac6c67575 100644 --- a/web/src/chat/toolGroups.ts +++ b/web/src/chat/toolGroups.ts @@ -232,7 +232,6 @@ export function buildVisibleChatBlocks( ): VisibleChatBlock[] { const visibleBlocks: VisibleChatBlock[] = [] const previousGroups = options.previousGroups ?? [] - let sawVisibleGroup = false for (let index = 0; index < blocks.length; index += 1) { const block = blocks[index] @@ -257,7 +256,8 @@ export function buildVisibleChatBlocks( continue } - const needsOlderHistory = options.hasMoreMessages && !sawVisibleGroup + const startsAtOldestVisibleBoundary = visibleBlocks.length === 0 + const needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary visibleBlocks.push({ kind: 'tool-group', id: createToolGroupId(tools, needsOlderHistory, previousGroups), @@ -271,7 +271,6 @@ export function buildVisibleChatBlocks( needsOlderHistory, summary: summarizeToolGroup(tools) }) - sawVisibleGroup = true index = cursor - 1 }