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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user