mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
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:
@@ -2004,7 +2004,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const activityType = event.activityType;
|
||||
let nextRun: SessionRunRecord | undefined;
|
||||
if (activityType !== 'completed') {
|
||||
if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
|
||||
nextRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
appendRunActivityEvent(run, {
|
||||
activityType,
|
||||
@@ -2032,6 +2032,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
activityType: event.activityType,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
subworkflowNodeId: event.subworkflowNodeId,
|
||||
subworkflowName: event.subworkflowName,
|
||||
sourceAgentId: event.sourceAgentId,
|
||||
sourceAgentName: event.sourceAgentName,
|
||||
toolName: event.toolName,
|
||||
|
||||
@@ -675,7 +675,7 @@ export class SessionTurnExecutor {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const activityType = event.activityType;
|
||||
let nextRun: SessionRunRecord | undefined;
|
||||
if (activityType !== 'completed') {
|
||||
if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
|
||||
nextRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
appendRunActivityEvent(run, {
|
||||
activityType,
|
||||
@@ -703,6 +703,8 @@ export class SessionTurnExecutor {
|
||||
activityType: event.activityType,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
subworkflowNodeId: event.subworkflowNodeId,
|
||||
subworkflowName: event.subworkflowName,
|
||||
sourceAgentId: event.sourceAgentId,
|
||||
sourceAgentName: event.sourceAgentName,
|
||||
toolName: event.toolName,
|
||||
|
||||
@@ -745,6 +745,7 @@ export default function App() {
|
||||
<ActivityPanel
|
||||
activity={activityForSession}
|
||||
workflow={workflowForSession}
|
||||
workflows={workspace?.workflows}
|
||||
session={selectedSession}
|
||||
sessionRequestUsage={requestUsageForSession}
|
||||
turnEvents={turnEventsForSession}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -271,7 +271,13 @@ export interface MessageReclassifiedEvent {
|
||||
newKind: 'thinking';
|
||||
}
|
||||
|
||||
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||
export type AgentActivityType =
|
||||
| 'thinking'
|
||||
| 'tool-calling'
|
||||
| 'handoff'
|
||||
| 'completed'
|
||||
| 'subworkflow-started'
|
||||
| 'subworkflow-completed';
|
||||
|
||||
export interface ToolCallFileChangePreview {
|
||||
path: string;
|
||||
@@ -286,6 +292,8 @@ export interface AgentActivityEvent {
|
||||
activityType: AgentActivityType;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
subworkflowNodeId?: string;
|
||||
subworkflowName?: string;
|
||||
sourceAgentId?: string;
|
||||
sourceAgentName?: string;
|
||||
toolName?: string;
|
||||
|
||||
@@ -8,7 +8,13 @@ import type {
|
||||
WorkflowDiagnosticSeverity,
|
||||
} from '@shared/contracts/sidecar';
|
||||
|
||||
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||
export type SessionActivityType =
|
||||
| 'thinking'
|
||||
| 'tool-calling'
|
||||
| 'handoff'
|
||||
| 'completed'
|
||||
| 'subworkflow-started'
|
||||
| 'subworkflow-completed';
|
||||
|
||||
export type SessionEventKind =
|
||||
| 'status'
|
||||
@@ -42,6 +48,8 @@ export interface SessionEventRecord {
|
||||
activityType?: SessionActivityType;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
subworkflowNodeId?: string;
|
||||
subworkflowName?: string;
|
||||
sourceAgentId?: string;
|
||||
sourceAgentName?: string;
|
||||
toolName?: string;
|
||||
|
||||
@@ -536,6 +536,68 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): AgentNodeCo
|
||||
});
|
||||
}
|
||||
|
||||
export interface SubWorkflowGroupDescriptor {
|
||||
nodeId: string;
|
||||
nodeLabel: string;
|
||||
workflowId?: string;
|
||||
workflowName: string;
|
||||
orchestrationMode: WorkflowOrchestrationMode;
|
||||
agents: AgentNodeConfig[];
|
||||
}
|
||||
|
||||
export interface WorkflowAgentHierarchy {
|
||||
topLevelAgents: AgentNodeConfig[];
|
||||
subWorkflows: SubWorkflowGroupDescriptor[];
|
||||
}
|
||||
|
||||
export function resolveWorkflowAgentHierarchy(
|
||||
workflow: WorkflowDefinition,
|
||||
options?: WorkflowResolutionOptions,
|
||||
): WorkflowAgentHierarchy {
|
||||
const topLevelAgents = resolveWorkflowAgents(workflow);
|
||||
const subWorkflows: SubWorkflowGroupDescriptor[] = [];
|
||||
|
||||
const subWorkflowNodes = workflow.graph.nodes
|
||||
.filter((node): node is WorkflowNode & { config: SubWorkflowConfig } => node.kind === 'sub-workflow')
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
for (const node of subWorkflowNodes) {
|
||||
const subWorkflowDef = resolveSubWorkflowDefinition(node, options);
|
||||
if (!subWorkflowDef) continue;
|
||||
|
||||
const agents = resolveWorkflowAgents(subWorkflowDef);
|
||||
const mode = inferWorkflowOrchestrationMode(subWorkflowDef, options);
|
||||
|
||||
subWorkflows.push({
|
||||
nodeId: node.id,
|
||||
nodeLabel: node.label,
|
||||
workflowId: node.config.workflowId,
|
||||
workflowName: subWorkflowDef.name || node.label,
|
||||
orchestrationMode: mode,
|
||||
agents,
|
||||
});
|
||||
}
|
||||
|
||||
return { topLevelAgents, subWorkflows };
|
||||
}
|
||||
|
||||
function resolveSubWorkflowDefinition(
|
||||
node: WorkflowNode & { config: SubWorkflowConfig },
|
||||
options?: WorkflowResolutionOptions,
|
||||
): WorkflowDefinition | undefined {
|
||||
if (node.config.inlineWorkflow) return node.config.inlineWorkflow;
|
||||
if (node.config.workflowId && options?.resolveWorkflow) {
|
||||
return options.resolveWorkflow(node.config.workflowId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasWorkflowExecutionFanEdges(
|
||||
workflow: WorkflowDefinition,
|
||||
options?: WorkflowResolutionOptions,
|
||||
|
||||
Reference in New Issue
Block a user