mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
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:
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user