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 00000000..d52cdc5a --- /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 00000000..4e151d8b --- /dev/null +++ b/web/src/chat/toolGroups.test.ts @@ -0,0 +1,332 @@ +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) + }) + + 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) + }) + + 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', () => { + 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('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' }), + 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('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' }), + 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(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' }), + 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) + }) +}) diff --git a/web/src/chat/toolGroups.ts b/web/src/chat/toolGroups.ts new file mode 100644 index 00000000..aac6c675 --- /dev/null +++ b/web/src/chat/toolGroups.ts @@ -0,0 +1,278 @@ +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' +]) + +const INTERACTIVE_TOOL_NAMES = new Set([ + 'CodexPermission' +]) + +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 INTERACTIVE_TOOL_NAMES.has(block.tool.name) + || block.tool.permission?.status === 'pending' + || 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 startsAtOldestVisibleBoundary = visibleBlocks.length === 0 + const needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary + 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 +} diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 73aa4ddb..b361f58f 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -681,7 +681,10 @@ export function HappyThread(props: { terminalToolDisplayMode, 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 87725ea7..a58ca3fb 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' } @@ -164,6 +174,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 5561b05b..e8986828 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 bf4d644c..3d10f11c 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -226,7 +226,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 ( @@ -259,7 +259,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' @@ -284,6 +284,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({ @@ -312,7 +351,6 @@ function ToolCardInner(props: ToolCardProps) { const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode) const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode) const CompactToolView = showInline ? getToolViewComponent(toolName) : null - const FullToolView = getToolFullViewComponent(toolName) const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) @@ -323,7 +361,7 @@ function ToolCardInner(props: ToolCardProps) { || ((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 = ( @@ -357,7 +395,7 @@ function ToolCardInner(props: ToolCardProps) { )}> - + @@ -384,37 +422,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 00000000..d5849411 --- /dev/null +++ b/web/src/components/ToolCard/ToolGroupCard.test.tsx @@ -0,0 +1,300 @@ +import { useCallback, useState } from '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' +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() + + function Harness() { + const [hasMore, setHasMore] = useState(true) + 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) + + await waitFor(() => { + expect(loadOlder).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + 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 [hasMore, setHasMore] = useState(true) + const loadOlderMessagesPreservingScroll = useCallback(() => { + const shouldContinue = loadCount === 0 + loadCount += 1 + setIsLoadingMore(true) + return new Promise((resolve) => { + setTimeout(() => { + setIsLoadingMore(false) + if (!shouldContinue) { + setHasMore(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() + }) + }) + + 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 new file mode 100644 index 00000000..1eae4595 --- /dev/null +++ b/web/src/components/ToolCard/ToolGroupCard.tsx @@ -0,0 +1,389 @@ +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' +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) + 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) + setIsHydratingHistory(false) + 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) + return + } + if (isHydratingHistory || historyExhausted) { + return + } + if (ctx.isLoadingMoreMessages) { + return + } + if (!ctx.hasMoreMessages) { + hydrationRunRef.current += 1 + setIsHydratingHistory(false) + setHistoryExhausted(true) + return + } + + const runId = hydrationRunRef.current + 1 + hydrationRunRef.current = runId + setHistoryExhausted(false) + setIsHydratingHistory(true) + void ctx.loadOlderMessagesPreservingScroll() + .then((loaded) => { + if (hydrationRunRef.current !== runId) return + setIsHydratingHistory(false) + if (!loaded) { + 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) + }) + }, [ + open, + props.block.needsOlderHistory, + ctx.hasMoreMessages, + ctx.isLoadingMoreMessages, + ctx.loadOlderMessagesPreservingScroll, + historyExhausted, + isHydratingHistory, + retryNonce, + ]) + + 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 bcd0a338..00fb42f6 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 b8d109f7..7dbb870b 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -272,6 +272,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 57b49329..47313c84 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -274,6 +274,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': '设置',