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 index 0f897e54d75..2e98d5de2ff 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx @@ -1,5 +1,5 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' -import { cleanup, render, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' @@ -104,6 +104,84 @@ function groupedPendingMessage(): ThreadMessage { } as ThreadMessage } +function pendingOnlyMessage(): ThreadMessage { + return { + id: 'assistant-pending-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-only', + toolName: 'terminal', + args: { command: 'sleep 10' }, + argsText: JSON.stringify({ command: 'sleep 10' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function completedOnlyMessage(): ThreadMessage { + return { + id: 'assistant-completed-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-only', + toolName: 'read_file', + args: { path: '/etc/hosts' }, + argsText: JSON.stringify({ path: '/etc/hosts' }), + result: { content: '127.0.0.1 localhost' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function failedOnlyMessage(): ThreadMessage { + return { + id: 'assistant-failed-only', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-failed', + toolName: 'terminal', + args: { command: 'exit 1' }, + argsText: JSON.stringify({ command: 'exit 1' }), + isError: true, + result: { stderr: 'boom' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + function GroupHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -155,4 +233,40 @@ describe('flat tool list approval surfacing', () => { expect(bar?.closest('[hidden]')).toBeNull() }) }) + + it('lets completed tool rows be dismissed', async () => { + const { container } = render() + + const dismiss = await screen.findByLabelText('Dismiss') + + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1) + + fireEvent.click(dismiss) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('lets failed tool rows be dismissed', async () => { + render() + + const dismiss = await screen.findByLabelText('Dismiss') + + fireEvent.click(dismiss) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + }) + + it('does not show dismiss for pending tool rows', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + }) + + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 8478afc118c..ceb881c0252 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -2,7 +2,7 @@ import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' +import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo, useState } from 'react' import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' @@ -12,10 +12,13 @@ import { DiffLines } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { ToolIcon } from '@/components/ui/tool-icon' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' import { AlertCircle, CheckCircle2 } from '@/lib/icons' @@ -193,13 +196,16 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean function ToolEntry({ part }: ToolEntryProps) { const { t } = useI18n() const copy = t.assistant.tool + const statusCopy = t.statusStack const messageId = useAuiState(s => s.message.id) const messageRunning = useAuiState(selectMessageRunning) const embedded = useContext(ToolEmbedContext) + const [dismissed, setDismissed] = useState(false) const toolViewMode = useStore($toolViewMode) const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` const open = useDisclosureOpen(disclosureId) const isPending = messageRunning && part.result === undefined + const canDismiss = !isPending && !embedded // Only animate entries that mount while their message is actively // streaming — historical sessions mount with `messageRunning === false`, // so they paint statically without a settle cascade. The wrapping group @@ -284,8 +290,28 @@ function ToolEntry({ part }: ToolEntryProps) { const trailing = isPending && !embedded ? ( + ) : canDismiss ? ( + + + ) : undefined + if (dismissed) { + return null + } + return (