hermes-agent/apps/desktop/src/components/assistant-ui/tool-fallback.tsx
Brooklyn Nicholson b16e22b8f2 fix(desktop): persist tool-row dismissal across virtualization; keep caret hittable
Salvage of #45240. The dismiss-settled-tool-rows affordance was correct in
intent but had two issues against current main:

- The thread is virtualized, so a row's component unmounts/remounts as it
  scrolls. Component-local `useState` dismissal was forgotten on remount and
  the row popped back. Move dismissal into a session-scoped nanostore keyed by
  the stable disclosure id (mirrors $toolDisclosureOpen), so a dismissed row
  stays gone while scrolling but a reload restores real history instead of
  permanently rewriting it.
- The dismiss button lived in DisclosureRow's absolute `trailing` slot — the
  exact "opacity-0-but-clickable control fights the caret" pattern the trailing
  comment warns against. Add an in-flow `action` slot that lays out at the far
  right so an interactive control never overlaps the caret's hit-target,
  regardless of title length, and move the dismiss button into it.

Adds a remount regression test alongside the existing dismissal coverage.
2026-06-12 17:34:48 -05:00

509 lines
21 KiB
TypeScript

'use client'
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 { AnsiText } from '@/components/assistant-ui/ansi-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { CompactMarkdown } from '@/components/chat/compact-markdown'
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'
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'
import {
buildToolView,
cleanVisibleText,
inlineDiffFromResult,
isPreviewableTarget,
looksRedundant,
type SearchResultRow,
selectMessageRunning,
stripInlineDiffChrome,
toolCopyPayload,
type ToolPart,
toolPartDisclosureId,
type ToolStatus
} from './tool-fallback-model'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
// false, so every row currently owns its own chrome; kept as a seam for any
// future embedding surface.
const ToolEmbedContext = createContext(false)
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
// and the multi-tool group header pass through these constants so a
// "Patch" row and a "Tool actions · 2 steps" row are visually identical.
const TOOL_HEADER_TITLE_CLASS =
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
const TOOL_HEADER_SUBTITLE_CLASS =
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center self-center'
// Glass-style section label that sits above any pre/JSON/output block.
// Lowercase tracking + tiny size so it reads as a quiet field label rather
// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc.
const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
// Inset scroll surface for any detail body. The expanded tool row owns the
// border; the payload itself is just clipped raw text.
const TOOL_SECTION_SURFACE_CLASS =
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
interface ToolStatusCopy {
statusDone: string
statusError: string
statusRecovered: string
statusRunning: string
}
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
.map(value => {
if (typeof value === 'string') {
return value
}
try {
return JSON.stringify(value)
} catch {
return String(value)
}
})
.filter(Boolean)
return parts.join('\n')
}
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
if (status === 'running') {
return (
<GlyphSpinner
ariaLabel={copy.statusRunning}
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
spinner="breathe"
/>
)
}
if (status === 'error') {
return <AlertCircle aria-label={copy.statusError} className="size-3.5 shrink-0 text-destructive" />
}
if (status === 'warning') {
return (
<AlertCircle aria-label={copy.statusRecovered} className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
)
}
return (
<CheckCircle2
aria-label={copy.statusDone}
className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85"
/>
)
}
// Leading glyph for any tool-row header. Status (running/error/warning)
// takes precedence; otherwise falls back to the tool's codicon. Returns
// null when neither applies so callers can render unconditionally.
function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) {
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
}
// Which status (if any) should pre-empt the tool's icon in the leading
// slot. Success is silent — the row reads as "done" without a checkmark.
function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined {
if (isPending) {
return 'running'
}
return status === 'success' ? undefined : status
}
function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
return (
<ol className="m-0 grid list-none gap-2.5 p-0">
{hits.map((hit, index) => {
const key = `${hit.url || hit.title}-${index}`
const trimmedTitle = hit.title.trim()
return (
<li className="grid min-w-0 gap-0.5" key={key}>
{hit.url ? (
<PrettyLink
className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')}
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
href={hit.url}
label={trimmedTitle || undefined}
/>
) : (
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
)}
{hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>}
</li>
)
})}
</ol>
)
}
function LinkifiedText({ className, text }: { className?: string; text: string }) {
return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} />
}
interface ToolEntryProps {
part: ToolPart
}
function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean {
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
return persistedOpen ?? fallbackOpen
}
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 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
// 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
// handles its own enter animation, so embedded children skip it.
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
// Stale parts (no result, but message stopped running) get a synthetic
// empty result so buildToolView treats them as completed-no-output.
const view = useMemo(() => {
const p = !isPending && part.result === undefined ? { ...part, result: {} } : part
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, part])
const detailSections = useMemo(() => {
if (!view.detail) {
return { body: '', summary: '' }
}
if (view.status !== 'error') {
return { body: view.detail, summary: '' }
}
const chunks = view.detail
.split(/\n\s*\n+/)
.map(chunk => chunk.trim())
.filter(Boolean)
const [summary = '', ...rest] = chunks
const subtitleNorm = view.subtitle.trim().toLowerCase()
const summaryDuplicatesSubtitle = summary && summary.toLowerCase() === subtitleNorm
if (summaryDuplicatesSubtitle) {
return { body: rest.join('\n\n').trim(), summary: '' }
}
return { body: rest.join('\n\n').trim(), summary }
}, [view.detail, view.status, view.subtitle])
const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail)
const showDetail =
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(view.status !== 'error' &&
Boolean(view.detail) &&
!looksRedundant(view.title, view.detail) &&
!detailMatchesSubtitle)
const renderDetailAsCode =
view.status !== 'error' &&
(part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file')
const hasSearchHits = Boolean(view.searchHits?.length)
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
const showRawSearchDrilldown =
part.toolName === 'web_search' &&
part.result !== undefined &&
toolViewMode !== 'technical' &&
Boolean(view.rawResult.trim())
const hasExpandableContent = Boolean(
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
toolViewMode === 'technical'
)
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
// The header trailing slot only carries the live duration timer while the
// tool is running. The copy control used to live here too, but an
// `opacity-0` (yet still clickable) button straddling the caret/duration made
// 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 ? <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> : 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 ? (
<Tip label={statusCopy.dismiss}>
<Button
aria-label={statusCopy.dismiss}
className="size-5 rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:text-(--ui-text-primary) hover:opacity-100 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80"
onClick={event => {
event.stopPropagation()
dismissToolRow(disclosureId)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
) : undefined
if (dismissed) {
return null
}
return (
<div
className={cn(
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
)}
data-slot="tool-block"
ref={enterRef}
>
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
<DisclosureRow
action={dismissAction}
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
open={open}
trailing={trailing}
>
<span className="flex min-w-0 items-center gap-1.5">
<ToolGlyph copy={copy} icon={view.icon} status={leadingStatus(isPending, view.status)} />
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'shimmer text-(--ui-text-tertiary)',
view.status === 'error' && 'text-destructive',
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{view.title}
</FadeText>
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{!isPending && view.durationLabel && (
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
)}
</span>
</DisclosureRow>
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{copyAction.text && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
stopPropagation
text={copyAction.text}
/>
)}
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
<PreviewAttachment source="tool-result" target={view.previewTarget} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
</div>
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
<SearchResultsList hits={view.searchHits} />
</div>
)}
{showDetail &&
toolViewMode !== 'technical' &&
(view.status === 'error' ? (
detailSections.summary || detailSections.body ? (
<div className="max-w-full text-xs leading-relaxed text-destructive">
{detailSections.summary && (
<LinkifiedText className="block font-medium" text={detailSections.summary} />
)}
{detailSections.body && (
<pre
className={cn(
'max-h-56 overflow-auto whitespace-pre-wrap wrap-anywhere font-mono text-[0.7rem] leading-[1.55] text-destructive/90',
detailSections.summary && 'mt-1.5'
)}
>
{detailSections.body}
</pre>
)}
</div>
) : null
) : view.stdout || view.stderr ? (
// Stdout + stderr split: render both as labeled blocks. stderr
// is intentionally NOT painted destructive — many CLIs log
// informational output there.
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{view.stdout && (
<div className="space-y-0.5">
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
</pre>
</div>
)}
{view.stderr && (
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
<pre
className={cn(
TOOL_SECTION_PRE_CLASS,
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
)}
>
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
</pre>
</div>
)}
</div>
) : (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
{renderDetailAsCode ? (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
</pre>
) : (
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
)}
</div>
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{view.rawResult}
</pre>
</details>
)}
{toolViewMode === 'technical' && (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
</pre>
)}
</div>
)}
{open && view.inlineDiff && <DiffLines text={view.inlineDiff} />}
</div>
)
}
/**
* Flat, Cursor-style tool list. assistant-ui hands us a *range* of
* consecutive tool-call parts, but how that range is sliced is unstable: a
* live stream interleaves narration/reasoning between calls (many tiny
* ranges), while the settled message reconstructs every tool_call back-to-back
* (one big range). Rendering a "Tool actions · N steps" group off that range
* therefore reshuffled the whole turn the instant it settled.
*
* So we never group: each tool is a standalone row, and the wrapper just lays
* its children out on the tight `--tool-row-gap` rhythm. One range or ten,
* fragmented or consecutive, the result is pixel-identical — a tight, stable
* stack. The wrapper stays a single `<div>` of stable identity so children
* never remount as the range grows mid-stream. `ToolEmbedContext` is false so
* every row owns its own chrome (timer / preview / copy / inline approval).
*/
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
children,
startIndex
}) => {
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
return (
<ToolEmbedContext.Provider value={false}>
<div
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
data-slot="tool-block"
ref={enterRef}
>
{children}
</div>
</ToolEmbedContext.Provider>
)
}
/**
* Per-tool fallback. Now strictly returns a single ToolEntry — the
* grouping decision lives in ToolGroupSlot above, so this never swaps
* its return type and the underlying ToolEntry stays mounted across
* group-shape changes.
*/
export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => {
const part: ToolPart = { args, isError, result, toolCallId, toolName, type: 'tool-call' }
return <ToolEntry part={part} />
}