Files
hapi/web/src/components/ToolCard/ToolCard.tsx
T
AnanovoandGitHub c78785a0b2 fix(web): open tool details from inline previews (#725)
* fix(web): open tool details from inline previews

* fix(web): ignore nested controls in tool previews
2026-05-29 10:22:54 +08:00

448 lines
20 KiB
TypeScript

import type { ToolCallBlock } from '@/chat/types'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import { memo, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { isObject, safeStringify } from '@hapi/protocol'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { PermissionFooter } from '@/components/ToolCard/PermissionFooter'
import { AskUserQuestionFooter } from '@/components/ToolCard/AskUserQuestionFooter'
import { RequestUserInputFooter } from '@/components/ToolCard/RequestUserInputFooter'
import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion'
import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput'
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all'
import { getToolResultViewComponent } from '@/components/ToolCard/views/_results'
import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers'
import type { TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode'
import { usePointerFocusRing } from '@/hooks/usePointerFocusRing'
import { getInputStringAny, truncate } from '@/lib/toolInputUtils'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { TraceSection } from '@/components/ToolCard/trace'
import { isSubagentToolName } from '@/chat/subagentTool'
const ELAPSED_INTERVAL_MS = 1000
const TERMINAL_RELATED_TOOL_NAMES = new Set(['Bash', 'CodexBash', 'shell_command', 'run_shell_command'])
export function shouldUseCompactTerminalToolCard(toolName: string, terminalToolDisplayMode: TerminalToolDisplayMode): boolean {
return TERMINAL_RELATED_TOOL_NAMES.has(toolName) && terminalToolDisplayMode === 'compact'
}
export function shouldShowInlineToolCardBody(
toolName: string,
presentationMinimal: boolean,
terminalToolDisplayMode: TerminalToolDisplayMode
): boolean {
if (isSubagentToolName(toolName)) return false
if (TERMINAL_RELATED_TOOL_NAMES.has(toolName)) {
return terminalToolDisplayMode === 'detailed'
}
return !presentationMinimal
}
function ElapsedView(props: { from: number; active: boolean }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!props.active) return
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS)
return () => clearInterval(id)
}, [props.active, props.from])
if (!props.active) return null
const elapsed = Math.max(0, now - props.from) / 1000
if (!Number.isFinite(elapsed)) return null
return (
<span className="font-mono text-xs text-[var(--app-hint)]">
{elapsed.toFixed(1)}s
</span>
)
}
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
if (!isSubagentToolName(block.tool.name)) return null
const children = block.children
.filter((child): child is ToolCallBlock => child.kind === 'tool-call')
.filter((child) => child.tool.state === 'pending' || child.tool.state === 'running' || child.tool.state === 'completed' || child.tool.state === 'error')
if (children.length === 0) return null
const visible = children.slice(-3)
return { visible, remaining: children.length - visible.length }
}
function renderTaskSummary(
block: ToolCallBlock,
metadata: SessionMetadataSummary | null,
t: (key: string, params?: Record<string, string | number>) => string,
): ReactNode | null {
const summary = getTaskSummaryChildren(block)
if (!summary) return null
const visible = summary.visible
const remaining = summary.remaining
return (
<div className="flex flex-col gap-1 px-1">
<div className="flex flex-col gap-1">
{visible.map((child) => (
<div key={child.id} className="flex items-center gap-2">
<div className="min-w-0 flex-1 font-mono text-xs text-[var(--app-hint)]">
<span className="mr-2 inline-block w-4 text-center align-middle">
<TaskStateIcon state={child.tool.state} />
</span>
<span className="align-middle break-all">
{formatTaskChildLabel(child, metadata, t)}
</span>
</div>
</div>
))}
{remaining > 0 ? (
<div className="text-xs text-[var(--app-hint)] italic">
(+{remaining} more)
</div>
) : null}
</div>
</div>
)
}
function renderToolInput(block: ToolCallBlock, surface: 'inline' | 'dialog' = 'inline'): ReactNode {
const collapseLongContent = surface === 'inline'
const codeBlockSurfaceProps = surface === 'dialog'
? { size: 'comfortable' as const, scrollY: true }
: {}
const toolName = block.tool.name
const input = block.tool.input
if (isSubagentToolName(toolName) && isObject(input) && typeof input.prompt === 'string') {
return <MarkdownRenderer content={input.prompt} />
}
const commandArray = isObject(input) && Array.isArray(input.command) ? input.command : null
if ((toolName === 'CodexBash' || toolName === 'Bash') && (typeof commandArray?.[0] === 'string' || typeof input === 'object')) {
const cmd = Array.isArray(commandArray)
? commandArray.filter((part) => typeof part === 'string').join(' ')
: getInputStringAny(input, ['command', 'cmd'])
if (cmd) {
return <CodeBlock code={cmd} language="bash" title="Command" collapseLongContent={collapseLongContent} {...codeBlockSurfaceProps} />
}
}
return <CodeBlock code={safeStringify(input)} language="json" title="Input" collapseLongContent={collapseLongContent} {...codeBlockSurfaceProps} />
}
export function ToolStatusIcon(props: { state: ToolCallBlock['tool']['state'] }) {
if (props.state === 'completed') {
return (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M5.2 8.3l1.8 1.8 3.8-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
if (props.state === 'error') {
return (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
if (props.state === 'pending') {
return (
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
<rect x="4.5" y="7" width="7" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 7V5.8a2 2 0 0 1 4 0V7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
return (
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
</svg>
)
}
export function toolStatusColorClass(state: ToolCallBlock['tool']['state']): string {
if (state === 'completed') return 'text-emerald-600'
if (state === 'error') return 'text-red-600'
if (state === 'pending') return 'text-amber-600'
return 'text-[var(--app-hint)]'
}
function DetailsIcon() {
return (
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
const INLINE_PREVIEW_INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, summary, [role="button"], [contenteditable="true"]'
function isNestedInteractiveElement(event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>): boolean {
if (event.target === event.currentTarget) return false
if (!(event.target instanceof Element)) return false
const interactive = event.target.closest(INLINE_PREVIEW_INTERACTIVE_SELECTOR)
return interactive !== null && interactive !== event.currentTarget
}
type ToolCardProps = {
api: ApiClient
sessionId: string
metadata: SessionMetadataSummary | null
terminalToolDisplayMode: TerminalToolDisplayMode
disabled: boolean
onDone: () => void
block: ToolCallBlock
}
export function ToolDetailDialogContent(props: {
block: ToolCallBlock
metadata: SessionMetadataSummary | null
}) {
const { t } = useTranslation()
const toolName = props.block.tool.name
const FullToolView = getToolFullViewComponent(toolName)
const ResultToolView = getToolResultViewComponent(toolName)
const permission = props.block.tool.permission
const isAskUserQuestion = isAskUserQuestionToolName(toolName)
const isRequestUserInput = isRequestUserInputToolName(toolName)
const isQuestionTool = isAskUserQuestion || isRequestUserInput
const isQuestionToolWithAnswers = isQuestionTool
&& permission?.answers
&& Object.keys(permission.answers).length > 0
return (
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
</div>
{FullToolView ? (
<FullToolView block={props.block} metadata={props.metadata} surface="dialog" />
) : (
renderToolInput(props.block, 'dialog')
)}
</div>
<TraceSection block={props.block} metadata={props.metadata} />
{!isQuestionToolWithAnswers ? (
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultToolView block={props.block} metadata={props.metadata} surface="dialog" />
</div>
) : null}
</div>
)
}
function ToolCardInner(props: ToolCardProps) {
const { t } = useTranslation()
const [detailsOpen, setDetailsOpen] = useState(false)
const presentation = useMemo(() => getToolPresentation({
toolName: props.block.tool.name,
input: props.block.tool.input,
result: props.block.tool.result,
childrenCount: props.block.children.length,
description: props.block.tool.description,
metadata: props.metadata
}, t), [
props.block.tool.name,
props.block.tool.input,
props.block.tool.result,
props.block.children.length,
props.block.tool.description,
props.metadata,
t
])
const toolName = props.block.tool.name
const toolTitle = presentation.title
const subtitle = presentation.subtitle ?? props.block.tool.description
const taskSummary = renderTaskSummary(props.block, props.metadata, t)
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
const isCodexAgentCard = toolName === 'CodexAgent'
const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode)
const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode)
const CompactToolView = showInline ? getToolViewComponent(toolName) : null
const ResultToolView = getToolResultViewComponent(toolName)
const permission = props.block.tool.permission
const isAskUserQuestion = isAskUserQuestionToolName(toolName)
const isRequestUserInput = isRequestUserInputToolName(toolName)
const isQuestionTool = isAskUserQuestion || isRequestUserInput
const showsPermissionFooter = Boolean(permission && (
permission.status === 'pending'
|| ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason))
))
const hasBody = showInline || taskSummary !== null || showsPermissionFooter
const stateColor = toolStatusColorClass(props.block.tool.state)
const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing()
const openDetails = () => setDetailsOpen(true)
const openDetailsFromInlinePreview = (event: MouseEvent<HTMLElement>) => {
if (isNestedInteractiveElement(event)) return
openDetails()
}
const openDetailsFromInlinePreviewKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (isNestedInteractiveElement(event)) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openDetails()
}
}
const header = (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-1 flex-col gap-1">
<div className="min-w-0 flex items-center gap-2">
<div className="shrink-0 flex h-3.5 w-3.5 items-center justify-center text-[var(--app-tool-card-accent)] leading-none">
{presentation.icon}
</div>
<CardTitle className={cn(
'min-w-0 text-sm font-medium leading-tight text-[var(--app-fg)]',
isCodexAgentCard ? 'truncate whitespace-nowrap' : 'break-words'
)}>
{toolTitle}
</CardTitle>
</div>
{subtitle ? (
<CardDescription className={cn(
'font-mono text-xs text-[var(--app-tool-card-subtitle)]',
isCodexAgentCard || useCompactTerminalCard ? 'truncate whitespace-nowrap' : 'break-all'
)}>
{truncate(subtitle, 160)}
</CardDescription>
) : null}
</div>
<div className={cn(
'flex shrink-0 items-center gap-2 self-center text-[var(--app-hint)]',
subtitle ? '-translate-y-0.5' : null
)}>
<ElapsedView from={runningFrom} active={props.block.tool.state === 'running'} />
<span className={stateColor}>
<ToolStatusIcon state={props.block.tool.state} />
</span>
<span className="text-[var(--app-hint)]">
<DetailsIcon />
</span>
</div>
</div>
)
return (
<Card className="overflow-hidden rounded-[20px] bg-[var(--app-tool-card-bg)] shadow-none">
<CardHeader className={cn('space-y-0 p-3', subtitle ? 'pb-2' : null)}>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogTrigger asChild>
<button
type="button"
className={cn(
'w-full text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]',
suppressFocusRing && 'focus-visible:ring-0'
)}
onPointerDown={onTriggerPointerDown}
onKeyDown={onTriggerKeyDown}
onBlur={onTriggerBlur}
>
{header}
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{toolTitle}</DialogTitle>
</DialogHeader>
<ToolDetailDialogContent block={props.block} metadata={props.metadata} />
</DialogContent>
</Dialog>
</CardHeader>
{hasBody ? (
<CardContent className="px-3 pb-3 pt-1">
{taskSummary ? (
<div className="mt-2">
{taskSummary}
</div>
) : null}
{showInline ? (
CompactToolView ? (
<div
className="mt-3 cursor-pointer rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
role="button"
tabIndex={0}
onClick={openDetailsFromInlinePreview}
onKeyDown={openDetailsFromInlinePreviewKeyDown}
>
<CompactToolView block={props.block} metadata={props.metadata} surface="inline" />
</div>
) : (
<div className="mt-3 flex flex-col gap-3">
<div
className="cursor-pointer rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
role="button"
tabIndex={0}
onClick={openDetailsFromInlinePreview}
onKeyDown={openDetailsFromInlinePreviewKeyDown}
>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.input')}</div>
{renderToolInput(props.block, 'inline')}
</div>
<div
className="cursor-pointer rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
role="button"
tabIndex={0}
onClick={openDetailsFromInlinePreview}
onKeyDown={openDetailsFromInlinePreviewKeyDown}
>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultToolView block={props.block} metadata={props.metadata} surface="inline" />
</div>
</div>
)
) : null}
{isAskUserQuestion && permission?.status === 'pending' ? (
<AskUserQuestionFooter
api={props.api}
sessionId={props.sessionId}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
) : isRequestUserInput && permission?.status === 'pending' ? (
<RequestUserInputFooter
api={props.api}
sessionId={props.sessionId}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
) : (
<PermissionFooter
api={props.api}
sessionId={props.sessionId}
metadata={props.metadata}
tool={props.block.tool}
disabled={props.disabled}
onDone={props.onDone}
/>
)}
</CardContent>
) : null}
</Card>
)
}
export const ToolCard = memo(ToolCardInner)