feat: move run timeline from side panel into inline chat turn activity panels

Replace the separate RunTimeline in the ActivityPanel with a generalized
TurnActivityPanel that renders inline in the chat pane between user
messages and assistant responses.

The new component:
- Shows all turn activities (thinking steps, tool calls, approvals,
  handoffs, run status) in a single collapsible panel
- Merges chat thinking messages with run timeline events into a
  chronological activity stream
- Auto-expands when a turn starts, auto-collapses on completion
- Displays a compact summary header (elapsed time, tool call count,
  handoff count, etc.) when collapsed
- Includes post-run git change summary with discard/commit actions
- Uses subtle entrance animations for rows

Removed components:
- ThinkingProcess.tsx (subsumed by TurnActivityPanel)
- RunTimeline.tsx (no longer needed as a standalone component)

The ActivityPanel retains its agents, session usage, and turn events
(diagnostics/hooks) sections.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-07 09:47:19 +02:00
co-authored by Copilot
parent a3e0bd9244
commit 3a71baf104
8 changed files with 468 additions and 615 deletions
+2 -3
View File
@@ -737,14 +737,13 @@ export default function App() {
gitPanelOpen={bottomPanelOpen && bottomPanelTab === 'git'}
gitDirty={gitDirty}
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
onDiscardRunChanges={handleDiscardRunChanges}
onOpenCommitComposer={handleOpenCommitComposer}
/>
);
detailPanel = (
<ActivityPanel
activity={activityForSession}
onDiscard={handleDiscardRunChanges}
onJumpToMessage={jumpToMessage}
onOpenCommitComposer={handleOpenCommitComposer}
workflow={workflowForSession}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
+1 -30
View File
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
@@ -15,10 +15,8 @@ import {
type SessionRequestUsageState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline';
import { inferProvider } from '@shared/domain/models';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
@@ -208,9 +206,6 @@ function formatTurnEventTimestamp(iso: string): string {
interface ActivityPanelProps {
activity?: SessionActivityState;
onJumpToMessage?: (messageId: string) => void;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
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({
</div>
)}
{/* ── Run timeline section ─────────────────────────── */}
<div className="mb-4">
<SectionHeader>
<Clock className="size-3" />
<span>Timeline</span>
{session.runs.length > 0 && (
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{session.runs.length}
</span>
)}
</SectionHeader>
<RunTimeline
onDiscard={onDiscard}
onJumpToMessage={onJumpToMessage}
onOpenCommitComposer={onOpenCommitComposer}
runs={session.runs}
sessionId={session.id}
/>
</div>
{/* ── Turn events section ─────────────────────────── */}
{turnEvents && turnEvents.length > 0 && (
<div className="mb-4">
+31 -21
View File
@@ -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<unknown>;
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({
)}
<div className="space-y-1">
{displayItems.map((item, itemIndex) => {
if (item.type === 'thinking-group') {
if (item.type === 'turn-activity') {
return (
<div key={`thinking-${item.messages[0].id}`} className="py-2">
<ThinkingProcess
messages={item.messages}
isActive={isThinkingGroupActive(item, itemIndex)}
<div key={`activity-${item.thinkingMessages[0]?.id ?? item.run?.id ?? itemIndex}`} className="py-2">
<TurnActivityPanel
thinkingMessages={item.thinkingMessages}
run={item.run}
isActive={isTurnActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt}
sessionId={session.id}
onDiscard={onDiscardRunChanges}
onOpenCommitComposer={onOpenCommitComposer}
/>
</div>
);
-429
View File
@@ -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<WorkflowOrchestrationMode, { dot: string; ring: string; text: string }> = {
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<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
running: { icon: <CircleDot className="size-3" />, className: 'text-[var(--color-status-info)]' },
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-[var(--color-status-success)]' },
cancelled: { icon: <XCircle className="size-3" />, className: 'text-[var(--color-text-muted)]' },
error: { icon: <XCircle className="size-3" />, 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 <Brain className={`${base} ${pulse} text-white`} />;
case 'approval':
return <AlertTriangle className={`${base} ${pulse} text-white`} />;
case 'message':
return <MessageSquare className={`${base} ${pulse} text-white`} />;
default:
return <Play className={`${base} text-white`} />;
}
}
switch (kind) {
case 'run-started':
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
case 'thinking':
return <Brain className={`${base} text-[var(--color-text-muted)]`} />;
case 'handoff':
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
case 'tool-call':
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
case 'approval':
return <AlertTriangle className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
case 'message':
return <MessageSquare className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
case 'run-cancelled':
return <XCircle className={`${base} text-[var(--color-text-muted)]`} />;
case 'run-failed':
return <AlertTriangle className={`${base} text-[var(--color-status-error)]`} />;
}
}
/* ── 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 (
<div className="relative">
{/* Vertical connector line */}
{!isLast && (
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
)}
<button
className={`group flex w-full gap-2.5 text-left transition-all duration-200 ${terminal ? 'py-1' : 'py-1.5'} ${isClickable ? 'cursor-pointer' : 'cursor-default'}`}
disabled={!isClickable}
onClick={isClickable ? () => onJumpToMessage(event.messageId!) : undefined}
type="button"
>
{/* Node */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className={`flex size-[18px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
<EventIcon kind={event.kind} status={event.status} />
</div>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className={`text-[11px] font-medium ${terminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'} ${isClickable ? 'group-hover:text-[var(--color-text-accent)]' : ''}`}>
{label}
</span>
{/* Approval kind badge */}
{event.kind === 'approval' && event.approvalKind && (
<span className={`rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
event.status === 'running'
? 'bg-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
: event.status === 'completed'
? 'bg-[var(--color-status-success)]/15 text-[var(--color-status-success)]'
: 'bg-[var(--color-status-error)]/15 text-[var(--color-status-error)]'
}`}>
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
</span>
)}
<span className="font-mono ml-auto shrink-0 text-[9px] tabular-nums text-[var(--color-text-muted)]">{timestamp}</span>
</div>
{/* Content preview for message events */}
{preview && (
<p className={`mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)] ${isClickable ? 'group-hover:text-[var(--color-text-secondary)]' : ''}`}>
{preview}
</p>
)}
{/* Approval detail */}
{event.kind === 'approval' && event.approvalDetail && (
<p className="mt-0.5 text-[10px] leading-snug text-[var(--color-text-muted)]">
{truncateContent(event.approvalDetail, 120)}
</p>
)}
{/* Error detail */}
{event.error && (
<p className="mt-0.5 text-[10px] leading-snug text-[var(--color-status-error)]/80">
{truncateContent(event.error, 120)}
</p>
)}
</div>
</button>
{/* File change preview for tool-call events */}
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
<div className="relative z-10 ml-[25px] pb-1">
<FileChangePreview fileChanges={event.fileChanges} />
</div>
)}
</div>
);
}
/* ── Collapsed thinking group ──────────────────────────────── */
function ThinkingGroupRow({
events,
agentName,
isLast,
}: {
events: RunTimelineEventRecord[];
agentName: string;
isLast: boolean;
}) {
return (
<div className="group relative flex w-full gap-2.5 py-1">
{!isLast && (
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
)}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div className="flex size-[18px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
<Brain className="size-2.5 text-[var(--color-text-muted)]" />
</div>
</div>
<div className="min-w-0 flex-1">
<span className="text-[11px] text-[var(--color-text-muted)]">
{agentName ? `${agentName} thinking` : 'Thinking'} ×{events.length}
</span>
</div>
</div>
);
}
/* ── Collapsed event dispatcher ────────────────────────────── */
function CollapsedEventRow({
item,
isLast,
onJumpToMessage,
}: {
item: CollapsedTimelineEvent;
isLast: boolean;
onJumpToMessage?: (messageId: string) => void;
}) {
if (item.type === 'thinking-group') {
return <ThinkingGroupRow agentName={item.agentName} events={item.events} isLast={isLast} />;
}
return <TimelineEventRow event={item.event} isLast={isLast} onJumpToMessage={onJumpToMessage} />;
}
/* ── 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 (
<div className="mb-1.5 flex items-center gap-1.5 text-[9px] text-[var(--color-text-muted)]">
<GitBranch className="size-2.5 shrink-0" />
{branch && <span className="font-mono text-[var(--color-text-secondary)]">{branch}</span>}
{changedFileCount > 0 ? (
<>
<span>·</span>
<span className="text-[var(--color-status-warning)]">{changedFileCount} changed</span>
{parts.length > 0 && (
<span className="text-[var(--color-text-muted)]">({parts.join(', ')})</span>
)}
</>
) : (
<>
<span>·</span>
<span className="text-[var(--color-status-success)]">clean</span>
</>
)}
</div>
);
}
/* ── 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<unknown>;
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 (
<div className="glass-surface rounded-lg">
{/* Run header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]"
onClick={onToggle}
type="button"
>
{expanded
? <ChevronDown className="size-3 shrink-0 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 shrink-0 text-[var(--color-text-muted)]" />}
<Bot className={`size-3 shrink-0 ${accent.text}`} />
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-[var(--color-text-secondary)]">
{run.workflowName}
</span>
{/* Status */}
<span className={`flex items-center gap-1 shrink-0 ${statusStyle.className}`}>
{run.status === 'running' && <span className="size-1.5 animate-pulse rounded-full bg-[var(--color-status-info)]" />}
{run.status !== 'running' && statusStyle.icon}
<span className="text-[9px] font-medium">{formatRunStatusLabel(run.status)}</span>
</span>
</button>
{/* Expanded timeline */}
{expanded && (
<div className="border-t border-[var(--color-border-subtle)] px-3 pb-2 pt-1.5">
{/* Agent badges */}
{run.agents.length > 1 && (
<div className="mb-2 flex flex-wrap gap-1">
{run.agents.map((agent) => (
<span
className="rounded-full bg-[var(--color-surface-2)] px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)]"
key={agent.agentId}
>
{agent.agentName}
</span>
))}
</div>
)}
{/* Git baseline */}
{run.preRunGitSnapshot && (
<RunGitBaseline snapshot={run.preRunGitSnapshot} />
)}
{/* Timeline events */}
<div>
{collapsedEvents.map((item, index) => (
<CollapsedEventRow
isLast={index === collapsedEvents.length - 1}
item={item}
key={item.type === 'single' ? item.event.id : `thinking-${item.events[0].id}`}
onJumpToMessage={onJumpToMessage}
/>
))}
</div>
{/* Duration footer */}
{duration && (
<div className="font-mono mt-1 border-t border-[var(--color-border-subtle)] pt-1.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
Duration: {duration}
</div>
)}
{/* Post-run git changes */}
{run.postRunGitSummary && run.status !== 'running' && onDiscard && (
<div className="mt-2">
<RunChangeSummaryCard
onDiscard={onDiscard}
onOpenCommitComposer={onOpenCommitComposer}
runId={run.requestId}
sessionId={sessionId}
summary={run.postRunGitSummary}
/>
</div>
)}
</div>
)}
</div>
);
}
/* ── Empty state ───────────────────────────────────────────── */
function EmptyTimeline() {
return (
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">
Send a message to see the run timeline
</p>
);
}
/* ── Main export ───────────────────────────────────────────── */
interface RunTimelineProps {
runs: readonly SessionRunRecord[];
sessionId: string;
onJumpToMessage?: (messageId: string) => void;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
}
export function RunTimeline({ runs, sessionId, onJumpToMessage, onDiscard, onOpenCommitComposer }: RunTimelineProps) {
const latestRunId = runs.length > 0 ? runs[0].id : undefined;
const [expandedRunId, setExpandedRunId] = useState<string | undefined>(latestRunId);
// Auto-expand the latest run when it changes
useEffect(() => {
setExpandedRunId(latestRunId);
}, [latestRunId]);
if (runs.length === 0) {
return <EmptyTimeline />;
}
return (
<div className="space-y-2">
{runs.map((run) => (
<RunCard
expanded={expandedRunId === run.id}
key={run.id}
onDiscard={onDiscard}
onJumpToMessage={onJumpToMessage}
onOpenCommitComposer={onOpenCommitComposer}
onToggle={() => setExpandedRunId(expandedRunId === run.id ? undefined : run.id)}
run={run}
sessionId={sessionId}
/>
))}
</div>
);
}
@@ -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 (
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
<button
type="button"
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Brain className="size-3.5 shrink-0 text-[var(--color-accent-purple)]" />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className="text-[var(--color-text-secondary)]">Thinking</span>
<ThinkingPulse />
</span>
) : (
<span className="text-[var(--color-text-secondary)]">
Thought for {summaryParts.join(' · ')}
</span>
)}
<span className="ml-auto shrink-0">
{expanded
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
</span>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-1.5">
{messages.map((message) => (
<ThinkingStep key={message.id} message={message} />
))}
</div>
</div>
)}
</div>
);
}
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 (
<div className="flex gap-2 text-[12px] leading-relaxed">
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]"></span>
<div className="min-w-0">
{message.authorName && (
<span className="mr-1.5 font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[var(--color-text-muted)]">{preview}</span>
</div>
</div>
);
}
function ThinkingPulse() {
return (
<span className="inline-flex items-center gap-0.5">
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent-purple)]" />
</span>
);
}
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)}`;
}
@@ -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<unknown>;
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 <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
case 'approval':
return (
<ShieldAlert
className={`${base} ${
status === 'error'
? 'text-[var(--color-status-error)]'
: status === 'running'
? 'text-[var(--color-status-warning)]'
: 'text-[var(--color-status-success)]'
}`}
/>
);
case 'handoff':
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
case 'message':
return <MessageSquare className={`${base} text-[var(--color-accent-sky)]`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
case 'run-cancelled':
return <XCircle className={`${base} text-[var(--color-text-muted)]`} />;
case 'run-failed':
return <AlertTriangle className={`${base} text-[var(--color-status-error)]`} />;
default:
return <Zap className={`${base} text-[var(--color-text-muted)]`} />;
}
}
/* ── 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 (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<ActivityEventIcon kind={event.kind} status={event.status} />
</div>
<div className="min-w-0 flex-1">
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
{label}
</span>
{/* Approval kind badge */}
{event.kind === 'approval' && event.approvalKind && (
<span
className={`ml-1.5 inline-flex rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
event.status === 'running'
? 'bg-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
: event.status === 'completed'
? 'bg-[var(--color-status-success)]/15 text-[var(--color-status-success)]'
: 'bg-[var(--color-status-error)]/15 text-[var(--color-status-error)]'
}`}
>
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
</span>
)}
{/* Content preview for message events */}
{event.kind === 'message' && event.content && (
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
{truncateContent(event.content, 120)}
</p>
)}
{/* Approval detail */}
{event.kind === 'approval' && event.approvalDetail && (
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-text-muted)]">
{truncateContent(event.approvalDetail, 120)}
</p>
)}
{/* Error detail */}
{event.error && (
<p className="mt-0.5 text-[11px] leading-snug text-[var(--color-status-error)]/80">
{truncateContent(event.error, 120)}
</p>
)}
{/* File change preview for tool-call events */}
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
<div className="mt-1">
<FileChangePreview fileChanges={event.fileChanges} />
</div>
)}
</div>
</div>
);
}
/* ── 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 (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
{message.authorName && (
<span className="mr-1.5 text-[12px] font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[12px] text-[var(--color-text-muted)]">{preview}</span>
</div>
</div>
);
}
/* ── Active pulse dots ─────────────────────────────────────── */
function ActivityPulse() {
return (
<span className="inline-flex items-center gap-0.5">
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
<span className="thinking-dot size-1 rounded-full bg-[var(--color-accent)]" />
</span>
);
}
/* ── 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 (
<div
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
isActive
? 'border-[var(--color-accent)]/30'
: isFailed
? 'border-[var(--color-status-error)]/20'
: 'border-[var(--color-border)]/50'
}`}
>
{/* Summary header */}
<button
type="button"
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
}`}
>
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className={statusColorClass}>{summaryLabel}</span>
<ActivityPulse />
</span>
) : (
<span className={statusColorClass}>{summaryLabel}</span>
)}
{/* Inline counters */}
{summaryParts.length > 0 && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
{'· '}
{summaryParts.join(' · ')}
</span>
)}
<span className="ml-auto shrink-0">
{expanded
? <ChevronDown className="size-3 text-[var(--color-text-muted)]" />
: <ChevronRight className="size-3 text-[var(--color-text-muted)]" />}
</span>
</button>
{/* Expanded activity stream */}
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-0.5">
{activityStream.map((item) => {
if (item.kind === 'thinking-step') {
return <ThinkingStepRow key={item.message.id} message={item.message} />;
}
return <ActivityTimelineEventRow key={item.event.id} event={item.event} />;
})}
</div>
{/* Post-run git changes */}
{showGitSummary && (
<div className="mt-2 border-t border-[var(--color-border)]/30 pt-2">
<RunChangeSummaryCard
onDiscard={onDiscard}
onOpenCommitComposer={onOpenCommitComposer}
runId={run.requestId}
sessionId={sessionId}
summary={run.postRunGitSummary!}
/>
</div>
)}
</div>
)}
</div>
);
}
+17 -5
View File
@@ -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;
}
}