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({})
+}