import { useEffect, useMemo, useRef, useState } from 'react' import type { ToolGroupBlock } from '@/chat/toolGroups' import type { ToolCallBlock } from '@/chat/types' import { getCodexCommandActions, type CodexCommandAction } from '@/chat/codexCommandPresentation' import type { SessionMetadataSummary } from '@/types/api' import { useHappyChatContext } from '@/components/AssistantChat/context' import { getToolTimingDetails, ToolDetailDialogContent, ToolStatusIcon, ToolTimingSummary, toolStatusColorClass } from '@/components/ToolCard/ToolCard' import { getToolPresentation } from '@/components/ToolCard/knownTools' import { formatGroupedHeaderSubtitle, formatGroupedHeaderTitle, safeGroupedLabelValue } from '@/components/ToolCard/groupedPresentation' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' import { formatDuration } from '@/chat/presentation' const TIMING_INTERVAL_MS = 1000 export function getToolGroupTiming(tools: ToolCallBlock[], now: number): { startedAt: number | null completedAt: number | null durationMs: number | null running: boolean } { const startedValues = tools .filter((tool) => tool.tool.state !== 'pending') .map((tool) => tool.tool.startedAt ?? tool.tool.createdAt) .filter((value): value is number => Number.isFinite(value)) const startedAt = startedValues.length > 0 ? Math.min(...startedValues) : null const running = tools.some((tool) => tool.tool.state === 'running') const allFinished = tools.length > 0 && tools.every((tool) => tool.tool.state === 'completed' || tool.tool.state === 'error') const completedValues = allFinished ? tools.map((tool) => tool.tool.completedAt).filter((value): value is number => value != null && Number.isFinite(value)) : [] const completedAt = allFinished && completedValues.length === tools.length ? Math.max(...completedValues) : null const durationEnd = running ? now : completedAt const durationMs = startedAt != null && durationEnd != null && durationEnd >= startedAt ? durationEnd - startedAt : null return { startedAt, completedAt, durationMs, running } } function DetailsIcon(props: { open: boolean }) { return ( ) } function SummaryBadge(props: { className: string; text: string }) { return ( {props.text} ) } function RowStatusBadge(props: { block: ToolCallBlock }) { const { t } = useTranslation() if (props.block.tool.state === 'error') { return } if (props.block.tool.state === 'running') { return } if (props.block.tool.state === 'pending') { return } return null } function formatActionSummary(block: ToolGroupBlock, t: (key: string, params?: Record) => string): string | null { const parts: string[] = [] const { countsByKind } = block.summary if (countsByKind.mutation > 0) { parts.push(t('toolGroup.summary.mutation', { n: countsByKind.mutation })) } if (countsByKind.read > 0) { parts.push(t('toolGroup.summary.read', { n: countsByKind.read })) } if (countsByKind.command > 0) { parts.push(t('toolGroup.summary.command', { n: countsByKind.command })) } if (countsByKind.search > 0) { parts.push(t('toolGroup.summary.search', { n: countsByKind.search })) } if (countsByKind.web > 0) { parts.push(t('toolGroup.summary.web', { n: countsByKind.web })) } if (countsByKind.other > 0 && parts.length > 0) { parts.push(t('toolGroup.summary.other', { n: countsByKind.other })) } return parts.length > 0 ? parts.join(' · ') : null } function RowLabel(props: { block: ToolCallBlock; metadata: SessionMetadataSummary | null }) { const { t } = useTranslation() 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.nativeTitle ?? props.block.tool.description, metadata: props.metadata }, t), [props.block, props.metadata, t]) return (
{presentation.icon}
{presentation.title}
{presentation.subtitle ? (
{presentation.subtitle}
) : null}
) } function basename(value: string): string { return value.replace(/\\/g, '/').split('/').filter(Boolean).at(-1) ?? value } function codexActionLabel( action: CodexCommandAction, t: (key: string, params?: Record) => string ): { title: string; detail: string | null } { if (action.type === 'read') { const detail = safeGroupedLabelValue(action.name) ?? safeGroupedLabelValue(action.path) return { title: t('toolGroup.codex.read'), detail: detail ? basename(detail) : null } } if (action.type === 'listFiles') { return { title: t('toolGroup.codex.list'), detail: safeGroupedLabelValue(action.path) } } if (action.type === 'search') { const query = safeGroupedLabelValue(action.query) const path = safeGroupedLabelValue(action.path) return { title: t('toolGroup.codex.search'), detail: query && path ? t('toolGroup.codex.searchIn', { query, path }) : query ?? path } } return { title: t('toolGroup.friendly.genericCommand'), detail: null } } function CodexExplorationRows(props: { tools: ToolCallBlock[] onSelect: (toolId: string) => void }) { const { t } = useTranslation() return props.tools.flatMap((tool) => ( getCodexCommandActions(tool).map((action, index) => { const label = codexActionLabel(action, t) return ( ) }) )) } export function ToolGroupCard(props: { block: ToolGroupBlock metadata: SessionMetadataSummary | null }) { const { t } = useTranslation() const ctx = useHappyChatContext() const [open, setOpen] = useState(props.block.defaultOpen) const [selectedToolId, setSelectedToolId] = useState(null) const [isHydratingHistory, setIsHydratingHistory] = useState(false) const [historyExhausted, setHistoryExhausted] = useState(false) const [retryNonce, setRetryNonce] = useState(0) const [now, setNow] = useState(() => Date.now()) const hydrationRunRef = useRef(0) const retryTimerRef = useRef | null>(null) const groupTiming = getToolGroupTiming(props.block.tools, now) useEffect(() => { if (!groupTiming.running) return setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), TIMING_INTERVAL_MS) return () => clearInterval(id) }, [groupTiming.running, groupTiming.startedAt]) function clearRetryTimer() { if (retryTimerRef.current === null) { return } clearTimeout(retryTimerRef.current) retryTimerRef.current = null } useEffect(() => { clearRetryTimer() hydrationRunRef.current += 1 setOpen(props.block.defaultOpen) setSelectedToolId(null) setIsHydratingHistory(false) setHistoryExhausted(false) }, [props.block.id, props.block.defaultOpen]) useEffect(() => { return () => { clearRetryTimer() } }, []) useEffect(() => { if (!open) { clearRetryTimer() hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(false) return } if (!props.block.needsOlderHistory) { clearRetryTimer() hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(false) return } if (isHydratingHistory || historyExhausted) { return } if (ctx.isLoadingMoreMessages) { return } if (!ctx.hasMoreMessages) { hydrationRunRef.current += 1 setIsHydratingHistory(false) setHistoryExhausted(true) return } const runId = hydrationRunRef.current + 1 hydrationRunRef.current = runId setHistoryExhausted(false) setIsHydratingHistory(true) void ctx.loadOlderMessagesPreservingScroll() .then((loaded) => { if (hydrationRunRef.current !== runId) return setIsHydratingHistory(false) if (!loaded) { if (!ctx.hasMoreMessages) { setHistoryExhausted(true) return } clearRetryTimer() retryTimerRef.current = setTimeout(() => { retryTimerRef.current = null if (hydrationRunRef.current !== runId) return setRetryNonce((value) => value + 1) }, 150) } }) .catch(() => { if (hydrationRunRef.current !== runId) return clearRetryTimer() setIsHydratingHistory(false) setHistoryExhausted(true) }) }, [ open, props.block.needsOlderHistory, ctx.hasMoreMessages, ctx.isLoadingMoreMessages, ctx.loadOlderMessagesPreservingScroll, historyExhausted, isHydratingHistory, retryNonce, ]) const selectedTool = useMemo( () => props.block.tools.find((tool) => tool.id === selectedToolId) ?? null, [props.block.tools, selectedToolId] ) const selectedPresentation = useMemo(() => { if (!selectedTool) return null return getToolPresentation({ toolName: selectedTool.tool.name, input: selectedTool.tool.input, result: selectedTool.tool.result, childrenCount: selectedTool.children.length, description: selectedTool.tool.nativeTitle ?? selectedTool.tool.description, metadata: props.metadata }, t) }, [selectedTool, props.metadata, t]) const primaryTitle = formatGroupedHeaderTitle(props.block, t) const subtitle = props.block.presentationMode === 'codex-exploration' ? null : formatGroupedHeaderSubtitle(props.block, t) ?? formatActionSummary(props.block, t) const summaryBadgeText = props.block.presentationMode === 'codex-exploration' ? null : subtitle ?? t('toolGroup.toolCount', { n: props.block.tools.length }) const fileCount = props.block.summary.fileTargets.length return ( {open ? (
{props.block.presentationMode === 'codex-exploration' ? ( ) : props.block.tools.map((tool) => { const timing = getToolTimingDetails(tool.tool, now) return ( ) })}
{isHydratingHistory ? (
{t('toolGroup.loadingOlderHistory')}
) : null} {!isHydratingHistory && historyExhausted && props.block.needsOlderHistory ? (
{t('toolGroup.historyUnavailable')}
) : null}
) : null} { if (!nextOpen) { setSelectedToolId(null) } }}> {selectedTool && selectedPresentation ? ( <> {selectedPresentation.title} ) : null}
) }