hermes-agent/apps/desktop/src/components/assistant-ui/tool/fallback.tsx

720 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import {
Children,
createContext,
type FC,
type PropsWithChildren,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} 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 { FileDiffPanel } from '@/components/chat/diff-lines'
import { DisclosureRow } from '@/components/chat/disclosure-row'
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 { FileTypeIcon } from '@/components/ui/file-type-icon'
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 { normalize } from '@/lib/text'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { recordPreviewArtifact } from '@/store/preview-status'
import { $activeSessionId, $currentCwd } from '@/store/session'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './approval'
import {
buildToolView,
clampForDisplay,
cleanVisibleText,
countDiffLineStats,
inlineDiffFromResult,
isFileEditTool,
isPreviewableTarget,
looksRedundant,
type SearchResultRow,
selectMessageRunning,
stripInlineDiffChrome,
toolCopyPayload,
type ToolPart,
toolPartDisclosureId,
type ToolStatus,
type ToolTitleAction
} from './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 "stdout", "stderr", "Search results", 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_EXPANDED_SHELL_CLASS = 'rounded-[0.3125rem] border border-(--ui-stroke-tertiary)'
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 clampForDisplay(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,
filePath,
icon,
status
}: {
copy: ToolStatusCopy
filePath?: string
icon?: string
status?: ToolStatus
}) {
const node = status ? (
statusGlyph(status, copy)
) : filePath ? (
<FileTypeIcon className="text-(--ui-text-tertiary)" path={filePath} size="0.875rem" />
) : 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)} />
}
function ToolTitle({
isPending,
status,
title,
titleAction
}: {
isPending: boolean
status: ToolStatus
title: string
titleAction?: ToolTitleAction
}) {
return (
<FadeText
className={cn(
TOOL_HEADER_TITLE_CLASS,
isPending && 'text-(--ui-text-tertiary)',
status === 'error' && 'text-destructive',
status === 'warning' && 'text-amber-700 dark:text-amber-300'
)}
>
{isPending && titleAction ? (
<>
{titleAction.prefix}
<span className="shimmer">{titleAction.text}</span>
{titleAction.suffix}
</>
) : (
title
)}
</FadeText>
)
}
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)
// `ToolFallback` rebuilds the `part` wrapper each render, defeating the memos
// below and re-running buildToolView (full JSON.stringify of result) on every
// stream delta — the freeze on big `/learn` runs. Re-derive a stable part from
// the referentially-stable args/result so the memos hold across deltas.
const { args, isError, result, toolCallId, toolName } = part
const stablePart = useMemo<ToolPart>(
() => ({ args, isError, result, toolCallId, toolName, type: 'tool-call' }),
[args, isError, result, toolCallId, toolName]
)
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(stablePart)}`
const dismissed = useStore($toolRowDismissed(disclosureId))
const isPending = messageRunning && result === undefined
const liveDiffs = useStore($toolInlineDiffs)
const sideDiff = toolCallId ? liveDiffs[toolCallId] || '' : ''
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(result)
const isFileEdit = isFileEditTool(toolName)
const defaultOpen = Boolean(inlineDiff)
const open = useDisclosureOpen(disclosureId, defaultOpen)
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}`)
// Stale parts (no result, but message stopped running) get a synthetic empty
// result so buildToolView treats them as completed-no-output. Keyed on
// stablePart so it recomputes only when this tool's data changes.
const view = useMemo(() => {
const p = !isPending && result === undefined ? { ...stablePart, result: {} } : stablePart
return buildToolView(p, inlineDiff)
}, [inlineDiff, isPending, result, stablePart])
// Surface a previewable artifact (HTML file / localhost URL) as a compact link
// in the composer status stack rather than a bulky inline card. Uses the same
// detected target the old inline card did, keyed to the active session the
// stack reads from. Idempotent + dedup'd, so re-renders don't churn.
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const previewTarget = view.previewTarget
useEffect(() => {
if (isPending || !activeSessionId || !previewTarget || !isPreviewableTarget(previewTarget)) {
return
}
recordPreviewArtifact(activeSessionId, previewTarget, currentCwd || '')
}, [activeSessionId, currentCwd, isPending, previewTarget])
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 = normalize(view.subtitle)
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.inlineDiff &&
((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.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
)
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
// what's painted, so the row's Copy button still yields the full output.
const copyAction = useMemo(() => toolCopyPayload(stablePart, view), [stablePart, view])
const diffStats = useMemo(
() => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null),
[isFileEdit, view.inlineDiff]
)
const showDiffStats = !isPending && Boolean(diffStats && (diffStats.added > 0 || diffStats.removed > 0))
// 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={cn(
'size-5 rounded-md text-(--ui-text-tertiary) transition-opacity hover:text-(--ui-text-primary) hover:opacity-100',
open
? 'opacity-80'
: 'opacity-0 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
}
// A completed file edit with no diff to review is a bare, unexpandable row.
// This is almost always a `write_file` create after a reload: only `patch`
// persists its diff in the tool result, so creates rehydrate diff-less and
// read like dead duplicates of the real diff row. Hide them — but keep
// in-flight writes (activity) and failures (errors) visible.
if (isFileEdit && !isPending && view.status !== 'error' && !view.inlineDiff) {
return null
}
return (
<div
className={cn(
'group/tool-block min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
open && TOOL_EXPANDED_SHELL_CLASS
)}
data-file-edit={isFileEdit && open ? '' : undefined}
data-slot="tool-block"
data-tool-open={open ? '' : undefined}
data-tool-row=""
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"
title={isFileEdit && view.subtitle ? view.subtitle : undefined}
>
<ToolGlyph
copy={copy}
filePath={isFileEdit ? view.subtitle : undefined}
icon={view.icon}
status={leadingStatus(isPending, view.status)}
/>
<ToolTitle isPending={isPending} status={view.status} title={view.title} titleAction={view.titleAction} />
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
{showDiffStats && diffStats && (
<span className="flex shrink-0 items-center gap-1 font-mono text-[0.625rem] tabular-nums">
{diffStats.added > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">+{diffStats.added}</span>
)}
{diffStats.removed > 0 && (
<span className="text-rose-600 dark:text-rose-400">{diffStats.removed}</span>
)}
</span>
)}
{!isFileEdit && !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-4 top-1.5 z-10 h-5 gap-0 rounded-md px-1 opacity-5 transition-opacity group-hover/tool-block:opacity-100 hover:opacity-100 focus-visible:opacity-100"
iconClassName="size-3"
label={copyAction.label}
showLabel={false}
side="left"
stopPropagation
text={copyAction.text}
/>
)}
{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>
)}
{view.inlineDiff && (
<FileDiffPanel className="-mt-1.5" diff={view.inlineDiff} path={isFileEdit ? view.subtitle : undefined} />
)}
{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'
)}
>
{clampForDisplay(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={clampForDisplay(view.stdout)} />
) : (
clampForDisplay(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={clampForDisplay(view.stderr)} />
) : (
clampForDisplay(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={clampForDisplay(view.detail)} /> : clampForDisplay(view.detail)}
</pre>
) : (
<CompactMarkdown
className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')}
text={clampForDisplay(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' && !(isFileEdit && view.inlineDiff) && (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
</pre>
)}
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
</pre>
</details>
)}
</div>
)}
</div>
)
}
// A back-to-back run of this many tool calls collapses into the bounded,
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3
// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on
// growth (a call lands or a row expands) unless the user scrolled up, and fades
// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live
// preview. `enabled` is false for short runs, leaving the plain flat stack.
function useToolWindow(enabled: boolean) {
const scrollRef = useRef<HTMLDivElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const stickRef = useRef(true)
const [faded, setFaded] = useState(false)
const syncFade = useCallback(() => setFaded((scrollRef.current?.scrollTop ?? 0) > 4), [])
const onScroll = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 8
syncFade()
}, [syncFade])
useEffect(() => {
const el = scrollRef.current
const content = contentRef.current
if (!enabled || !el || !content) {
return
}
const pin = () => {
if (stickRef.current) {
el.scrollTop = el.scrollHeight
}
syncFade()
}
pin()
const observer = new ResizeObserver(pin)
observer.observe(content)
return () => observer.disconnect()
}, [enabled, syncFade])
return { contentRef, faded, onScroll, scrollRef }
}
/**
* 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 still never *label* the group: each tool is a standalone row on the
* tight `--tool-row-gap` rhythm. Once a run reaches `TOOL_GROUP_SCROLL_THRESHOLD`
* rows it collapses into a fixed-height, auto-scrolling window so a long run
* doesn't shove the reply off screen; shorter runs are byte-identical to before.
* The DOM shape is the same either way — only classes flip — so a run that
* crosses the threshold mid-stream never remounts a row. `ToolEmbedContext` is
* false so every row owns its own chrome (timer / preview / copy / 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}`)
const bounded = Children.count(children) >= TOOL_GROUP_SCROLL_THRESHOLD
const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded)
return (
<ToolEmbedContext.Provider value={false}>
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" data-tool-group="" ref={enterRef}>
<div
className={cn(
bounded && 'tool-group-scroll max-h-(--tool-group-scroll-max-h) overflow-y-auto',
bounded && faded && 'tool-group-scroll--faded'
)}
onScroll={bounded ? onScroll : undefined}
ref={scrollRef}
>
<div className="grid min-w-0 max-w-full gap-(--tool-row-gap)" ref={contentRef}>
{children}
</div>
</div>
</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} />
}