import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, ArrowRight, Brain, CheckCircle2, ChevronDown, ChevronRight, Database, Eye, ExternalLink, FileSearch, Github, MessageSquare, Pencil, Search, ShieldAlert, Terminal, Users, Wrench, XCircle, Zap, } from 'lucide-react'; import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer'; import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel'; import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard'; import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting'; import { formatToolGroupLabel, extractToolCallSnippet, formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary'; import { buildActivityStream, groupActivityStream, extractLatestIntent, generateActivitySummary, type GroupedActivityItem } from '@renderer/lib/activityGrouping'; import type { ChatMessageRecord } from '@shared/domain/session'; import type { ProjectGitFileReference } from '@shared/domain/project'; import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline'; /* ── Props ─────────────────────────────────────────────────── */ export interface TurnActivityPanelProps { thinkingMessages: ChatMessageRecord[]; run?: SessionRunRecord; isActive: boolean; turnStartedAt?: string; sessionId: string; currentIntent?: string; agentNames?: ReadonlySet; isLastRunPanel?: boolean; onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; onOpenCommitComposer?: () => void; } /* ── Helpers ───────────────────────────────────────────────── */ function truncatePreview(text: string, maxLength: number): string { const firstLine = text.split('\n')[0] ?? ''; const cleaned = firstLine.trim(); if (cleaned.length <= maxLength) return cleaned; return `${cleaned.slice(0, maxLength)}…`; } function formatSummaryParts(summary: ActivitySummary): string[] { const parts: string[] = []; if (summary.toolCalls > 0) { parts.push(`${summary.toolCalls} ${summary.toolCalls === 1 ? 'action' : 'actions'}`); } if (summary.handoffs > 0) { parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`); } if (summary.approvals > 0) { parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`); } return parts; } /* ── Tool-category icon ────────────────────────────────────── */ function ToolCategoryIcon({ toolName, className }: { toolName?: string; className?: string }) { const base = className ?? 'size-3 shrink-0'; if (!toolName) return ; if (toolName.startsWith('github-')) { return ; } switch (toolName) { case 'view': return ; case 'grep': case 'glob': return ; case 'lsp': return ; case 'edit': case 'create': return ; case 'powershell': return ; case 'web_fetch': case 'web_search': return ; case 'sql': return ; case 'task': return ; default: return ; } } /* ── Event icon (for non-tool events) ──────────────────────── */ function ActivityEventIcon({ kind, status, toolName }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status']; toolName?: string; }) { const base = 'size-3 shrink-0'; switch (kind) { case 'tool-call': return ; case 'approval': return ( ); case 'handoff': return ; case 'message': return ; case 'run-completed': return ; case 'run-cancelled': return ; case 'run-failed': return ; default: return ; } } /* ── Single event row ──────────────────────────────────────── */ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) { const label = formatEventLabel(event); const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed'; return (
{label} {/* Approval kind badge */} {event.kind === 'approval' && event.approvalKind && ( {event.approvalKind === 'final-response' ? 'response' : 'tool'} )} {/* Content preview for message events */} {event.kind === 'message' && event.content && (

{truncateContent(event.content, 120)}

)} {/* Approval detail */} {event.kind === 'approval' && event.approvalDetail && (

{truncateContent(event.approvalDetail, 120)}

)} {/* Error detail */} {event.error && (

{truncateContent(event.error, 120)}

)} {/* Tool call argument details */} {event.kind === 'tool-call' && ( )} {/* File change preview for tool-call events */} {event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
)}
); } /* ── Grouped tool-call row ─────────────────────────────────── */ function GroupedToolCallRow({ toolName, events }: { toolName: string; events: RunTimelineEventRecord[] }) { const [expanded, setExpanded] = useState(false); const label = formatToolGroupLabel(toolName, events.length); const snippets = useMemo( () => events.map((e) => extractToolCallSnippet(toolName, e.toolArguments)).filter(Boolean) as string[], [toolName, events], ); const hasFileChanges = events.some((e) => e.fileChanges && e.fileChanges.length > 0); return (
{/* Collapsed preview: show snippets inline */} {!expanded && snippets.length > 0 && (
{snippets.slice(0, 6).map((s, i) => ( {s} ))} {snippets.length > 6 && ( +{snippets.length - 6} more )}
)} {/* Expanded: full per-event rows */} {expanded && (
{events.map((event) => (
{formatToolCallPrimaryLabel(event.toolName, event.toolArguments)} {event.fileChanges && event.fileChanges.length > 0 && (
)}
))}
)} {/* Aggregate file changes when collapsed */} {!expanded && hasFileChanges && (
{events .filter((e) => e.fileChanges && e.fileChanges.length > 0) .flatMap((e) => e.fileChanges!) .length > 0 && ( e.fileChanges ?? [])} /> )}
)}
); } /* ── Intent divider ────────────────────────────────────────── */ function IntentDividerRow({ text }: { text: string }) { return (
{text}
); } /* ── Thinking step row ─────────────────────────────────────── */ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) { const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]); if (message.pending && !message.content) return null; return (

"{preview}"

); } /* ── Thinking group (multiple consecutive) ─────────────────── */ function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) { const [expanded, setExpanded] = useState(false); const visibleMessages = messages.filter((m) => !m.pending || m.content); if (visibleMessages.length === 0) return null; const latest = visibleMessages[visibleMessages.length - 1]; const preview = truncatePreview(latest.content, 180); const hiddenCount = visibleMessages.length - 1; return (

"{preview}"

{hiddenCount > 0 && ( )}
{expanded && (
{visibleMessages.slice(0, -1).map((msg) => (

"{truncatePreview(msg.content, 140)}"

))}
)}
); } /* ── Active pulse dots ─────────────────────────────────────── */ function ActivityPulse() { return ( ); } /* ── Grouped item renderer ─────────────────────────────────── */ function GroupedItemRow({ item }: { item: GroupedActivityItem }) { switch (item.kind) { case 'intent-divider': return ; case 'single-event': return ; case 'tool-group': return ; case 'single-thinking': return ; case 'thinking-group': return ; } } /* ── Main component ────────────────────────────────────────── */ export function TurnActivityPanel({ thinkingMessages, run, isActive, turnStartedAt, sessionId, currentIntent, agentNames, isLastRunPanel, onDiscard, onOpenCommitComposer, }: TurnActivityPanelProps) { const [expanded, setExpanded] = useState(false); const wasActiveRef = useRef(isActive); useEffect(() => { if (isActive && (thinkingMessages.length > 0 || run)) { setExpanded(true); } else if (wasActiveRef.current && !isActive) { setExpanded(false); } wasActiveRef.current = isActive; }, [isActive, thinkingMessages.length, run]); const toggle = useCallback(() => setExpanded((prev) => !prev), []); const scopedEvents = useMemo( () => filterEventsByAgent(run?.events ?? [], agentNames), [run?.events, agentNames], ); const effectiveTurnStartedAt = useMemo(() => { if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) { return turnStartedAt; } let earliest = turnStartedAt; for (const e of scopedEvents) { if (!earliest || e.occurredAt < earliest) { earliest = e.occurredAt; break; } } return earliest; }, [agentNames, scopedEvents, turnStartedAt]); const elapsed = useElapsedTimer( thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined, isActive, run?.completedAt, ); const summary = useMemo( () => summarizeActivity(thinkingMessages, scopedEvents), [thinkingMessages, scopedEvents], ); const groupedItems = useMemo(() => { const stream = buildActivityStream(thinkingMessages, scopedEvents); return groupActivityStream(stream); }, [thinkingMessages, scopedEvents]); // Prefer the session-level normalized intent when active; fall back to // scanning run timeline for report_intent tool calls (backward compat). const intentText = useMemo( () => (isActive ? currentIntent : undefined) ?? extractLatestIntent(scopedEvents), [isActive, currentIntent, scopedEvents], ); const fallbackSummary = useMemo( () => !intentText ? generateActivitySummary(scopedEvents) : undefined, [intentText, scopedEvents], ); if (thinkingMessages.length === 0 && !run) { return null; } const summaryParts = formatSummaryParts(summary); const runStatus = run?.status; const isCompleted = runStatus === 'completed'; const isFailed = runStatus === 'error'; const isCancelled = runStatus === 'cancelled'; const isTerminated = isCompleted || isFailed || isCancelled; const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false); let summaryLabel: string; if (isActive) { summaryLabel = 'Working'; } else if (isFailed) { summaryLabel = elapsed ? `Failed after ${elapsed}` : 'Failed'; } else if (isCancelled) { summaryLabel = elapsed ? `Cancelled after ${elapsed}` : 'Cancelled'; } else if (elapsed) { summaryLabel = `Completed in ${elapsed}`; } else { summaryLabel = 'Completed'; } const headerDetail = intentText ?? fallbackSummary; const statusColorClass = isFailed ? 'text-[var(--color-status-error)]' : isCancelled ? 'text-[var(--color-text-muted)]' : isActive ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'; const statusDotClass = isFailed ? 'bg-[var(--color-status-error)]' : isCancelled ? 'bg-[var(--color-text-muted)]' : isActive ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-status-success)]'; return (
{/* Summary header */} {/* Expanded activity stream — grouped */} {expanded && (
{groupedItems.map((item, index) => ( ))}
{/* Post-run git changes */} {showGitSummary && (
)}
)}
); }