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 { ToolDetailDialogContent, ToolStatusIcon, 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' 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 hydrationRunRef = useRef(0) const retryTimerRef = useRef | null>(null) 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 fileCount = props.block.summary.fileTargets.length return ( {open ? (
{props.block.presentationMode === 'codex-exploration' ? ( ) : props.block.tools.map((tool) => { 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}
) }