diff --git a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx new file mode 100644 index 000000000..a615b9c4f --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx @@ -0,0 +1,161 @@ +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $approvalRequest } from '@/store/prompts' +import { $toolDisclosureStates } from '@/store/tool-view' + +import { Thread } from './thread' + +// Regression coverage for the "approval buried behind a collapsed tool group" +// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the +// pending tool's inline ApprovalBar lives inside the group body — which is +// `hidden` until expanded. A live approval must surface WITHOUT the user +// expanding anything, so ToolGroupSlot force-opens its body while an approval +// targeting one of its pending tools is in flight. + +const createdAt = new Date('2026-06-03T00:00:00.000Z') + +const resizeObservers = new Set() + +class TestResizeObserver { + private target: Element | null = null + + constructor(private readonly callback: ResizeObserverCallback) { + resizeObservers.add(this) + } + + observe(target: Element) { + this.target = target + } + + unobserve() {} + + disconnect() { + resizeObservers.delete(this) + } +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + +Element.prototype.scrollTo = function scrollTo() {} + +Element.prototype.animate = function animate() { + return { + cancel: () => {}, + finished: Promise.resolve() + } as unknown as Animation +} + +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + +// A running assistant message with two tools: a completed read_file plus a +// pending terminal (no result). Two visible tools → ToolGroupSlot groups them +// behind a collapsed "Tool actions · 2 steps" header. +function groupedPendingMessage(): ThreadMessage { + return { + id: 'assistant-group-1', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-1', + toolName: 'read_file', + args: { path: '/etc/hosts' }, + argsText: JSON.stringify({ path: '/etc/hosts' }), + result: { content: '127.0.0.1 localhost' } + }, + { + type: 'tool-call', + toolCallId: 'term-1', + toolName: 'terminal', + args: { command: 'rm -rf /tmp/x' }, + argsText: JSON.stringify({ command: 'rm -rf /tmp/x' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function GroupHarness({ message }: { message: ThreadMessage }) { + const runtime = useExternalStoreRuntime({ + messages: [message], + isRunning: message.status?.type === 'running', + onNew: async () => {} + }) + + return ( + + + + ) +} + +beforeEach(() => { + $approvalRequest.set(null) + $toolDisclosureStates.set({}) +}) + +afterEach(() => { + cleanup() + $approvalRequest.set(null) +}) + +describe('ToolGroupSlot approval surfacing', () => { + it('hides the grouped pending tool body when there is no approval', async () => { + const { container } = render() + + // Group header renders collapsed; the inline approval strip lives in the + // hidden body, so with no live approval it must not render at all (the + // ApprovalBar returns null when $approvalRequest is empty). + await waitFor(() => { + expect(screen.getByText(/Tool actions/)).toBeTruthy() + }) + expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull() + }) + + it('force-opens the group body so the approval surfaces without expanding', async () => { + $approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) + + const { container } = render() + + // Even though the group defaults collapsed, the live approval forces the + // body open so the inline controls are visible (and reachable, not in a + // hidden subtree) immediately. + await waitFor(() => { + const bar = container.querySelector('[data-slot="tool-approval-inline"]') + expect(bar).not.toBeNull() + // The forced-open group body must not be hidden — assert no ancestor + // carries the `hidden` attribute that would keep the bar off-screen. + expect(bar?.closest('[hidden]')).toBeNull() + }) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool-approval.tsx b/apps/desktop/src/components/assistant-ui/tool-approval.tsx index 8e6dfdaaf..6940b7a10 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval.tsx @@ -39,7 +39,7 @@ import type { ToolPart } from './tool-fallback-model' // approval at a time, so the single pending row of those tools IS the row that // raised it. The command/description text comes from `$approvalRequest` (the // event payload), which is the only place that data reliably exists. -const APPROVAL_TOOLS = new Set(['terminal', 'execute_code']) +export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code']) // Canonical gateway choices (ui-tui/src/components/prompts.tsx). type ApprovalChoice = 'once' | 'session' | 'always' | 'deny' diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 4835e125c..ff0a4652f 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -21,10 +21,11 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' +import { $approvalRequest } from '@/store/prompts' import { $toolInlineDiffs } from '@/store/tool-diffs' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' -import { PendingToolApproval } from './tool-approval' +import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval' import { groupCopyText as buildGroupCopyText, buildToolView, @@ -458,7 +459,24 @@ export const ToolGroupSlot: FC p.result === undefined && APPROVAL_TOOLS.has(p.toolName)) + + const open = userOpen || hostsLiveApproval const enterRef = useEnterAnimation(messageRunning, disclosureId) const status = groupStatus(visibleParts)