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 2e98d5de2ff..79c07ea4dbf 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 @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' import { $activeSessionId } from '@/store/session' +import { clearDismissedToolRows } from '@/store/tool-dismiss' import { $toolDisclosureStates } from '@/store/tool-view' import { Thread } from './thread' @@ -200,12 +201,14 @@ beforeEach(() => { clearAllPrompts() $activeSessionId.set('sess-1') $toolDisclosureStates.set({}) + clearDismissedToolRows() }) afterEach(() => { cleanup() clearAllPrompts() $activeSessionId.set(null) + clearDismissedToolRows() }) describe('flat tool list approval surfacing', () => { @@ -248,6 +251,30 @@ describe('flat tool list approval surfacing', () => { }) }) + it('keeps a dismissed row hidden after a remount (virtualization)', async () => { + // The thread virtualizes, so a row's component unmounts/remounts as it + // scrolls. Dismissal must persist across that — component-local state would + // forget it and the row would pop back. Simulate the remount by unmounting + // and rendering the same message fresh. + const first = render() + + fireEvent.click(await screen.findByLabelText('Dismiss')) + + await waitFor(() => { + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + + first.unmount() + + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + }) + + expect(screen.queryByLabelText('Dismiss')).toBeNull() + }) + it('lets failed tool rows be dismissed', async () => { render() diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index ceb881c0252..e93eabe1557 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, useState } from 'react' +import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' @@ -25,6 +25,7 @@ import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { $toolInlineDiffs } from '@/store/tool-diffs' +import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' import { PendingToolApproval } from './tool-approval' @@ -200,9 +201,9 @@ function ToolEntry({ part }: ToolEntryProps) { 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 dismissed = useStore($toolRowDismissed(disclosureId)) const open = useDisclosureOpen(disclosureId) const isPending = messageRunning && part.result === undefined const canDismiss = !isPending && !embedded @@ -288,25 +289,29 @@ function ToolEntry({ part }: ToolEntryProps) { // the disclosure caret hard to hit. Copy now lives in the expanded body's // top-right, where it can't fight the caret for the right edge. const trailing = - isPending && !embedded ? ( - - ) : canDismiss ? ( - - - - ) : undefined + isPending && !embedded ? : undefined + + // Once a turn has settled, a hover/focus-revealed dismiss lets the user clear + // a completed/failed row that would otherwise sit at the tail of the chat. + // It goes in the in-flow `action` slot (not `trailing`) so it can't overlap + // the disclosure caret's hit-target — see the comment above `trailing`. + const dismissAction = canDismiss ? ( + + + + ) : undefined if (dismissed) { return null @@ -323,6 +328,7 @@ function ToolEntry({ part }: ToolEntryProps) { >
setToolDisclosureOpen(disclosureId, !open) : undefined} open={open} trailing={trailing} diff --git a/apps/desktop/src/components/chat/disclosure-row.tsx b/apps/desktop/src/components/chat/disclosure-row.tsx index e0555fceb06..56cd6d9a3dd 100644 --- a/apps/desktop/src/components/chat/disclosure-row.tsx +++ b/apps/desktop/src/components/chat/disclosure-row.tsx @@ -14,12 +14,19 @@ import { cn } from '@/lib/utils' // title text, NOT the full row — and reaches just past the chevron with // `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab // stretching to the message edge. +// - `trailing` overlays the right edge (absolute) and must stay +// non-interactive (e.g. a duration timer) — an opacity-0-but-clickable +// control there steals clicks from the caret. Interactive controls go in +// `action`, which lays out *in flow* at the far right so it never sits on +// top of the caret's hit-target, no matter how long the title is. export function DisclosureRow({ + action, children, onToggle, open, trailing }: { + action?: ReactNode children: ReactNode onToggle?: () => void open: boolean @@ -55,6 +62,11 @@ export function DisclosureRow({ )} + {action && ( + + {action} + + )} {trailing && ( {trailing} )} diff --git a/apps/desktop/src/store/tool-dismiss.ts b/apps/desktop/src/store/tool-dismiss.ts new file mode 100644 index 00000000000..6f6e1be2f3b --- /dev/null +++ b/apps/desktop/src/store/tool-dismiss.ts @@ -0,0 +1,45 @@ +import { atom, computed, type ReadableAtom } from 'nanostores' + +type DismissedToolRows = Record + +// Tool rows the user has locally hidden via a row's dismiss control. This is a +// *view-only* hide: the underlying tool call still lives in the stored chat +// history, but once a turn has settled the user can clear a completed/failed +// row out of the way so it stops sitting at the tail of the conversation. +// +// Kept in module memory (not localStorage, unlike $toolDisclosureStates) on +// purpose: the thread is virtualized, so a dismissed row's component unmounts +// and remounts as it scrolls — component-local state would forget the dismissal +// and the row would pop back. Storing it here survives those remounts for the +// life of the app session, while a reload restores every row in place rather +// than permanently rewriting history from a stray click. +export const $dismissedToolRows = atom({}) + +const dismissedCache = new Map>() + +export function $toolRowDismissed(id: string): ReadableAtom { + let cached = dismissedCache.get(id) + + if (!cached) { + cached = computed($dismissedToolRows, rows => Boolean(rows[id])) + dismissedCache.set(id, cached) + } + + return cached +} + +export function dismissToolRow(id: string) { + if (!id || $dismissedToolRows.get()[id]) { + return + } + + $dismissedToolRows.set({ ...$dismissedToolRows.get(), [id]: true }) +} + +export function clearDismissedToolRows() { + if (Object.keys($dismissedToolRows.get()).length === 0) { + return + } + + $dismissedToolRows.set({}) +}