diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1073120..a6acc5f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -177,7 +177,7 @@ Each user turn becomes a **run**. A run is more than the final assistant output; - success or failure - optional git baselines and post-run change summaries for project-backed execution -That run model is what enables the activity panel and historical timeline instead of forcing the user to infer execution from message text alone. +That run model is what enables the inline turn activity panel instead of forcing the user to infer execution from message text alone. ## Communication model @@ -309,7 +309,7 @@ This lets the application treat tooling as reusable workspace capability while s Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful. -For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the run timeline after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly. +For git-backed projects, the renderer surfaces three specialized components. `RunChangeSummaryCard` appears inline in the turn activity panel after each completed run, showing the files changed during that run with per-file diff previews, origin attribution (run-created vs. pre-existing), and selective discard actions. `CommitComposer` is a slide-over panel for staging files, editing an AI-suggested commit message, selecting a conventional commit type, and committing (with optional push). `GitPanel` is embedded in the tabbed bottom panel (alongside the terminal) and provides branch management, push/pull/fetch network operations, working-tree change inspection, and recent commit history. The bottom panel uses a shared resize handle and tab bar so the terminal and git views coexist without competing for screen real estate. All git write operations flow through IPC to the main process; the renderer never runs git commands directly. ### Execution observability @@ -318,7 +318,7 @@ The architecture treats execution as observable by design: - partial output is streamed - agent activity is surfaced - turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed -- runs are persisted as timeline history +- runs are surfaced inline as collapsible turn activity panels - failures are represented explicitly This improves trust and debuggability, especially for multi-agent workflows. diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6161ad8..334f116 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -737,14 +737,13 @@ export default function App() { gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'} gitDirty={gitDirty} toolingSettings={chatToolingSettings ?? workspace.settings.tooling} + onDiscardRunChanges={handleDiscardRunChanges} + onOpenCommitComposer={handleOpenCommitComposer} /> ); detailPanel = ( void; - onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; - onOpenCommitComposer?: () => void; workflow: WorkflowDefinition; session: SessionRecord; sessionRequestUsage?: SessionRequestUsageState; @@ -219,9 +214,6 @@ interface ActivityPanelProps { export function ActivityPanel({ activity, - onJumpToMessage, - onDiscard, - onOpenCommitComposer, workflow, session, sessionRequestUsage, @@ -346,27 +338,6 @@ export function ActivityPanel({ )} - {/* ── Run timeline section ─────────────────────────── */} -
- - - Timeline - {session.runs.length > 0 && ( - - {session.runs.length} - - )} - - - -
- {/* ── Turn events section ─────────────────────────── */} {turnEvents && turnEvents.length > 0 && (
diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index e4e49e0..39f144e 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -12,7 +12,7 @@ import { UserInputBanner } from '@renderer/components/chat/UserInputBanner'; import { InlineApprovalPill, InlineGitPill, InlineModelPill, InlineTerminalPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills'; import { InlinePromptPill, type ArmedPrompt } from '@renderer/components/chat/InlinePromptPill'; import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; -import { ThinkingProcess } from '@renderer/components/chat/ThinkingProcess'; +import { TurnActivityPanel } from '@renderer/components/chat/TurnActivityPanel'; import { SubagentActivityList } from '@renderer/components/chat/SubagentActivityCard'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { ApprovalDecision } from '@shared/domain/approval'; @@ -29,8 +29,9 @@ import { type ModelDefinition, } from '@shared/domain/models'; import { resolveWorkflowAgentNodes, type AgentNodeConfig, type ReasoningEffort, type WorkflowDefinition } from '@shared/domain/workflow'; -import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'; +import { isScratchpadProject, type ProjectGitFileReference, type ProjectRecord } from '@shared/domain/project'; import { resolveSessionToolingSelection, type ChatMessageRecord, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session'; +import type { SessionRunRecord } from '@shared/domain/runTimeline'; import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; import { countApprovedToolsInGroups, @@ -45,7 +46,7 @@ import { type DisplayItem = | { type: 'message'; message: ChatMessageRecord } - | { type: 'thinking-group'; messages: ChatMessageRecord[]; turnStartedAt?: string }; + | { type: 'turn-activity'; thinkingMessages: ChatMessageRecord[]; run?: SessionRunRecord; turnStartedAt?: string }; interface ChatPaneProps { project: ProjectRecord; @@ -82,6 +83,8 @@ interface ChatPaneProps { onPinMessage?: (messageId: string, isPinned: boolean) => void; onRegenerateMessage?: (messageId: string) => void; onEditAndResendMessage?: (messageId: string, content: string) => void; + onDiscardRunChanges?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; + onOpenCommitComposer?: () => void; branchOriginLabel?: string; } @@ -117,6 +120,8 @@ export function ChatPane({ onPinMessage, onRegenerateMessage, onEditAndResendMessage, + onDiscardRunChanges, + onOpenCommitComposer, branchOriginLabel, }: ChatPaneProps) { const [hasComposerContent, setHasComposerContent] = useState(false); @@ -168,7 +173,7 @@ export function ChatPane({ if (pendingThinking.length > 0) { const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; - items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt }); + items.push({ type: 'turn-activity', thinkingMessages: pendingThinking, run, turnStartedAt: run?.startedAt }); pendingThinking = []; } items.push({ type: 'message', message }); @@ -179,30 +184,31 @@ export function ChatPane({ if (pendingThinking.length > 0) { const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined; - items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt }); + items.push({ type: 'turn-activity', thinkingMessages: pendingThinking, run, turnStartedAt: run?.startedAt }); } return items; }, [session.messages, session.runs, session.status]); - const lastThinkingGroupIndex = useMemo(() => { + const lastTurnActivityIndex = useMemo(() => { for (let i = displayItems.length - 1; i >= 0; i--) { - if (displayItems[i].type === 'thinking-group') return i; + if (displayItems[i].type === 'turn-activity') return i; } return -1; }, [displayItems]); - // A thinking group is active when the session is running AND the group - // contains at least one pending message (currently streaming). This is - // more robust than relying purely on array position, which can be stale - // during reclassification gaps. - const isThinkingGroupActive = useCallback( - (group: { messages: ChatMessageRecord[] }, groupIndex: number): boolean => { + // A turn activity panel is active when the session is running AND the + // group contains at least one pending message (currently streaming) or + // the associated run is still running. + const isTurnActive = useCallback( + (item: DisplayItem & { type: 'turn-activity' }, itemIndex: number): boolean => { if (!isSessionBusy) return false; - if (groupIndex !== lastThinkingGroupIndex) return false; - return group.messages.some((m) => m.pending); + if (itemIndex !== lastTurnActivityIndex) return false; + const hasStreamingMessage = item.thinkingMessages.some((m) => m.pending); + const runStillRunning = item.run?.status === 'running'; + return hasStreamingMessage || !!runStillRunning; }, - [isSessionBusy, lastThinkingGroupIndex], + [isSessionBusy, lastTurnActivityIndex], ); const lastAssistantId = useMemo(() => { @@ -518,13 +524,17 @@ export function ChatPane({ )}
{displayItems.map((item, itemIndex) => { - if (item.type === 'thinking-group') { + if (item.type === 'turn-activity') { return ( -
- +
); diff --git a/src/renderer/components/RunTimeline.tsx b/src/renderer/components/RunTimeline.tsx deleted file mode 100644 index 5beafe7..0000000 --- a/src/renderer/components/RunTimeline.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; -import { - AlertTriangle, - ArrowRight, - Bot, - Brain, - CheckCircle2, - ChevronDown, - ChevronRight, - CircleDot, - GitBranch, - MessageSquare, - Play, - Wrench, - XCircle, -} from 'lucide-react'; - -import { - collapseTimelineEvents, - formatEventLabel, - formatRunDuration, - formatRunStatusLabel, - formatRunTimestamp, - isTerminalEvent, - truncateContent, - type CollapsedTimelineEvent, -} from '@renderer/lib/runTimelineFormatting'; -import type { WorkflowOrchestrationMode } from '@shared/domain/workflow'; -import type { ProjectGitFileReference, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project'; -import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline'; -import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; -import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard'; - -/* ── Mode accent colours (shared with ActivityPanel) ───────── */ - -const modeAccent: Record = { - single: { dot: 'bg-[#245CF9]', ring: 'ring-[#245CF9]/30', text: 'text-[#245CF9]' }, - sequential: { dot: 'bg-[var(--color-status-warning)]', ring: 'ring-[var(--color-status-warning)]/30', text: 'text-[var(--color-status-warning)]' }, - concurrent: { dot: 'bg-[var(--color-status-success)]', ring: 'ring-[var(--color-status-success)]/30', text: 'text-[var(--color-status-success)]' }, - handoff: { dot: 'bg-[var(--color-accent-sky)]', ring: 'ring-[var(--color-accent-sky)]/30', text: 'text-[var(--color-accent-sky)]' }, - 'group-chat': { dot: 'bg-[var(--color-accent-purple)]', ring: 'ring-[var(--color-accent-purple)]/30', text: 'text-[var(--color-accent-purple)]' }, -}; - -/* ── Status badges ─────────────────────────────────────────── */ - -const runStatusStyles: Record = { - running: { icon: , className: 'text-[var(--color-status-info)]' }, - completed: { icon: , className: 'text-[var(--color-status-success)]' }, - cancelled: { icon: , className: 'text-[var(--color-text-muted)]' }, - error: { icon: , className: 'text-[var(--color-status-error)]' }, -}; - -/* ── Event node icon ───────────────────────────────────────── */ - -function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) { - const isRunning = status === 'running'; - const base = 'size-2.5'; - - // Running events use white icons to contrast with the brand-gradient circle - if (isRunning) { - const pulse = 'animate-pulse'; - switch (kind) { - case 'thinking': - return ; - case 'approval': - return ; - case 'message': - return ; - default: - return ; - } - } - - switch (kind) { - case 'run-started': - return ; - case 'thinking': - return ; - case 'handoff': - return ; - case 'tool-call': - return ; - case 'approval': - return ; - case 'message': - return ; - case 'run-completed': - return ; - case 'run-cancelled': - return ; - case 'run-failed': - return ; - } -} - -/* ── Single timeline event row ─────────────────────────────── */ - -function TimelineEventRow({ - event, - isLast, - onJumpToMessage, -}: { - event: RunTimelineEventRecord; - isLast: boolean; - onJumpToMessage?: (messageId: string) => void; -}) { - const label = formatEventLabel(event); - const timestamp = formatRunTimestamp(event.updatedAt ?? event.occurredAt); - const preview = event.kind === 'message' ? truncateContent(event.content) : undefined; - const isClickable = !!onJumpToMessage && !!event.messageId; - const terminal = isTerminalEvent(event.kind); - - return ( -
- {/* Vertical connector line */} - {!isLast && ( -
- )} - - - - {/* File change preview for tool-call events */} - {event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && ( -
- -
- )} -
- ); -} - -/* ── Collapsed thinking group ──────────────────────────────── */ - -function ThinkingGroupRow({ - events, - agentName, - isLast, -}: { - events: RunTimelineEventRecord[]; - agentName: string; - isLast: boolean; -}) { - return ( -
- {!isLast && ( -
- )} -
-
- -
-
-
- - {agentName ? `${agentName} thinking` : 'Thinking'} ×{events.length} - -
-
- ); -} - -/* ── Collapsed event dispatcher ────────────────────────────── */ - -function CollapsedEventRow({ - item, - isLast, - onJumpToMessage, -}: { - item: CollapsedTimelineEvent; - isLast: boolean; - onJumpToMessage?: (messageId: string) => void; -}) { - if (item.type === 'thinking-group') { - return ; - } - return ; -} - -/* ── Git baseline snapshot ──────────────────────────────────── */ - -function RunGitBaseline({ snapshot }: { snapshot: ProjectGitWorkingTreeSnapshot }) { - const { branch, changes, changedFileCount } = snapshot; - - const parts: string[] = []; - if (changes.staged > 0) parts.push(`${changes.staged} staged`); - if (changes.unstaged > 0) parts.push(`${changes.unstaged} modified`); - if (changes.untracked > 0) parts.push(`${changes.untracked} untracked`); - if (changes.conflicted > 0) parts.push(`${changes.conflicted} conflicted`); - - return ( -
- - {branch && {branch}} - {changedFileCount > 0 ? ( - <> - · - {changedFileCount} changed - {parts.length > 0 && ( - ({parts.join(', ')}) - )} - - ) : ( - <> - · - clean - - )} -
- ); -} - -/* ── Run card ──────────────────────────────────────────────── */ - -function RunCard({ - run, - expanded, - onToggle, - onJumpToMessage, - sessionId, - onDiscard, - onOpenCommitComposer, -}: { - run: SessionRunRecord; - expanded: boolean; - onToggle: () => void; - onJumpToMessage?: (messageId: string) => void; - sessionId: string; - onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; - onOpenCommitComposer?: () => void; -}) { - const accent = modeAccent[run.workflowMode] ?? modeAccent.single; - const statusStyle = runStatusStyles[run.status]; - const duration = formatRunDuration(run.startedAt, run.completedAt); - - const collapsedEvents = useMemo( - () => collapseTimelineEvents(run.events), - [run.events], - ); - - return ( -
- {/* Run header */} - - - {/* Expanded timeline */} - {expanded && ( -
- {/* Agent badges */} - {run.agents.length > 1 && ( -
- {run.agents.map((agent) => ( - - {agent.agentName} - - ))} -
- )} - - {/* Git baseline */} - {run.preRunGitSnapshot && ( - - )} - - {/* Timeline events */} -
- {collapsedEvents.map((item, index) => ( - - ))} -
- - {/* Duration footer */} - {duration && ( -
- Duration: {duration} -
- )} - - {/* Post-run git changes */} - {run.postRunGitSummary && run.status !== 'running' && onDiscard && ( -
- -
- )} -
- )} -
- ); -} - -/* ── Empty state ───────────────────────────────────────────── */ - -function EmptyTimeline() { - return ( -

- Send a message to see the run timeline -

- ); -} - -/* ── Main export ───────────────────────────────────────────── */ - -interface RunTimelineProps { - runs: readonly SessionRunRecord[]; - sessionId: string; - onJumpToMessage?: (messageId: string) => void; - onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise; - onOpenCommitComposer?: () => void; -} - -export function RunTimeline({ runs, sessionId, onJumpToMessage, onDiscard, onOpenCommitComposer }: RunTimelineProps) { - const latestRunId = runs.length > 0 ? runs[0].id : undefined; - const [expandedRunId, setExpandedRunId] = useState(latestRunId); - - // Auto-expand the latest run when it changes - useEffect(() => { - setExpandedRunId(latestRunId); - }, [latestRunId]); - - if (runs.length === 0) { - return ; - } - - return ( -
- {runs.map((run) => ( - setExpandedRunId(expandedRunId === run.id ? undefined : run.id)} - run={run} - sessionId={sessionId} - /> - ))} -
- ); -} diff --git a/src/renderer/components/chat/ThinkingProcess.tsx b/src/renderer/components/chat/ThinkingProcess.tsx deleted file mode 100644 index 2d724f2..0000000 --- a/src/renderer/components/chat/ThinkingProcess.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Brain, ChevronDown, ChevronRight } from 'lucide-react'; - -import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer'; -import type { ChatMessageRecord } from '@shared/domain/session'; - -interface ThinkingProcessProps { - messages: ChatMessageRecord[]; - isActive: boolean; - turnStartedAt?: string; -} - -export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingProcessProps) { - const [expanded, setExpanded] = useState(false); - const wasActiveRef = useRef(isActive); - - // Auto-expand when the turn is active and thinking messages appear. - // Auto-collapse once the turn finishes. - useEffect(() => { - if (isActive && messages.length > 0) { - setExpanded(true); - } else if (wasActiveRef.current && !isActive) { - setExpanded(false); - } - wasActiveRef.current = isActive; - }, [isActive, messages.length]); - - const toggle = useCallback(() => setExpanded((prev) => !prev), []); - - const elapsed = useElapsedTimer( - messages.length > 0 ? turnStartedAt : undefined, - isActive, - ); - - if (messages.length === 0) { - return null; - } - - // Don't count empty pending messages (optimistically folded in) toward step count - const stepCount = messages.filter((m) => m.content).length; - const summaryParts: string[] = []; - if (elapsed) summaryParts.push(`${elapsed}`); - if (stepCount > 0) { - summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`); - } - - return ( -
- - {expanded && ( -
-
- {messages.map((message) => ( - - ))} -
-
- )} -
- ); -} - -function ThinkingStep({ message }: { message: ChatMessageRecord }) { - const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]); - - // Pending message folded into thinking group with no content yet - if (message.pending && !message.content) { - return null; - } - - return ( -
- -
- {message.authorName && ( - - {message.authorName} - - )} - {preview} -
-
- ); -} - -function ThinkingPulse() { - return ( - - - - - - ); -} - -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)}…`; -} diff --git a/src/renderer/components/chat/TurnActivityPanel.tsx b/src/renderer/components/chat/TurnActivityPanel.tsx new file mode 100644 index 0000000..f26f3a4 --- /dev/null +++ b/src/renderer/components/chat/TurnActivityPanel.tsx @@ -0,0 +1,414 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + AlertTriangle, + ArrowRight, + Brain, + CheckCircle2, + ChevronDown, + ChevronRight, + MessageSquare, + ShieldAlert, + Wrench, + XCircle, + Zap, +} from 'lucide-react'; + +import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer'; +import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; +import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard'; +import { formatEventLabel, truncateContent } from '@renderer/lib/runTimelineFormatting'; +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 { + thinkingMessages: ChatMessageRecord[]; + run?: SessionRunRecord; + isActive: boolean; + turnStartedAt?: string; + sessionId: string; + 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 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; +} + +interface ActivitySummary { + thinkingSteps: number; + toolCalls: number; + handoffs: number; + approvals: number; + hasError: boolean; +} + +function summarizeActivity( + thinkingMessages: ChatMessageRecord[], + run?: SessionRunRecord, +): ActivitySummary { + const thinkingSteps = thinkingMessages.filter((m) => m.content).length; + let toolCalls = 0; + let handoffs = 0; + let approvals = 0; + let hasError = false; + + if (run) { + for (const e of run.events) { + if (e.kind === 'tool-call') toolCalls++; + else if (e.kind === 'handoff') handoffs++; + else if (e.kind === 'approval') approvals++; + else if (e.kind === 'run-failed') hasError = true; + } + } + + return { thinkingSteps, toolCalls, handoffs, approvals, hasError }; +} + +function formatSummaryParts(summary: ActivitySummary): string[] { + const parts: string[] = []; + if (summary.toolCalls > 0) { + parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`); + } + 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'}`); + } + if (summary.thinkingSteps > 0) { + parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`); + } + return parts; +} + +/* ── Event icon ────────────────────────────────────────────── */ + +function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) { + 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 ; + } +} + +/* ── Activity 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)} +

+ )} + + {/* File change preview for tool-call events */} + {event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && ( +
+ +
+ )} +
+
+ ); +} + +/* ── 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 ( +
+
+ +
+
+ {message.authorName && ( + + {message.authorName} + + )} + {preview} +
+
+ ); +} + +/* ── Active pulse dots ─────────────────────────────────────── */ + +function ActivityPulse() { + return ( + + + + + + ); +} + +/* ── Main component ────────────────────────────────────────── */ + +export function TurnActivityPanel({ + thinkingMessages, + run, + isActive, + turnStartedAt, + sessionId, + onDiscard, + onOpenCommitComposer, +}: TurnActivityPanelProps) { + const [expanded, setExpanded] = useState(false); + const wasActiveRef = useRef(isActive); + + // Auto-expand when the turn is active and activity appears. + // Auto-collapse once the turn finishes. + useEffect(() => { + const hasActivity = thinkingMessages.length > 0 || (run?.events.length ?? 0) > 0; + if (isActive && hasActivity) { + setExpanded(true); + } else if (wasActiveRef.current && !isActive) { + setExpanded(false); + } + wasActiveRef.current = isActive; + }, [isActive, thinkingMessages.length, run?.events.length]); + + const toggle = useCallback(() => setExpanded((prev) => !prev), []); + + const elapsed = useElapsedTimer( + thinkingMessages.length > 0 || run ? turnStartedAt : undefined, + isActive, + ); + + const summary = useMemo( + () => summarizeActivity(thinkingMessages, run), + [thinkingMessages, run], + ); + + const activityStream = useMemo( + () => buildActivityStream(thinkingMessages, run?.events ?? []), + [thinkingMessages, run?.events], + ); + + // Nothing to show yet + if (thinkingMessages.length === 0 && (!run || run.events.length === 0)) { + 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; + + // Build the summary label + 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 statusColorClass = isFailed + ? 'text-[var(--color-status-error)]' + : isCancelled + ? 'text-[var(--color-text-muted)]' + : isActive + ? 'text-[var(--color-text-secondary)]' + : 'text-[var(--color-text-secondary)]'; + + return ( +
+ {/* Summary header */} + + + {/* Expanded activity stream */} + {expanded && ( +
+
+ {activityStream.map((item) => { + if (item.kind === 'thinking-step') { + return ; + } + return ; + })} +
+ + {/* Post-run git changes */} + {showGitSummary && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 57297c5..3b612a8 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -639,17 +639,28 @@ body { animation: update-banner-in 0.3s cubic-bezier(0.16, 1, 0.3, 1); } -/* ── Thinking process section ────────────────────────────────── */ +/* ── Turn activity panel ─────────────────────────────────────── */ -@keyframes thinking-process-in { +@keyframes turn-activity-in { from { opacity: 0; transform: translateY(-4px); } } -.thinking-process-enter { - animation: thinking-process-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; +.turn-activity-enter { + animation: turn-activity-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes turn-activity-row-in { + from { + opacity: 0; + transform: translateX(-6px); + } +} + +.turn-activity-row { + animation: turn-activity-row-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; } /* ── Respect reduced motion ──────────────────────────────────── */ @@ -665,7 +676,8 @@ body { .session-item-enter, .banner-slide-enter, .update-banner-enter, - .thinking-process-enter { + .turn-activity-enter, + .turn-activity-row { animation: none; } }