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
+1
View File
@@ -745,6 +745,7 @@ export default function App() {
<ActivityPanel
activity={activityForSession}
workflow={workflowForSession}
workflows={workspace?.workflows}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
+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>
);
}
@@ -0,0 +1,111 @@
import { Sparkles } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import { inferProvider } from '@shared/domain/models';
import {
formatAgentActivityLabel,
formatDuration,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
} from '@renderer/lib/sessionActivity';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { type ModeAccent, formatEffort, formatModel } from './constants';
interface AgentRowProps {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: ModeAccent;
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}
export function AgentRow({ row, agent, accent, isLast, agentUsage }: AgentRowProps) {
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)]'}`}>
{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>
{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>
)}
<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>
{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>
);
}
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, GitBranch } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import {
isAgentActivityActive,
type AgentUsageAccumulator,
type SubWorkflowActivityGroup,
} from '@renderer/lib/sessionActivity';
import { AgentRow } from './AgentRow';
import { modeAccent, modeLabels } from './constants';
interface SubWorkflowGroupProps {
group: SubWorkflowActivityGroup;
agentConfigs: ReadonlyArray<AgentNodeConfig>;
agentUsage?: Record<string, AgentUsageAccumulator>;
}
const statusPresentation = {
idle: {
dot: 'bg-[var(--color-surface-3)]',
text: 'text-[var(--color-text-muted)]',
label: 'Idle',
},
running: {
dot: 'animate-pulse',
text: '',
label: 'Running',
},
completed: {
dot: 'bg-[var(--color-status-success)]',
text: 'text-[var(--color-status-success)]',
label: 'Done',
},
} as const;
export function SubWorkflowGroup({ group, agentConfigs, agentUsage }: SubWorkflowGroupProps) {
const [isExpanded, setIsExpanded] = useState(false);
const prevStatusRef = useRef(group.status);
useEffect(() => {
if (prevStatusRef.current !== 'running' && group.status === 'running') {
setIsExpanded(true);
}
prevStatusRef.current = group.status;
}, [group.status]);
const toggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const accent = modeAccent[group.orchestrationMode] ?? modeAccent.single;
const status = statusPresentation[group.status];
const hasActiveAgent = group.agents.some((a) => isAgentActivityActive(a.activity));
return (
<div
className="overflow-hidden rounded-lg border border-[var(--color-border-subtle)] border-l-[3px] bg-[var(--color-surface-1)]"
style={{ borderLeftColor: accent.color }}
role="group"
aria-label={`Sub-workflow: ${group.name}`}
>
{/* Collapsible header */}
<button
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-2)]"
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
aria-expanded={isExpanded}
type="button"
>
<GitBranch className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{group.name}
</span>
{/* Status badge */}
<span className="flex items-center gap-1" aria-live="polite">
<span
className={`size-1.5 rounded-full ${
group.status === 'running'
? `${accent.dot} ${status.dot} ring-1 ring-[var(--color-border-glow)]`
: status.dot
}`}
/>
<span className={`text-[9px] font-medium ${group.status === 'running' ? accent.label : status.text}`}>
{status.label}
</span>
</span>
{/* Agent count pill */}
<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)]">
{group.agents.length}
</span>
<ChevronDown
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{/* Expandable agent list */}
<div
className="grid transition-[grid-template-rows] duration-150 ease-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<div className="relative border-t border-[var(--color-border-subtle)] py-1 pl-5 pr-3">
{/* Connecting vertical accent line */}
{hasActiveAgent && (
<div className={`absolute bottom-3 left-[11px] top-3 w-px ${accent.bar}`} />
)}
{group.agents.map((row, index) => {
const agent = agentConfigs.find((c) => c.id === row.key || c.name === row.agentName);
const usage = agentUsage?.[row.activity?.agentId ?? row.key] ?? agentUsage?.[row.agentName];
return (
<AgentRow
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={index === group.agents.length - 1}
agentUsage={usage}
/>
);
})}
{/* Mode label */}
<div className="flex items-center gap-1 pb-1 pt-0.5">
<span className={`text-[9px] font-medium ${accent.label}`}>
{modeLabels[group.orchestrationMode]}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import type { WorkflowOrchestrationMode } from '@shared/domain/workflow';
export interface ModeAccent {
dot: string;
bar: string;
label: string;
color: string;
}
export const modeAccent: Record<WorkflowOrchestrationMode, ModeAccent> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]', color: '#245CF9' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]', color: '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)]', color: '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)]', color: '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)]', color: 'var(--color-accent-purple)' },
};
export const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
export function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
export 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;
}
+115 -4
View File
@@ -1,4 +1,4 @@
import type { AgentNodeConfig } from '@shared/domain/workflow';
import type { AgentNodeConfig, WorkflowAgentHierarchy, WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { SessionEventRecord } from '@shared/domain/event';
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
@@ -8,6 +8,8 @@ export interface AgentActivityState {
activityType?: SessionEventRecord['activityType'];
toolName?: string;
toolArguments?: Record<string, unknown>;
subworkflowNodeId?: string;
subworkflowName?: string;
}
export interface SessionUsageState {
@@ -31,9 +33,14 @@ export function applySessionEventActivity(
event: SessionEventRecord,
): SessionActivityMap {
if (event.kind === 'agent-activity') {
const agentKey = resolveAgentKey(event);
const isSubworkflowLifecycle =
event.activityType === 'subworkflow-started' || event.activityType === 'subworkflow-completed';
const agentKey = isSubworkflowLifecycle
? event.subworkflowNodeId?.trim()
: resolveAgentKey(event);
if (!agentKey) {
console.warn('[aryx activity] Dropping agent-activity event without agentId/agentName.', event);
console.warn('[aryx activity] Dropping agent-activity event without key.', event);
return current;
}
@@ -43,10 +50,12 @@ export function applySessionEventActivity(
...(current[event.sessionId] ?? {}),
[agentKey]: {
agentId: event.agentId ?? agentKey,
agentName: event.agentName?.trim() || event.agentId?.trim() || agentKey,
agentName: event.agentName?.trim() || event.subworkflowName?.trim() || event.agentId?.trim() || agentKey,
activityType: event.activityType,
toolName: event.toolName,
toolArguments: event.toolArguments,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
},
},
};
@@ -146,6 +155,88 @@ export function isAgentActivityCompleted(activity: AgentActivityState | undefine
return activity?.activityType === 'completed';
}
export type SubWorkflowGroupStatus = 'idle' | 'running' | 'completed';
export interface SubWorkflowActivityGroup {
nodeId: string;
name: string;
workflowId?: string;
orchestrationMode: WorkflowOrchestrationMode;
status: SubWorkflowGroupStatus;
agents: AgentActivityRow[];
}
export interface GroupedActivityRows {
topLevelAgents: AgentActivityRow[];
subWorkflows: SubWorkflowActivityGroup[];
}
function resolveSubWorkflowGroupStatus(
agents: AgentActivityRow[],
lifecycleEntry: AgentActivityState | undefined,
): SubWorkflowGroupStatus {
if (lifecycleEntry?.activityType === 'subworkflow-completed') return 'completed';
if (lifecycleEntry?.activityType === 'subworkflow-started') return 'running';
if (agents.some((a) => isAgentActivityActive(a.activity))) return 'running';
if (agents.some((a) => isAgentActivityCompleted(a.activity))) return 'completed';
return 'idle';
}
export function buildGroupedActivityRows(
current: SessionActivityState | undefined,
hierarchy: WorkflowAgentHierarchy,
): GroupedActivityRows {
const topLevelAgents = buildAgentActivityRows(current, hierarchy.topLevelAgents);
const subWorkflows: SubWorkflowActivityGroup[] = hierarchy.subWorkflows.map((sub) => {
const agents = buildAgentActivityRows(current, sub.agents);
const lifecycleEntry = current?.[sub.nodeId];
return {
nodeId: sub.nodeId,
name: sub.workflowName || sub.nodeLabel,
workflowId: sub.workflowId,
orchestrationMode: sub.orchestrationMode,
status: resolveSubWorkflowGroupStatus(agents, lifecycleEntry),
agents,
};
});
// Pick up agents that arrived via activity events with a subworkflowNodeId
// but whose sub-workflow isn't in the statically-resolved hierarchy (e.g.
// a referenced workflow that couldn't be resolved at design time).
if (current) {
const knownKeys = new Set([
...topLevelAgents.map((a) => a.key),
...subWorkflows.flatMap((sw) => [sw.nodeId, ...sw.agents.map((a) => a.key)]),
]);
const dynamicGroups = new Map<string, { name: string; agents: AgentActivityRow[] }>();
for (const [key, state] of Object.entries(current)) {
if (knownKeys.has(key)) continue;
if (!state.subworkflowNodeId) continue;
if (state.activityType === 'subworkflow-started' || state.activityType === 'subworkflow-completed') continue;
const group = dynamicGroups.get(state.subworkflowNodeId)
?? { name: state.subworkflowName ?? state.subworkflowNodeId, agents: [] };
group.agents.push({ key, agentName: state.agentName, activity: state });
dynamicGroups.set(state.subworkflowNodeId, group);
}
for (const [nodeId, group] of dynamicGroups) {
const lifecycleEntry = current[nodeId];
subWorkflows.push({
nodeId,
name: group.name,
orchestrationMode: 'sequential',
status: resolveSubWorkflowGroupStatus(group.agents, lifecycleEntry),
agents: group.agents,
});
}
}
return { topLevelAgents, subWorkflows };
}
function removeSessionActivity(
current: SessionActivityMap,
sessionId: string,
@@ -280,6 +371,26 @@ function formatDiagnosticLabel(
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
switch (event.kind) {
case 'agent-activity': {
if (event.activityType === 'subworkflow-started') {
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Sub-workflow started: ${event.subworkflowName ?? event.subworkflowNodeId ?? 'unknown'}`,
phase: 'start',
};
}
if (event.activityType === 'subworkflow-completed') {
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Sub-workflow completed: ${event.subworkflowName ?? event.subworkflowNodeId ?? 'unknown'}`,
phase: 'end',
success: true,
};
}
return undefined;
}
case 'subagent':
return {
kind: event.kind,