Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions src/web-ui/src/app/scenes/agents/AgentsScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import './AgentsView.scss';
import './AgentsScene.scss';
import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh';
import {
CORE_AGENT_IDS,
ACP_CORE_AGENT_PREFIX,
buildCoreAgentIds,
isAgentInOverviewZone,
isLocallyManageableSubagent,
} from './agentVisibility';
Expand Down Expand Up @@ -288,26 +289,50 @@ const AgentsHomeView: React.FC = () => {
};
}, []);

const coreAgentMeta = useMemo((): Record<string, CoreAgentMeta> => ({
agentic: {
role: t('coreAgentsZone.modes.agentic.role'),
...CORE_AGENT_ACCENTS.agentic,
},
Cowork: {
role: t('coreAgentsZone.modes.cowork.role'),
...CORE_AGENT_ACCENTS.Cowork,
},
ComputerUse: {
role: t('coreAgentsZone.modes.computerUse.role'),
...CORE_AGENT_ACCENTS.ComputerUse,
},
}), [t]);
const coreAgentMeta = useMemo((): Record<string, CoreAgentMeta> => {
const meta: Record<string, CoreAgentMeta> = {
agentic: {
role: t('coreAgentsZone.modes.agentic.role'),
...CORE_AGENT_ACCENTS.agentic,
},
Cowork: {
role: t('coreAgentsZone.modes.cowork.role'),
...CORE_AGENT_ACCENTS.Cowork,
},
ComputerUse: {
role: t('coreAgentsZone.modes.computerUse.role'),
...CORE_AGENT_ACCENTS.ComputerUse,
},
};
// Append ACP external agent meta (use a neutral accent).
for (const agent of allAgents) {
if (agent.id.startsWith(ACP_CORE_AGENT_PREFIX) && !meta[agent.id]) {
meta[agent.id] = {
role: t('coreAgentsZone.modes.acpExternal.role', agent.name),
...DEFAULT_CORE_AGENT_ACCENT,
};
}
}
return meta;
}, [t, allAgents]);

const acpEnabledIds = useMemo(
() => allAgents
.filter((a) => a.id.startsWith(ACP_CORE_AGENT_PREFIX))
.map((a) => a.id.slice(ACP_CORE_AGENT_PREFIX.length)),
[allAgents],
);

const coreAgents = useMemo(() => allAgents.filter((agent) => CORE_AGENT_IDS.has(agent.id)), [allAgents]);
const effectiveCoreAgentIds = useMemo(() => buildCoreAgentIds(acpEnabledIds), [acpEnabledIds]);

const coreAgents = useMemo(
() => allAgents.filter((agent) => effectiveCoreAgentIds.has(agent.id)),
[allAgents, effectiveCoreAgentIds],
);

const visibleAgents = useMemo(
() => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds)),
[filteredAgents, hiddenAgentIds],
() => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds, effectiveCoreAgentIds)),
[filteredAgents, hiddenAgentIds, effectiveCoreAgentIds],
);

const scrollToZone = useCallback((targetId: string) => {
Expand Down
20 changes: 18 additions & 2 deletions src/web-ui/src/app/scenes/agents/agentVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,31 @@ export const HIDDEN_AGENT_IDS = new Set<string>([
...FALLBACK_REVIEW_HIDDEN_AGENT_IDS,
]);

/** Prefix used by ACP external agents (e.g. acp__codex, acp__Claude_Code). */
export const ACP_CORE_AGENT_PREFIX = 'acp__';

/** Core mode agents shown in the top zone only; excluded from overview zone list and counts. */
export const CORE_AGENT_IDS = new Set<string>(['agentic', 'Cowork', 'ComputerUse']);
const STATIC_CORE_AGENT_IDS = new Set<string>(['agentic', 'Cowork', 'ComputerUse']);

/** Build the effective core-agent id set including enabled ACP external agents. */
export function buildCoreAgentIds(acpEnabledIds: string[]): Set<string> {
const ids = new Set(STATIC_CORE_AGENT_IDS);
for (const acpId of acpEnabledIds) {
ids.add(`${ACP_CORE_AGENT_PREFIX}${acpId}`);
}
return ids;
}

/** Legacy static set kept for backward-compat callers that don't inject ACP ids. */
export const CORE_AGENT_IDS = STATIC_CORE_AGENT_IDS;

/** Agents that appear in the bottom overview grid (same pool as filter chip counts). */
export function isAgentInOverviewZone(
agent: { id: string },
hiddenAgentIds: ReadonlySet<string> = HIDDEN_AGENT_IDS,
coreAgentIds: ReadonlySet<string> = CORE_AGENT_IDS,
): boolean {
return !hiddenAgentIds.has(agent.id) && !CORE_AGENT_IDS.has(agent.id);
return !hiddenAgentIds.has(agent.id) && !coreAgentIds.has(agent.id);
}

/** External subagents are visible in the overview but managed by their source adapter. */
Expand Down
177 changes: 177 additions & 0 deletions src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import React, { useCallback, useState } from 'react';
import { ArrowLeft, GitBranch, Network } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, IconButton } from '@/component-library';
import { useNotification } from '@/shared/notification-system';
import PATTERNS, {
type LegionPatternNode,
type LegionPatternEdge,
} from '../data/orchestration-patterns';
import { LegionPresetAPI } from '@/infrastructure/api/service-api/LegionPresetAPI';
import '../AgentsView.scss';

interface CreateLegionPageProps {
onBack: () => void;
}

const CreateLegionPage: React.FC<CreateLegionPageProps> = ({ onBack }) => {
const { t } = useTranslation('scenes/agents');
const { success: notifySuccess, error: notifyError } = useNotification();
const [selectedPatternId, setSelectedPatternId] = useState<string>(PATTERNS[0]?.id ?? '');
const [saving, setSaving] = useState(false);

const selectedPattern = PATTERNS.find((p) => p.id === selectedPatternId) ?? null;

const handleSelectPattern = useCallback((id: string) => {
setSelectedPatternId(id);
}, []);

const handleSave = useCallback(async () => {
if (!selectedPattern || saving) return;
setSaving(true);
try {
await LegionPresetAPI.createPreset({
id: selectedPattern.id,
name: selectedPattern.name,
description: selectedPattern.description,
nodes: selectedPattern.nodes.map((n) => ({
id: n.id,
agent: n.agent,
role: n.role,
prompt: n.prompt,
gate: n.gate,
})),
edges: selectedPattern.edges.map((e) => ({
from: e.from,
to: e.to,
condition: e.condition,
})),
});
notifySuccess(`Legion preset "${selectedPattern.name}" saved`);
onBack();
} catch (err) {
notifyError(`Failed to save legion preset: ${err}`);
} finally {
setSaving(false);
}
}, [selectedPattern, saving, onBack, notifySuccess, notifyError]);

const renderNodeList = (nodes: LegionPatternNode[]) => (
<div className="legion-node-list">
{nodes.map((node, i) => (
<div key={node.id} className="legion-node-item">
<span className="legion-node-index">{i + 1}</span>
<div className="legion-node-info">
<span className="legion-node-role">{node.role}</span>
<span className="legion-node-agent">{node.agent}</span>
</div>
{node.gate ? <span className="legion-node-gate">{t('legionPattern.gate')}</span> : null}
</div>
))}
</div>
);

const renderEdgeList = (edges: LegionPatternEdge[], nodes: LegionPatternNode[]) => (
<div className="legion-edge-list">
{edges.map((edge) => {
const fromNode = nodes.find((n) => n.id === edge.from);
const toNode = nodes.find((n) => n.id === edge.to);
return (
<div key={`${edge.from}->${edge.to}`} className="legion-edge-item">
<span className="legion-edge-from">{fromNode?.role ?? edge.from}</span>
<GitBranch size={12} className="legion-edge-arrow" />
<span className="legion-edge-to">{toNode?.role ?? edge.to}</span>
{edge.condition ? (
<span className="legion-edge-condition">[{edge.condition}]</span>
) : null}
</div>
);
})}
</div>
);

return (
<div className="create-agent-page" data-testid="create-legion-page">
<div className="create-agent-page__header">
<IconButton
onClick={onBack}
aria-label={t('legionPattern.back')}
data-testid="create-legion-back"
>
<ArrowLeft size={18} />
</IconButton>
<h1 className="create-agent-page__title">
{selectedPattern ? selectedPattern.name : t('legionPattern.choosePattern')}
</h1>
</div>

{/* Pattern selector */}
<section className="create-agent-page__section">
<h2 className="create-agent-page__section-title">{t('legionPattern.orchestrationPatterns')}</h2>
<div className="legion-pattern-grid">
{PATTERNS.map((pattern) => (
<div
key={pattern.id}
className={`legion-pattern-chip ${pattern.id === selectedPatternId ? 'legion-pattern-chip--active' : ''}`}
onClick={() => handleSelectPattern(pattern.id)}
role="button"
tabIndex={0}
aria-pressed={pattern.id === selectedPatternId}
onKeyDown={(e) => e.key === 'Enter' && handleSelectPattern(pattern.id)}
data-testid="legion-pattern-option"
data-pattern-id={pattern.id}
>
<Network size={14} />
<span>{pattern.name}</span>
</div>
))}
</div>
</section>

{selectedPattern ? (
<>
{/* Summary */}
<section className="create-agent-page__section">
<h2 className="create-agent-page__section-title">{t('legionPattern.overview')}</h2>
<p className="legion-summary-desc">{selectedPattern.description}</p>
<div className="legion-summary-meta">
<span>{t('legionPattern.complexity', { level: selectedPattern.complexityLevel })}</span>
<span>{t('legionPattern.nodesCount', { count: selectedPattern.nodes.length })}</span>
<span>{t('legionPattern.edgesCount', { count: selectedPattern.edges.length })}</span>
</div>
</section>

{/* Nodes */}
<section className="create-agent-page__section">
<h2 className="create-agent-page__section-title">
{t('legionPattern.nodes', { count: selectedPattern.nodes.length })}
</h2>
{renderNodeList(selectedPattern.nodes)}
</section>

{/* Edges */}
<section className="create-agent-page__section">
<h2 className="create-agent-page__section-title">
{t('legionPattern.edges', { count: selectedPattern.edges.length })}
</h2>
{selectedPattern.edges.length > 0
? renderEdgeList(selectedPattern.edges, selectedPattern.nodes)
: <p className="legion-empty-hint">{t('legionPattern.noEdges')}</p>}
</section>

{/* Actions */}
<div className="create-agent-page__actions">
<Button variant="secondary" onClick={onBack}>
{t('legionPattern.back')}
</Button>
<Button variant="primary" onClick={handleSave} disabled={saving} data-testid="create-legion-save">
{saving ? t('loading') : t('legionPattern.usePattern')}
</Button>
</div>
</>
) : null}
</div>
);
};

export default CreateLegionPage;
99 changes: 99 additions & 0 deletions src/web-ui/src/app/scenes/agents/components/LegionCard.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
.legion-card {
cursor: pointer;
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border-radius: 10px;
background: var(--element-bg-soft);
border: 1px solid var(--border-subtle);
transition: border-color 0.15s, box-shadow 0.15s;

&:hover {
border-color: var(--border-accent);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.06);
}

&__header {
display: flex;
align-items: flex-start;
gap: 10px;
}

&__icon-area {
flex-shrink: 0;
}

&__icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--color-overlay-white-12);
color: var(--color-accent-500);
display: flex;
align-items: center;
justify-content: center;
}

&__header-info {
flex: 1;
min-width: 0;
}

&__title-row {
display: flex;
align-items: center;
gap: 6px;
}

&__name {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

&__badges {
display: flex;
gap: 4px;
flex-shrink: 0;
}

&__body {
flex: 1;
}

&__desc {
font-size: 12px;
line-height: 1.5;
color: var(--color-text-secondary);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

&__footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
}

&__meta {
display: flex;
gap: 10px;
}

&__meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--color-text-muted);
}
}
Loading