feat: show sub-workflow agents and lifecycle in the Activity panel

Deep agent resolution in the sidecar now walks sub-workflow nodes so
nested agents carry subworkflowNodeId and subworkflowName on activity
events. New subworkflow-started / subworkflow-completed activity types
let the frontend track sub-workflow lifecycle.

The Activity panel groups nested agents under collapsible sub-workflow
cards with status badges, accent-colored left borders, and smooth
expand/collapse transitions. Cards auto-expand when a sub-workflow
starts running. Workflows without sub-workflow nodes render identically
to before.

Extracted AgentRow, SubWorkflowGroup, and shared accent constants to
a new components/activity/ feature directory. Added
resolveWorkflowAgentHierarchy and buildGroupedActivityRows for
hierarchical activity grouping with dynamic fallback for unresolved
sub-workflow agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-08 18:57:23 +02:00
co-authored by Copilot
parent fa8f6ef4b3
commit c70a5c6612
24 changed files with 2063 additions and 240 deletions
+57 -171
View File
@@ -1,59 +1,21 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, GitBranch, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
formatAgentActivityLabel,
buildGroupedActivityRows,
formatDuration,
formatNanoAiu,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
type SessionActivityState,
type SessionRequestUsageState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { inferProvider } from '@shared/domain/models';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import { resolveWorkflowAgentHierarchy, type AgentNodeConfig, type WorkflowDefinition } from '@shared/domain/workflow';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
/* ── Mode accent colours ───────────────────────────────────── */
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
};
/* ── Helpers ───────────────────────────────────────────────── */
function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'Max',
};
return labels[effort] ?? effort;
}
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
import { AgentRow } from './activity/AgentRow';
import { SubWorkflowGroup } from './activity/SubWorkflowGroup';
import { modeAccent, modeLabels } from './activity/constants';
/* ── Section header ────────────────────────────────────────── */
@@ -65,112 +27,6 @@ function SectionHeader({ children }: { children: ReactNode }) {
);
}
/* ── Agent row ─────────────────────────────────────────────── */
function AgentRow({
row,
agent,
accent,
isLast,
agentUsage,
}: {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{/* Left accent bar — visible only when this agent is actively working */}
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{/* Model + effort inline */}
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
{/* Activity label */}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{/* Per-agent usage summary */}
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
/* ── Turn event helpers ─────────────────────────────────────── */
import type { SessionEventKind } from '@shared/domain/event';
@@ -178,6 +34,8 @@ import type { SessionEventKind } from '@shared/domain/event';
function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) {
const base = 'size-3';
switch (kind) {
case 'agent-activity':
return <GitBranch className={`${base} ${phase === 'start' ? 'text-[var(--color-accent-sky)]' : 'text-[var(--color-status-success)]'}`} />;
case 'subagent':
return <ArrowRight className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-accent-sky)]'}`} />;
case 'hook-lifecycle':
@@ -207,6 +65,7 @@ function formatTurnEventTimestamp(iso: string): string {
interface ActivityPanelProps {
activity?: SessionActivityState;
workflow: WorkflowDefinition;
workflows?: ReadonlyArray<WorkflowDefinition>;
session: SessionRecord;
sessionRequestUsage?: SessionRequestUsageState;
turnEvents?: TurnEventLog;
@@ -215,32 +74,40 @@ interface ActivityPanelProps {
export function ActivityPanel({
activity,
workflow,
workflows,
session,
sessionRequestUsage,
turnEvents,
}: ActivityPanelProps) {
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const resolveOptions = useMemo(() => ({
resolveWorkflow: (id: string) => workflows?.find((w) => w.id === id),
}), [workflows]);
const activityRows = useMemo(
() => buildAgentActivityRows(activity, workflowAgents),
[activity, workflowAgents],
const hierarchy = useMemo(
() => resolveWorkflowAgentHierarchy(workflow, resolveOptions),
[workflow, resolveOptions],
);
const groupedRows = useMemo(
() => buildGroupedActivityRows(activity, hierarchy),
[activity, hierarchy],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const totalAgentCount = hierarchy.topLevelAgents.length
+ hierarchy.subWorkflows.reduce((sum, sw) => sum + sw.agents.length, 0);
const isBusy = session.status === 'running';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
const accent = modeAccent[workflowMode] ?? modeAccent.single;
const hasSubWorkflows = groupedRows.subWorkflows.length > 0;
return (
<div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */}
{/* Header */}
<div className="drag-region border-b border-[var(--color-border)] px-4 pb-3 pt-3">
<div className="flex min-h-8 items-center gap-2">
<Activity className="size-4 text-[var(--color-text-muted)]" />
@@ -267,32 +134,53 @@ export function ActivityPanel({
<Users className="size-3" />
<span>Agents</span>
<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)]">
{activityRows.length}
{totalAgentCount}
</span>
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
{modeLabels[workflowMode]}
</span>
</SectionHeader>
{activityRows.length > 0 ? (
{/* Top-level agents */}
{groupedRows.topLevelAgents.length > 0 && (
<div className="glass-surface rounded-lg px-3">
{activityRows.map((row, index) => {
{groupedRows.topLevelAgents.map((row, index) => {
const agent = hierarchy.topLevelAgents.find((a) => a.id === row.key || a.name === row.agentName);
const agentKey = row.activity?.agentId ?? row.key;
const agentUsage = sessionRequestUsage?.perAgent[agentKey]
?? sessionRequestUsage?.perAgent[row.agentName];
return (
<AgentRow
accent={accent}
agent={workflowAgents[index]}
agentUsage={agentUsage}
isLast={index === activityRows.length - 1}
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={!hasSubWorkflows && index === groupedRows.topLevelAgents.length - 1}
agentUsage={agentUsage}
/>
);
})}
</div>
) : (
)}
{/* Sub-workflow groups */}
{hasSubWorkflows && (
<div className={`space-y-2 ${groupedRows.topLevelAgents.length > 0 ? 'mt-2' : ''}`}>
{groupedRows.subWorkflows.map((group) => {
const subDef = hierarchy.subWorkflows.find((sw) => sw.nodeId === group.nodeId);
return (
<SubWorkflowGroup
key={group.nodeId}
group={group}
agentConfigs={subDef?.agents ?? []}
agentUsage={sessionRequestUsage?.perAgent}
/>
);
})}
</div>
)}
{totalAgentCount === 0 && !hasSubWorkflows && (
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">No agents configured</p>
)}
</div>
@@ -376,5 +264,3 @@ export function ActivityPanel({
</div>
);
}