diff --git a/src/renderer/components/chat/TurnActivityPanel.tsx b/src/renderer/components/chat/TurnActivityPanel.tsx index 38df009..0e7a278 100644 --- a/src/renderer/components/chat/TurnActivityPanel.tsx +++ b/src/renderer/components/chat/TurnActivityPanel.tsx @@ -6,8 +6,17 @@ import { CheckCircle2, ChevronDown, ChevronRight, + Database, + Eye, + ExternalLink, + FileSearch, + Github, MessageSquare, + Pencil, + Search, ShieldAlert, + Terminal, + Users, Wrench, XCircle, Zap, @@ -18,21 +27,12 @@ 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'; -/* ── Types ─────────────────────────────────────────────────── */ - -/** A unified activity stream item, merging chat thinking messages - * and run timeline events into a single chronological list. */ -type ActivityStreamItem = - | { kind: 'thinking-step'; message: ChatMessageRecord } - | { kind: 'timeline-event'; event: RunTimelineEventRecord }; - -/** Events to skip in the inline panel (redundant or implicit). */ -const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']); - /* ── Props ─────────────────────────────────────────────────── */ export interface TurnActivityPanelProps { @@ -41,9 +41,7 @@ export interface TurnActivityPanelProps { isActive: boolean; turnStartedAt?: string; sessionId: string; - /** Agent names in this turn group — used to scope run events in multi-agent runs. */ agentNames?: ReadonlySet; - /** True when this panel is the last one sharing a given run (controls git summary / discard). */ isLastRunPanel?: boolean; onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; onOpenCommitComposer?: () => void; @@ -58,35 +56,10 @@ function truncatePreview(text: string, maxLength: number): string { return `${cleaned.slice(0, maxLength)}…`; } -function buildActivityStream( - thinkingMessages: ChatMessageRecord[], - events: readonly RunTimelineEventRecord[], -): ActivityStreamItem[] { - const items: ActivityStreamItem[] = []; - - for (const msg of thinkingMessages) { - items.push({ kind: 'thinking-step', message: msg }); - } - - for (const event of events) { - if (SKIP_EVENT_KINDS.has(event.kind)) continue; - items.push({ kind: 'timeline-event', event }); - } - - // Sort chronologically by timestamp - items.sort((a, b) => { - const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt; - const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt; - return new Date(tsA).getTime() - new Date(tsB).getTime(); - }); - - return items; -} - function formatSummaryParts(summary: ActivitySummary): string[] { const parts: string[] = []; if (summary.toolCalls > 0) { - parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`); + parts.push(`${summary.toolCalls} ${summary.toolCalls === 1 ? 'action' : 'actions'}`); } if (summary.handoffs > 0) { parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`); @@ -94,20 +67,57 @@ function formatSummaryParts(summary: ActivitySummary): string[] { if (summary.approvals > 0) { parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`); } - if (summary.thinkingSteps > 0) { - parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`); - } return parts; } -/* ── Event icon ────────────────────────────────────────────── */ +/* ── Tool-category icon ────────────────────────────────────── */ -function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) { +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 ; + return ; case 'approval': return (
- +
@@ -203,6 +213,106 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) ); } +/* ── 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 }) { @@ -216,17 +326,61 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
- {message.authorName && ( - - {message.authorName} - - )} - {preview} +

+ "{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() { @@ -239,6 +393,23 @@ function ActivityPulse() { ); } +/* ── 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({ @@ -255,8 +426,6 @@ export function TurnActivityPanel({ const [expanded, setExpanded] = useState(false); const wasActiveRef = useRef(isActive); - // Auto-expand when the turn is active (run exists or thinking arrives). - // Auto-collapse once the turn finishes. useEffect(() => { if (isActive && (thinkingMessages.length > 0 || run)) { setExpanded(true); @@ -268,25 +437,20 @@ export function TurnActivityPanel({ const toggle = useCallback(() => setExpanded((prev) => !prev), []); - // When the run is shared across multiple panels (multi-agent sequential), - // scope events to only those belonging to this panel's agents. const scopedEvents = useMemo( () => filterEventsByAgent(run?.events ?? [], agentNames), [run?.events, agentNames], ); - // Derive per-agent timing from the scoped events when agent names are set - // (multi-agent run). For single-agent runs, use the run-level start time. const effectiveTurnStartedAt = useMemo(() => { if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) { return turnStartedAt; } - // Use the earliest scoped event as the start time for this agent's panel. let earliest = turnStartedAt; for (const e of scopedEvents) { if (!earliest || e.occurredAt < earliest) { earliest = e.occurredAt; - break; // events are already in insertion order (chronological) + break; } } return earliest; @@ -302,12 +466,18 @@ export function TurnActivityPanel({ [thinkingMessages, scopedEvents], ); - const activityStream = useMemo( - () => buildActivityStream(thinkingMessages, scopedEvents), - [thinkingMessages, scopedEvents], + const groupedItems = useMemo(() => { + const stream = buildActivityStream(thinkingMessages, scopedEvents); + return groupActivityStream(stream); + }, [thinkingMessages, scopedEvents]); + + // Extract intent text for the header + const intentText = useMemo(() => extractLatestIntent(scopedEvents), [scopedEvents]); + const fallbackSummary = useMemo( + () => !intentText ? generateActivitySummary(scopedEvents) : undefined, + [intentText, scopedEvents], ); - // Nothing to show — no thinking messages, no run, and not active if (thinkingMessages.length === 0 && !run) { return null; } @@ -318,10 +488,8 @@ export function TurnActivityPanel({ const isFailed = runStatus === 'error'; const isCancelled = runStatus === 'cancelled'; const isTerminated = isCompleted || isFailed || isCancelled; - // Only show git summary and discard on the last panel for a given run const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false); - // Build the summary label let summaryLabel: string; if (isActive) { summaryLabel = 'Working'; @@ -335,6 +503,8 @@ export function TurnActivityPanel({ summaryLabel = 'Completed'; } + const headerDetail = intentText ?? fallbackSummary; + const statusColorClass = isFailed ? 'text-[var(--color-status-error)]' : isCancelled @@ -374,9 +544,17 @@ export function TurnActivityPanel({ {summaryLabel} )} + {/* Intent / generated summary */} + {headerDetail && ( + + {'· '} + {intentText ? `"${headerDetail}"` : headerDetail} + + )} + {/* Inline counters */} {summaryParts.length > 0 && ( - + {'· '} {summaryParts.join(' · ')} @@ -389,16 +567,13 @@ export function TurnActivityPanel({ - {/* Expanded activity stream */} + {/* Expanded activity stream — grouped */} {expanded && (
- {activityStream.map((item) => { - if (item.kind === 'thinking-step') { - return ; - } - return ; - })} + {groupedItems.map((item, index) => ( + + ))}
{/* Post-run git changes */} diff --git a/src/renderer/lib/activityGrouping.ts b/src/renderer/lib/activityGrouping.ts new file mode 100644 index 0000000..197bbbd --- /dev/null +++ b/src/renderer/lib/activityGrouping.ts @@ -0,0 +1,171 @@ +import type { ChatMessageRecord } from '@shared/domain/session'; +import type { RunTimelineEventRecord } from '@shared/domain/runTimeline'; + +/* ── Input / output types ──────────────────────────────────── */ + +/** A flat activity stream item (the existing model). */ +export type ActivityStreamItem = + | { kind: 'thinking-step'; message: ChatMessageRecord } + | { kind: 'timeline-event'; event: RunTimelineEventRecord }; + +/** A grouped timeline item ready for rendering. */ +export type GroupedActivityItem = + | { kind: 'intent-divider'; intentText: string; event: RunTimelineEventRecord } + | { kind: 'single-event'; event: RunTimelineEventRecord } + | { kind: 'tool-group'; toolName: string; events: RunTimelineEventRecord[] } + | { kind: 'thinking-group'; messages: ChatMessageRecord[] } + | { kind: 'single-thinking'; message: ChatMessageRecord }; + +/* ── Events to skip in the inline panel ────────────────────── */ + +const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']); + +/* ── Build the flat stream (moved from TurnActivityPanel) ──── */ + +export function buildActivityStream( + thinkingMessages: ChatMessageRecord[], + events: readonly RunTimelineEventRecord[], +): ActivityStreamItem[] { + const items: ActivityStreamItem[] = []; + + for (const msg of thinkingMessages) { + items.push({ kind: 'thinking-step', message: msg }); + } + + for (const event of events) { + if (SKIP_EVENT_KINDS.has(event.kind)) continue; + items.push({ kind: 'timeline-event', event }); + } + + items.sort((a, b) => { + const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt; + const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt; + return new Date(tsA).getTime() - new Date(tsB).getTime(); + }); + + return items; +} + +/* ── Group the flat stream into displayable chunks ─────────── */ + +export function groupActivityStream(items: ActivityStreamItem[]): GroupedActivityItem[] { + const result: GroupedActivityItem[] = []; + + let i = 0; + while (i < items.length) { + const item = items[i]; + + // ── Thinking steps: group consecutive runs ────────────── + if (item.kind === 'thinking-step') { + const group: ChatMessageRecord[] = [item.message]; + let j = i + 1; + while (j < items.length && items[j].kind === 'thinking-step') { + group.push((items[j] as { kind: 'thinking-step'; message: ChatMessageRecord }).message); + j++; + } + if (group.length === 1) { + result.push({ kind: 'single-thinking', message: group[0] }); + } else { + result.push({ kind: 'thinking-group', messages: group }); + } + i = j; + continue; + } + + // ── Timeline events ───────────────────────────────────── + const event = item.event; + + // report_intent → phase divider + if (event.kind === 'tool-call' && event.toolName === 'report_intent') { + const intentText = + typeof event.toolArguments?.intent === 'string' + ? event.toolArguments.intent + : ''; + if (intentText) { + result.push({ kind: 'intent-divider', intentText, event }); + } + // Skip silently when intent text is empty + i++; + continue; + } + + // Tool calls: group consecutive calls of the same tool + if (event.kind === 'tool-call' && event.toolName) { + const toolName = event.toolName; + const group: RunTimelineEventRecord[] = [event]; + let j = i + 1; + while (j < items.length) { + const next = items[j]; + if (next.kind !== 'timeline-event') break; + if (next.event.kind !== 'tool-call') break; + if (next.event.toolName !== toolName) break; + group.push(next.event); + j++; + } + if (group.length === 1) { + result.push({ kind: 'single-event', event: group[0] }); + } else { + result.push({ kind: 'tool-group', toolName, events: group }); + } + i = j; + continue; + } + + // Everything else: single event + result.push({ kind: 'single-event', event }); + i++; + } + + return result; +} + +/* ── Extract the latest intent from events ─────────────────── */ + +export function extractLatestIntent(events: readonly RunTimelineEventRecord[]): string | undefined { + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if ( + e.kind === 'tool-call' + && e.toolName === 'report_intent' + && typeof e.toolArguments?.intent === 'string' + && e.toolArguments.intent.trim() + ) { + return e.toolArguments.intent.trim(); + } + } + return undefined; +} + +/* ── Generate a fallback summary from the tool mix ─────────── */ + +export function generateActivitySummary(events: readonly RunTimelineEventRecord[]): string | undefined { + const toolCounts = new Map(); + let editCount = 0; + let searchCount = 0; + let viewCount = 0; + + for (const e of events) { + if (e.kind !== 'tool-call' || !e.toolName) continue; + if (e.toolName === 'report_intent') continue; + toolCounts.set(e.toolName, (toolCounts.get(e.toolName) ?? 0) + 1); + + if (e.toolName === 'edit' || e.toolName === 'create') editCount++; + else if (e.toolName === 'grep' || e.toolName === 'glob' || e.toolName === 'lsp') searchCount++; + else if (e.toolName === 'view') viewCount++; + } + + const parts: string[] = []; + if (searchCount > 0) parts.push(`searched ${searchCount} ${searchCount === 1 ? 'pattern' : 'patterns'}`); + if (viewCount > 0) parts.push(`viewed ${viewCount} ${viewCount === 1 ? 'file' : 'files'}`); + if (editCount > 0) parts.push(`edited ${editCount} ${editCount === 1 ? 'file' : 'files'}`); + + if (parts.length === 0) { + const total = Array.from(toolCounts.values()).reduce((a, b) => a + b, 0); + if (total > 0) return `${total} ${total === 1 ? 'action' : 'actions'}`; + return undefined; + } + + // Capitalize first part + parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1); + return parts.join(', '); +} diff --git a/src/renderer/lib/runTimelineFormatting.ts b/src/renderer/lib/runTimelineFormatting.ts index 64da621..6811cd8 100644 --- a/src/renderer/lib/runTimelineFormatting.ts +++ b/src/renderer/lib/runTimelineFormatting.ts @@ -5,6 +5,7 @@ import type { SessionRunStatus, } from '@shared/domain/runTimeline'; import { buildMarkdownExcerpt } from '@shared/utils/markdownText'; +import { formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary'; export function formatRunTimestamp(isoDate: string): string { try { @@ -78,9 +79,7 @@ export function formatEventLabel(event: RunTimelineEventRecord): string { } return event.targetAgentName ? `Handoff to ${event.targetAgentName}` : 'Handoff'; case 'tool-call': - return event.toolName - ? `${event.agentName ?? 'Agent'} used ${event.toolName}` - : `${event.agentName ?? 'Agent'} tool call`; + return formatToolCallPrimaryLabel(event.toolName, event.toolArguments); case 'approval': if (event.status === 'completed') { return event.approvalTitle ? `${event.approvalTitle} approved` : 'Approval granted'; diff --git a/src/renderer/lib/toolCallSummary.ts b/src/renderer/lib/toolCallSummary.ts index f0213c4..ee98ee3 100644 --- a/src/renderer/lib/toolCallSummary.ts +++ b/src/renderer/lib/toolCallSummary.ts @@ -114,3 +114,180 @@ export function getDisplayableArguments( && value !== '', ); } + +/* ── Verb-based primary labels ─────────────────────────────── */ + +function shortenPath(rawPath: string): string { + const normalized = rawPath.replace(/\\/g, '/'); + const lastSlash = normalized.lastIndexOf('/'); + const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; + return truncateSummary(fileName); +} + +function pathWithRange(args: Record): string | undefined { + const path = stringArg(args, 'path'); + if (!path) return undefined; + const name = shortenPath(path); + const range = args['view_range'] ?? args['viewRange']; + if (Array.isArray(range) && range.length === 2) { + return `${name}:${range[0]}-${range[1]}`; + } + return name; +} + +type VerbLabelExtractor = (args: Record) => string; + +const verbLabels: Record = { + view: (args) => { + const target = pathWithRange(args); + return target ? `Viewed \`${target}\`` : 'Viewed a file'; + }, + edit: (args) => { + const name = stringArg(args, 'path') ? shortenPath(stringArg(args, 'path')!) : undefined; + return name ? `Edited \`${name}\`` : 'Edited a file'; + }, + create: (args) => { + const name = stringArg(args, 'path') ? shortenPath(stringArg(args, 'path')!) : undefined; + return name ? `Created \`${name}\`` : 'Created a file'; + }, + grep: (args) => { + const pattern = stringArg(args, 'pattern'); + return pattern ? `Searched for \`${truncateSummary(pattern)}\`` : 'Searched files'; + }, + glob: (args) => { + const pattern = stringArg(args, 'pattern'); + return pattern ? `Glob \`${truncateSummary(pattern)}\`` : 'Searched for files'; + }, + lsp: (args) => { + const op = stringArg(args, 'operation'); + const file = stringArg(args, 'file') ? shortenPath(stringArg(args, 'file')!) : undefined; + if (op && file) return `LSP ${op} \`${file}\``; + return op ? `LSP ${op}` : 'LSP operation'; + }, + powershell: (args) => { + const cmd = stringArg(args, 'command'); + return cmd ? `Ran \`${truncateSummary(cmd)}\`` : 'Ran a command'; + }, + web_fetch: (args) => { + const url = stringArg(args, 'url'); + if (!url) return 'Fetched a URL'; + try { + const hostname = new URL(url).hostname; + return `Fetched \`${hostname}\``; + } catch { + return `Fetched \`${truncateSummary(url)}\``; + } + }, + web_search: (args) => { + const query = stringArg(args, 'query'); + return query ? `Searched web: "${truncateSummary(query)}"` : 'Web search'; + }, + sql: (args) => { + const desc = stringArg(args, 'description'); + return desc ? `SQL: ${truncateSummary(desc)}` : 'SQL query'; + }, + task: (args) => { + const desc = stringArg(args, 'description'); + return desc ? `Launched agent: ${truncateSummary(desc)}` : 'Launched agent'; + }, + ask_user: (args) => { + const q = stringArg(args, 'question'); + return q ? `Asked: "${truncateSummary(q)}"` : 'Asked a question'; + }, + skill: (args) => { + const name = stringArg(args, 'skill'); + return name ? `Used skill \`${name}\`` : 'Used a skill'; + }, + store_memory: () => 'Stored a memory', + report_intent: (args) => { + const intent = stringArg(args, 'intent'); + return intent ? intent : 'Updated intent'; + }, +}; + +/** + * Produces a verb-based, context-rich label for a tool call. + * E.g. "Viewed `ChatPane.tsx:148-250`", "Searched for `toolCall`". + * Falls back to "Used " when no specific formatter exists. + */ +export function formatToolCallPrimaryLabel( + toolName: string | undefined, + toolArguments: Record | undefined, +): string { + if (!toolName) return 'Tool call'; + + const args = toolArguments ?? {}; + + // GitHub MCP tools + if (toolName.startsWith('github-')) { + const detail = summarizeGitHub(toolName, args); + const shortName = toolName.replace(/^github-mcp-server-/, '').replace(/_/g, ' '); + return detail ? `GitHub: ${shortName} — ${detail}` : `GitHub: ${shortName}`; + } + + const extractor = verbLabels[toolName]; + if (extractor) { + return extractor(args); + } + + // Unknown tool — try to extract something useful + const detail = fallbackSummary(args); + return detail ? `Used ${toolName}: ${detail}` : `Used ${toolName}`; +} + +/** + * Produces a compact group label for N consecutive calls of the same tool. + * E.g. "Viewed 4 files", "Ran 3 commands", "Searched 5 patterns". + */ +export function formatToolGroupLabel(toolName: string, count: number): string { + const n = count; + switch (toolName) { + case 'view': return `Viewed ${n} ${n === 1 ? 'file' : 'files'}`; + case 'edit': return `Edited ${n} ${n === 1 ? 'file' : 'files'}`; + case 'create': return `Created ${n} ${n === 1 ? 'file' : 'files'}`; + case 'grep': return `Searched ${n} ${n === 1 ? 'pattern' : 'patterns'}`; + case 'glob': return `Glob ${n} ${n === 1 ? 'pattern' : 'patterns'}`; + case 'lsp': return `${n} LSP ${n === 1 ? 'operation' : 'operations'}`; + case 'powershell': return `Ran ${n} ${n === 1 ? 'command' : 'commands'}`; + case 'web_fetch': return `Fetched ${n} ${n === 1 ? 'URL' : 'URLs'}`; + case 'sql': return `${n} SQL ${n === 1 ? 'query' : 'queries'}`; + case 'task': return `Launched ${n} ${n === 1 ? 'agent' : 'agents'}`; + default: return `${n} ${toolName} ${n === 1 ? 'call' : 'calls'}`; + } +} + +/** + * Extracts a short contextual snippet for a tool call to show inside + * a grouped row's detail list (e.g. the file path for view, the pattern for grep). + */ +export function extractToolCallSnippet( + toolName: string, + toolArguments: Record | undefined, +): string | undefined { + if (!toolArguments) return undefined; + switch (toolName) { + case 'view': + case 'edit': + case 'create': + return pathWithRange(toolArguments) ?? stringArg(toolArguments, 'path'); + case 'grep': + case 'glob': + return stringArg(toolArguments, 'pattern'); + case 'powershell': + return stringArg(toolArguments, 'command') ? truncateSummary(stringArg(toolArguments, 'command')!) : undefined; + case 'lsp': { + const op = stringArg(toolArguments, 'operation'); + const file = stringArg(toolArguments, 'file') ? shortenPath(stringArg(toolArguments, 'file')!) : undefined; + if (op && file) return `${op} ${file}`; + return op ?? undefined; + } + case 'web_fetch': + return stringArg(toolArguments, 'url'); + case 'sql': + return stringArg(toolArguments, 'description'); + case 'task': + return stringArg(toolArguments, 'description'); + default: + return undefined; + } +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 3b612a8..136808a 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -663,6 +663,17 @@ body { animation: turn-activity-row-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; } +@keyframes intent-divider-in { + from { + opacity: 0; + transform: scaleX(0.7); + } +} + +.turn-activity-row[role="separator"] { + animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; +} + /* ── Respect reduced motion ──────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { @@ -677,7 +688,8 @@ body { .banner-slide-enter, .update-banner-enter, .turn-activity-enter, - .turn-activity-row { + .turn-activity-row, + .turn-activity-row[role="separator"] { animation: none; } } diff --git a/tests/renderer/activityGrouping.test.ts b/tests/renderer/activityGrouping.test.ts new file mode 100644 index 0000000..9b75d8d --- /dev/null +++ b/tests/renderer/activityGrouping.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildActivityStream, + groupActivityStream, + extractLatestIntent, + generateActivitySummary, + type ActivityStreamItem, +} from '@renderer/lib/activityGrouping'; +import type { RunTimelineEventRecord } from '@shared/domain/runTimeline'; +import type { ChatMessageRecord } from '@shared/domain/session'; + +function createEvent(overrides?: Partial): RunTimelineEventRecord { + return { + id: `evt-${Math.random().toString(36).slice(2, 8)}`, + kind: 'tool-call', + occurredAt: '2026-04-01T12:00:00.000Z', + status: 'completed', + ...overrides, + }; +} + +function createThinkingMessage(content: string, overrides?: Partial): ChatMessageRecord { + return { + id: `msg-${Math.random().toString(36).slice(2, 8)}`, + role: 'assistant', + content, + createdAt: '2026-04-01T12:00:00.000Z', + messageKind: 'thinking', + ...overrides, + } as ChatMessageRecord; +} + +/* ── buildActivityStream ───────────────────────────────────── */ + +describe('buildActivityStream', () => { + test('merges thinking messages and events into chronological order', () => { + const msgs = [createThinkingMessage('think1', { createdAt: '2026-04-01T12:00:02.000Z' })]; + const events = [ + createEvent({ id: 'e1', occurredAt: '2026-04-01T12:00:01.000Z' }), + createEvent({ id: 'e2', occurredAt: '2026-04-01T12:00:03.000Z' }), + ]; + const stream = buildActivityStream(msgs, events); + expect(stream).toHaveLength(3); + expect(stream[0].kind).toBe('timeline-event'); + expect(stream[1].kind).toBe('thinking-step'); + expect(stream[2].kind).toBe('timeline-event'); + }); + + test('skips run-started and thinking event kinds', () => { + const events = [ + createEvent({ id: 'e1', kind: 'run-started' }), + createEvent({ id: 'e2', kind: 'thinking' }), + createEvent({ id: 'e3', kind: 'tool-call', toolName: 'view' }), + ]; + const stream = buildActivityStream([], events); + expect(stream).toHaveLength(1); + expect((stream[0] as { kind: 'timeline-event'; event: RunTimelineEventRecord }).event.id).toBe('e3'); + }); +}); + +/* ── groupActivityStream ───────────────────────────────────── */ + +describe('groupActivityStream', () => { + test('groups consecutive same-tool calls', () => { + const items: ActivityStreamItem[] = [ + { kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'view' }) }, + { kind: 'timeline-event', event: createEvent({ id: 'e2', toolName: 'view' }) }, + { kind: 'timeline-event', event: createEvent({ id: 'e3', toolName: 'view' }) }, + ]; + const grouped = groupActivityStream(items); + expect(grouped).toHaveLength(1); + expect(grouped[0].kind).toBe('tool-group'); + if (grouped[0].kind === 'tool-group') { + expect(grouped[0].toolName).toBe('view'); + expect(grouped[0].events).toHaveLength(3); + } + }); + + test('does not group non-consecutive same-tool calls', () => { + const items: ActivityStreamItem[] = [ + { kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'view' }) }, + { kind: 'timeline-event', event: createEvent({ id: 'e2', toolName: 'grep' }) }, + { kind: 'timeline-event', event: createEvent({ id: 'e3', toolName: 'view' }) }, + ]; + const grouped = groupActivityStream(items); + expect(grouped).toHaveLength(3); + expect(grouped[0].kind).toBe('single-event'); + expect(grouped[1].kind).toBe('single-event'); + expect(grouped[2].kind).toBe('single-event'); + }); + + test('converts report_intent to intent-divider', () => { + const items: ActivityStreamItem[] = [ + { + kind: 'timeline-event', + event: createEvent({ + id: 'e1', + toolName: 'report_intent', + toolArguments: { intent: 'Exploring codebase' }, + }), + }, + ]; + const grouped = groupActivityStream(items); + expect(grouped).toHaveLength(1); + expect(grouped[0].kind).toBe('intent-divider'); + if (grouped[0].kind === 'intent-divider') { + expect(grouped[0].intentText).toBe('Exploring codebase'); + } + }); + + test('skips report_intent with empty intent text', () => { + const items: ActivityStreamItem[] = [ + { + kind: 'timeline-event', + event: createEvent({ + id: 'e1', + toolName: 'report_intent', + toolArguments: { intent: '' }, + }), + }, + ]; + const grouped = groupActivityStream(items); + expect(grouped).toHaveLength(0); + }); + + test('groups consecutive thinking steps', () => { + const items: ActivityStreamItem[] = [ + { kind: 'thinking-step', message: createThinkingMessage('thought 1') }, + { kind: 'thinking-step', message: createThinkingMessage('thought 2') }, + { kind: 'thinking-step', message: createThinkingMessage('thought 3') }, + ]; + const grouped = groupActivityStream(items); + expect(grouped).toHaveLength(1); + expect(grouped[0].kind).toBe('thinking-group'); + if (grouped[0].kind === 'thinking-group') { + expect(grouped[0].messages).toHaveLength(3); + } + }); + + test('keeps single thinking step as single-thinking', () => { + const items: ActivityStreamItem[] = [ + { kind: 'thinking-step', message: createThinkingMessage('solo thought') }, + ]; + const grouped = groupActivityStream(items); + expect(grouped).toHaveLength(1); + expect(grouped[0].kind).toBe('single-thinking'); + }); + + test('produces a mixed timeline with all item kinds', () => { + const items: ActivityStreamItem[] = [ + { + kind: 'timeline-event', + event: createEvent({ id: 'intent', toolName: 'report_intent', toolArguments: { intent: 'Phase 1' } }), + }, + { kind: 'timeline-event', event: createEvent({ id: 'v1', toolName: 'view' }) }, + { kind: 'timeline-event', event: createEvent({ id: 'v2', toolName: 'view' }) }, + { kind: 'thinking-step', message: createThinkingMessage('thinking...') }, + { kind: 'timeline-event', event: createEvent({ id: 'g1', toolName: 'grep' }) }, + { + kind: 'timeline-event', + event: createEvent({ id: 'intent2', toolName: 'report_intent', toolArguments: { intent: 'Phase 2' } }), + }, + { kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'edit' }) }, + ]; + const grouped = groupActivityStream(items); + expect(grouped.map((g) => g.kind)).toEqual([ + 'intent-divider', + 'tool-group', + 'single-thinking', + 'single-event', + 'intent-divider', + 'single-event', + ]); + }); +}); + +/* ── extractLatestIntent ───────────────────────────────────── */ + +describe('extractLatestIntent', () => { + test('returns the intent from the last report_intent event', () => { + const events = [ + createEvent({ id: 'e1', toolName: 'report_intent', toolArguments: { intent: 'Phase 1' } }), + createEvent({ id: 'e2', toolName: 'view' }), + createEvent({ id: 'e3', toolName: 'report_intent', toolArguments: { intent: 'Phase 2' } }), + ]; + expect(extractLatestIntent(events)).toBe('Phase 2'); + }); + + test('returns undefined when no report_intent events exist', () => { + const events = [ + createEvent({ id: 'e1', toolName: 'view' }), + createEvent({ id: 'e2', toolName: 'grep' }), + ]; + expect(extractLatestIntent(events)).toBeUndefined(); + }); + + test('skips empty intent values', () => { + const events = [ + createEvent({ id: 'e1', toolName: 'report_intent', toolArguments: { intent: 'Valid' } }), + createEvent({ id: 'e2', toolName: 'report_intent', toolArguments: { intent: ' ' } }), + ]; + expect(extractLatestIntent(events)).toBe('Valid'); + }); +}); + +/* ── generateActivitySummary ───────────────────────────────── */ + +describe('generateActivitySummary', () => { + test('generates summary from tool mix', () => { + const events = [ + createEvent({ toolName: 'grep' }), + createEvent({ toolName: 'grep' }), + createEvent({ toolName: 'view' }), + createEvent({ toolName: 'view' }), + createEvent({ toolName: 'view' }), + createEvent({ toolName: 'edit' }), + ]; + const result = generateActivitySummary(events); + expect(result).toBe('Searched 2 patterns, viewed 3 files, edited 1 file'); + }); + + test('excludes report_intent from counts', () => { + const events = [ + createEvent({ toolName: 'report_intent' }), + createEvent({ toolName: 'view' }), + ]; + const result = generateActivitySummary(events); + expect(result).toBe('Viewed 1 file'); + }); + + test('returns undefined for empty events', () => { + expect(generateActivitySummary([])).toBeUndefined(); + }); + + test('falls back to total count for unknown tools', () => { + const events = [ + createEvent({ toolName: 'custom_tool' }), + createEvent({ toolName: 'custom_tool' }), + ]; + const result = generateActivitySummary(events); + expect(result).toBe('2 actions'); + }); +}); diff --git a/tests/renderer/runTimelineFormatting.test.ts b/tests/renderer/runTimelineFormatting.test.ts index f6e19e3..f814549 100644 --- a/tests/renderer/runTimelineFormatting.test.ts +++ b/tests/renderer/runTimelineFormatting.test.ts @@ -67,7 +67,7 @@ describe('run timeline formatting', () => { kind: 'tool-call', agentName: 'Writer', toolName: 'file_search', - }))).toBe('Writer used file_search'); + }))).toBe('Used file_search'); expect(formatEventLabel(createEvent({ kind: 'approval', status: 'running', diff --git a/tests/renderer/toolCallSummary.test.ts b/tests/renderer/toolCallSummary.test.ts index 704da6c..2fa9491 100644 --- a/tests/renderer/toolCallSummary.test.ts +++ b/tests/renderer/toolCallSummary.test.ts @@ -4,6 +4,9 @@ import { formatToolCallSummary, formatToolArgumentValue, getDisplayableArguments, + formatToolCallPrimaryLabel, + formatToolGroupLabel, + extractToolCallSnippet, } from '@renderer/lib/toolCallSummary'; describe('formatToolCallSummary', () => { @@ -171,3 +174,106 @@ describe('getDisplayableArguments', () => { expect(result).toEqual([['query', 'INSERT ...']]); }); }); + +/* ── formatToolCallPrimaryLabel ────────────────────────────── */ + +describe('formatToolCallPrimaryLabel', () => { + test('produces verb-based label for view with path', () => { + expect(formatToolCallPrimaryLabel('view', { path: '/src/components/ChatPane.tsx' })) + .toBe('Viewed `ChatPane.tsx`'); + }); + + test('includes view range', () => { + expect(formatToolCallPrimaryLabel('view', { path: '/src/index.ts', view_range: [10, 25] })) + .toBe('Viewed `index.ts:10-25`'); + }); + + test('produces verb-based label for edit', () => { + expect(formatToolCallPrimaryLabel('edit', { path: '/src/utils.ts', old_str: 'foo' })) + .toBe('Edited `utils.ts`'); + }); + + test('produces verb-based label for create', () => { + expect(formatToolCallPrimaryLabel('create', { path: '/new-file.ts' })) + .toBe('Created `new-file.ts`'); + }); + + test('produces verb-based label for grep', () => { + expect(formatToolCallPrimaryLabel('grep', { pattern: 'TODO', path: '/src' })) + .toBe('Searched for `TODO`'); + }); + + test('produces verb-based label for powershell', () => { + expect(formatToolCallPrimaryLabel('powershell', { command: 'npm run build' })) + .toBe('Ran `npm run build`'); + }); + + test('produces verb-based label for task', () => { + expect(formatToolCallPrimaryLabel('task', { description: 'Explore codebase' })) + .toBe('Launched agent: Explore codebase'); + }); + + test('produces verb-based label for web_fetch with hostname', () => { + expect(formatToolCallPrimaryLabel('web_fetch', { url: 'https://example.com/page' })) + .toBe('Fetched `example.com`'); + }); + + test('produces verb-based label for sql', () => { + expect(formatToolCallPrimaryLabel('sql', { description: 'Insert todos', query: 'INSERT ...' })) + .toBe('SQL: Insert todos'); + }); + + test('handles GitHub tools', () => { + const result = formatToolCallPrimaryLabel('github-mcp-server-search_code', { query: 'auth' }); + expect(result).toContain('search code'); + expect(result).toContain('auth'); + }); + + test('falls back to "Used " for unknown tools', () => { + expect(formatToolCallPrimaryLabel('custom_tool', { data: 'test' })) + .toBe('Used custom_tool: test'); + }); + + test('returns "Tool call" for undefined toolName', () => { + expect(formatToolCallPrimaryLabel(undefined, {})).toBe('Tool call'); + }); +}); + +/* ── formatToolGroupLabel ─────────────────────────────────── */ + +describe('formatToolGroupLabel', () => { + test('pluralizes correctly for view', () => { + expect(formatToolGroupLabel('view', 1)).toBe('Viewed 1 file'); + expect(formatToolGroupLabel('view', 4)).toBe('Viewed 4 files'); + }); + + test('pluralizes correctly for grep', () => { + expect(formatToolGroupLabel('grep', 1)).toBe('Searched 1 pattern'); + expect(formatToolGroupLabel('grep', 3)).toBe('Searched 3 patterns'); + }); + + test('uses generic format for unknown tools', () => { + expect(formatToolGroupLabel('custom', 2)).toBe('2 custom calls'); + }); +}); + +/* ── extractToolCallSnippet ───────────────────────────────── */ + +describe('extractToolCallSnippet', () => { + test('extracts file name for view', () => { + expect(extractToolCallSnippet('view', { path: '/src/components/ChatPane.tsx' })) + .toBe('ChatPane.tsx'); + }); + + test('extracts pattern for grep', () => { + expect(extractToolCallSnippet('grep', { pattern: 'TODO' })).toBe('TODO'); + }); + + test('extracts command for powershell', () => { + expect(extractToolCallSnippet('powershell', { command: 'npm test' })).toBe('npm test'); + }); + + test('returns undefined for unknown tool', () => { + expect(extractToolCallSnippet('custom', { foo: 'bar' })).toBeUndefined(); + }); +});