fix(FN-834): fix branch prefix drift, add merger branch guard, and fix test OOM

- Fix resolveBaseBranch to use stored branch name and consistent fusion/ prefix
  for both explicit deps and blockedBy paths (was using kb/ for blockedBy)
- Add main branch checkout verification in merger before squash merge to prevent
  feature code from landing on wrong branch lineage
- Align all branch prefix references from stale kb/ to fusion/ across executor,
  merger, store, and routes
- Fix executor test OOM by mocking merger fully, adding fake timers to retry
  tests, and switching vitest pool to vmThreads
- Update all test assertions to use fusion/ branch prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 11:11:59 -07:00
parent 10e86d63f7
commit 01ffffee81
26 changed files with 1985 additions and 236 deletions

View File

@@ -0,0 +1,73 @@
import { Activity } from "lucide-react";
import type { Agent } from "../api";
import { useLiveTranscript } from "../hooks/useLiveTranscript";
interface LiveAgentCardProps {
agent: Agent;
}
function LiveAgentCard({ agent }: LiveAgentCardProps) {
const { entries, isConnected } = useLiveTranscript(agent.taskId);
const elapsed = agent.lastHeartbeatAt
? Math.floor((Date.now() - new Date(agent.lastHeartbeatAt).getTime()) / 1000)
: 0;
return (
<div className="live-agent-card">
<div className="live-agent-card-header">
<div className="live-agent-card-name">
<span className="live-agent-pulse" />
<span>{agent.name}</span>
</div>
{agent.taskId && (
<span className="live-agent-task badge">{agent.taskId}</span>
)}
</div>
<div className="live-agent-card-transcript">
{entries.length === 0 ? (
<div className="live-agent-card-empty">
{isConnected ? "Waiting for output..." : "Connecting..."}
</div>
) : (
entries.slice(0, 20).map((entry, i) => (
<div key={i} className="live-agent-card-line">
{entry.content}
</div>
))
)}
</div>
<div className="live-agent-card-footer">
<span className="text-secondary">{formatElapsed(elapsed)}</span>
{isConnected && <Activity size={12} className="live-agent-streaming-dot" />}
</div>
</div>
);
}
function formatElapsed(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
interface ActiveAgentsPanelProps {
agents: Agent[];
}
export function ActiveAgentsPanel({ agents }: ActiveAgentsPanelProps) {
if (agents.length === 0) return null;
return (
<div className="active-agents-panel">
<div className="active-agents-panel-header">
<Activity size={16} />
<span>Active Agents ({agents.length})</span>
</div>
<div className="active-agents-grid">
{agents.map(agent => (
<LiveAgentCard key={agent.id} agent={agent} />
))}
</div>
</div>
);
}

View File

@@ -58,7 +58,9 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
};
@@ -183,8 +185,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
if (agent.state === "terminated") {
return { label: "Terminated", color: "var(--state-error-text, #f85149)" };
}
if (agent.state === "error") {
return { label: agent.lastError ?? "Error", color: "var(--state-error-text, #f85149)" };
}
if (agent.state === "paused") {
return { label: "Paused", color: "var(--state-paused-text, #e3b541)" };
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", color: "var(--state-paused-text, #e3b541)" };
}
if (agent.state === "running") {
return { label: "Running", color: "var(--state-active-text, #3fb950)" };
}
if (!agent.lastHeartbeatAt) {
return { label: agent.state === "active" ? "Starting..." : "Idle", color: "var(--state-idle-text, #8b949e)" };
@@ -292,13 +300,37 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
</button>
</>
)}
{agent.state === "running" && (
<>
<button className="btn" onClick={() => void handleStateChange("paused")}>
<Pause size={16} />
Pause
</button>
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
<Square size={16} />
Stop
</button>
</>
)}
{agent.state === "error" && (
<>
<button className="btn btn--primary" onClick={() => void handleStateChange("active")}>
<Play size={16} />
Retry
</button>
<button className="btn btn--danger" onClick={() => void handleStateChange("terminated")}>
<Square size={16} />
Stop
</button>
</>
)}
{agent.state === "terminated" && (
<button className="btn btn--danger" onClick={handleDelete}>
<Trash2 size={16} />
Delete
</button>
)}
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh">
<RefreshCw size={16} />
</button>

View File

@@ -17,14 +17,17 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
{ value: "reviewer", label: "Reviewer", icon: "👁" },
{ value: "merger", label: "Merger", icon: "🔀" },
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
{ value: "engineer", label: "Engineer", icon: "🛠" },
{ value: "custom", label: "Custom", icon: "🔧" },
];
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
};
export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentListModalProps) {
@@ -134,8 +137,14 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
if (agent.state === "terminated") {
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
}
if (agent.state === "error") {
return { label: agent.lastError ?? "Error", icon: <Activity size={14} />, color: "var(--state-error-text)" };
}
if (agent.state === "running") {
return { label: "Running", icon: <Activity size={14} />, color: "var(--state-active-text)" };
}
if (agent.state === "paused") {
return { label: "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
return { label: agent.pauseReason ?? "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
}
if (!agent.lastHeartbeatAt) {
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
@@ -208,7 +217,9 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
<option value="all">All States</option>
<option value="idle">Idle</option>
<option value="active">Active</option>
<option value="running">Running</option>
<option value="paused">Paused</option>
<option value="error">Error</option>
<option value="terminated">Terminated</option>
</select>
</div>
@@ -341,6 +352,42 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</button>
</>
)}
{agent.state === "running" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
title="Pause"
>
<Pause size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} />
</button>
</>
)}
{agent.state === "error" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
title="Retry"
>
<Play size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} />
</button>
</>
)}
{agent.state === "terminated" && (
<button
className="btn btn--sm btn--danger"
@@ -490,6 +537,42 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</button>
</>
)}
{agent.state === "running" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
title="Pause"
>
<Pause size={14} /> Pause
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} /> Stop
</button>
</>
)}
{agent.state === "error" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
title="Retry"
>
<Play size={14} /> Retry
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} /> Stop
</button>
</>
)}
{agent.state === "terminated" && (
<button
className="btn btn--sm btn--danger"

View File

@@ -0,0 +1,30 @@
import { Activity, CheckCircle, ListTodo } from "lucide-react";
import type { AgentStats } from "../api";
interface AgentMetricsBarProps {
stats: AgentStats | null;
}
export function AgentMetricsBar({ stats }: AgentMetricsBarProps) {
if (!stats) return null;
const cards = [
{ icon: Activity, label: "Active Agents", value: stats.activeCount, color: "var(--state-active-text)" },
{ icon: ListTodo, label: "Assigned Tasks", value: stats.assignedTaskCount, color: "var(--in-progress)" },
{ icon: CheckCircle, label: "Success Rate", value: `${Math.round(stats.successRate * 100)}%`, color: "var(--color-success, #3fb950)" },
];
return (
<div className="agent-metrics-bar">
{cards.map(card => (
<div key={card.label} className="agent-metric-card">
<card.icon size={18} style={{ color: card.color }} />
<div className="agent-metric-info">
<span className="agent-metric-value">{card.value}</span>
<span className="agent-metric-label">{card.label}</span>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { useState, useEffect } from "react";
import { CheckCircle, XCircle, Loader2, Square, Clock } from "lucide-react";
import type { AgentHeartbeatRun } from "../api";
import { fetchAgentRuns } from "../api";
interface AgentRunHistoryProps {
agentId: string;
projectId?: string;
}
const STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string }> = {
completed: { icon: CheckCircle, color: "var(--color-success, #3fb950)" },
failed: { icon: XCircle, color: "var(--color-error, #f85149)" },
active: { icon: Loader2, color: "var(--in-progress, #bc8cff)" },
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
};
export function AgentRunHistory({ agentId, projectId }: AgentRunHistoryProps) {
const [runs, setRuns] = useState<AgentHeartbeatRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
fetchAgentRuns(agentId, 50, projectId)
.then(setRuns)
.catch(() => setRuns([]))
.finally(() => setIsLoading(false));
}, [agentId, projectId]);
if (isLoading) {
return <div className="agent-run-loading"><Loader2 className="animate-spin" size={20} /> Loading runs...</div>;
}
if (runs.length === 0) {
return <div className="agent-run-empty">No runs yet</div>;
}
return (
<div className="agent-run-history">
{runs.map(run => {
const statusInfo = STATUS_ICONS[run.status] ?? STATUS_ICONS.terminated;
const StatusIcon = statusInfo.icon;
const duration = run.endedAt
? Math.round((new Date(run.endedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)
: null;
const usage = run.usageJson;
return (
<div key={run.id} className="agent-run-row">
<StatusIcon size={16} style={{ color: statusInfo.color }} className={run.status === "active" ? "animate-spin" : ""} />
<div className="agent-run-info">
<span className="agent-run-id">{run.id}</span>
<span className="text-secondary">{new Date(run.startedAt).toLocaleString()}</span>
</div>
<div className="agent-run-meta">
{duration !== null && (
<span className="badge"><Clock size={12} /> {duration}s</span>
)}
{usage && (
<span className="badge text-secondary">
{((usage.inputTokens + usage.outputTokens) / 1000).toFixed(1)}k tokens
</span>
)}
{run.triggerDetail && (
<span className="badge text-secondary">{run.triggerDetail}</span>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -2,8 +2,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { JSX } from "react";
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, Filter } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
import { fetchAgents, updateAgent, updateAgentState, deleteAgent } from "../api";
import { AgentDetailView } from "./AgentDetailView";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import { AgentMetricsBar } from "./AgentMetricsBar";
import { useAgents } from "../hooks/useAgents";
import { NewAgentDialog } from "./NewAgentDialog";
export interface AgentsViewProps {
addToast: (message: string, type?: "success" | "error") => void;
@@ -16,22 +20,24 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
{ value: "reviewer", label: "Reviewer", icon: "👁" },
{ value: "merger", label: "Merger", icon: "🔀" },
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
{ value: "engineer", label: "Engineer", icon: "🛠" },
{ value: "custom", label: "Custom", icon: "🔧" },
];
const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: string }> = {
idle: { bg: "var(--state-idle-bg)", text: "var(--state-idle-text)", border: "var(--state-idle-border)" },
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
};
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const { activeAgents, stats } = useAgents(projectId);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newAgentName, setNewAgentName] = useState("");
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
const [filterState, setFilterState] = useState<AgentState | "all">("all");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentView, setAgentView] = useState<"board" | "list">(() => {
@@ -65,19 +71,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
void loadAgents();
}, [loadAgents]);
const handleCreate = async () => {
if (!newAgentName.trim()) return;
try {
await createAgent({ name: newAgentName.trim(), role: newAgentRole }, projectId);
addToast(`Agent "${newAgentName}" created`, "success");
setNewAgentName("");
setIsCreating(false);
void loadAgents();
} catch (err: any) {
addToast(`Failed to create agent: ${err.message}`, "error");
}
};
const handleStateChange = async (agentId: string, newState: AgentState) => {
try {
await updateAgentState(agentId, newState, projectId);
@@ -132,8 +125,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
if (agent.state === "terminated") {
return { label: "Terminated", icon: <Square size={14} />, color: "var(--state-error-text)" };
}
if (agent.state === "error") {
return { label: agent.lastError ?? "Error", icon: <Activity size={14} />, color: "var(--state-error-text)" };
}
if (agent.state === "paused") {
return { label: "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
return { label: agent.pauseReason ? `Paused: ${agent.pauseReason}` : "Paused", icon: <Pause size={14} />, color: "var(--state-paused-text)" };
}
if (agent.state === "running") {
return { label: "Running", icon: <Activity size={14} />, color: "var(--state-active-text)" };
}
if (!agent.lastHeartbeatAt) {
return { label: agent.state === "active" ? "Starting..." : "Idle", icon: <Bot size={14} />, color: "var(--text-secondary)" };
@@ -200,48 +199,34 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<option value="all">All States</option>
<option value="idle">Idle</option>
<option value="active">Active</option>
<option value="running">Running</option>
<option value="paused">Paused</option>
<option value="error">Error</option>
<option value="terminated">Terminated</option>
</select>
</div>
<button
className="btn btn--primary"
onClick={() => setIsCreating(!isCreating)}
onClick={() => setIsCreating(true)}
>
<Plus size={16} />
{isCreating ? "Cancel" : "New Agent"}
New Agent
</button>
</div>
{/* Create Form */}
{isCreating && (
<div className="agent-create-form">
<input
type="text"
placeholder="Agent name..."
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void handleCreate()}
className="input"
autoFocus
/>
<select
className="select"
value={newAgentRole}
onChange={(e) => setNewAgentRole(e.target.value as AgentCapability)}
>
{AGENT_ROLES.map(role => (
<option key={role.value} value={role.value}>
{role.icon} {role.label}
</option>
))}
</select>
<button className="btn btn--primary" onClick={() => void handleCreate()}>
Create
</button>
</div>
)}
<NewAgentDialog
isOpen={isCreating}
onClose={() => setIsCreating(false)}
onCreated={() => { setIsCreating(false); void loadAgents(); }}
projectId={projectId}
/>
{/* Metrics Bar */}
<AgentMetricsBar stats={stats} />
{/* Active Agents Panel - Live streaming cards */}
<ActiveAgentsPanel agents={activeAgents} />
{/* Agent List */}
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
@@ -341,6 +326,42 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
</button>
</>
)}
{agent.state === "running" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
title="Pause"
>
<Pause size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} />
</button>
</>
)}
{agent.state === "error" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
title="Retry"
>
<Play size={14} />
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} />
</button>
</>
)}
{agent.state === "terminated" && (
<button
className="btn btn--sm btn--danger"
@@ -501,6 +522,42 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
</button>
</>
)}
{agent.state === "running" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "paused")}
title="Pause"
>
<Pause size={14} /> Pause
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} /> Stop
</button>
</>
)}
{agent.state === "error" && (
<>
<button
className="btn btn--sm"
onClick={() => void handleStateChange(agent.id, "active")}
title="Retry"
>
<Play size={14} /> Retry
</button>
<button
className="btn btn--sm btn--danger"
onClick={() => void handleStateChange(agent.id, "terminated")}
title="Stop"
>
<Square size={14} /> Stop
</button>
</>
)}
{agent.state === "terminated" && (
<button
className="btn btn--sm btn--danger"

View File

@@ -0,0 +1,273 @@
import { useState } from "react";
import type { AgentCapability } from "../api";
import { createAgent } from "../api";
export interface NewAgentDialogProps {
isOpen: boolean;
onClose: () => void;
onCreated: () => void;
projectId?: string;
}
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
{ value: "triage", label: "Triage", icon: "🔍" },
{ value: "executor", label: "Executor", icon: "⚡" },
{ value: "reviewer", label: "Reviewer", icon: "👁" },
{ value: "merger", label: "Merger", icon: "🔀" },
{ value: "scheduler", label: "Scheduler", icon: "⏰" },
{ value: "engineer", label: "Engineer", icon: "🛠" },
{ value: "custom", label: "Custom", icon: "🔧" },
];
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
interface RuntimeConfig {
model: string;
thinkingLevel: ThinkingLevel;
maxTurns: number;
}
export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAgentDialogProps) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [role, setRole] = useState<AgentCapability>("custom");
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
model: "",
thinkingLevel: "off",
maxTurns: 10,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleClose = () => {
setStep(0);
setName("");
setTitle("");
setRole("custom");
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
setError(null);
onClose();
};
const handleCreate = async () => {
if (!name.trim()) return;
setIsSubmitting(true);
setError(null);
try {
const runtimeCfg: Record<string, unknown> = {};
if (runtimeConfig.model.trim()) runtimeCfg.model = runtimeConfig.model.trim();
if (runtimeConfig.thinkingLevel !== "off") runtimeCfg.thinkingLevel = runtimeConfig.thinkingLevel;
if (runtimeConfig.maxTurns !== 10) runtimeCfg.maxTurns = runtimeConfig.maxTurns;
await createAgent({
name: name.trim(),
role,
...(title.trim() ? { title: title.trim() } : {}),
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
}, projectId);
handleClose();
onCreated();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to create agent");
} finally {
setIsSubmitting(false);
}
};
const selectedRole = AGENT_ROLES.find(r => r.value === role);
return (
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label="Create new agent">
{/* Header */}
<div className="agent-dialog-header">
<span style={{ fontWeight: 600, fontSize: 15 }}>New Agent</span>
<button
className="btn-icon"
onClick={handleClose}
aria-label="Close"
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)", fontSize: 18, lineHeight: 1 }}
>
×
</button>
</div>
{/* Step indicator */}
<div className="agent-dialog-steps">
{[0, 1, 2].map(i => (
<div
key={i}
className={`agent-dialog-step${i === step ? " active" : i < step ? " completed" : ""}`}
aria-label={`Step ${i + 1}`}
/>
))}
</div>
{/* Body */}
<div className="agent-dialog-body">
{step === 0 && (
<div>
<div className="agent-dialog-field">
<label htmlFor="agent-name">Name <span style={{ color: "var(--state-error-text, #f85149)" }}>*</span></label>
<input
id="agent-name"
type="text"
className="input"
placeholder="e.g. Frontend Reviewer"
value={name}
onChange={e => setName(e.target.value)}
autoFocus
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-title">Title <span style={{ color: "var(--text-muted)", fontWeight: 400 }}>(optional)</span></label>
<input
id="agent-title"
type="text"
className="input"
placeholder="e.g. Senior Code Reviewer"
value={title}
onChange={e => setTitle(e.target.value)}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="agent-dialog-field">
<label>Role</label>
<div className="agent-role-grid">
{AGENT_ROLES.map(r => (
<button
key={r.value}
type="button"
className={`agent-role-option${role === r.value ? " selected" : ""}`}
onClick={() => setRole(r.value)}
>
<span className="agent-role-option-icon">{r.icon}</span>
<span style={{ fontSize: 12, marginTop: 4 }}>{r.label}</span>
</button>
))}
</div>
</div>
</div>
)}
{step === 1 && (
<div>
<div className="agent-dialog-field">
<label htmlFor="agent-model">Model ID</label>
<input
id="agent-model"
type="text"
className="input"
placeholder="e.g. claude-sonnet-4-5"
value={runtimeConfig.model}
onChange={e => setRuntimeConfig(c => ({ ...c, model: e.target.value }))}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-thinking">Thinking Level</label>
<select
id="agent-thinking"
className="select"
value={runtimeConfig.thinkingLevel}
onChange={e => setRuntimeConfig(c => ({ ...c, thinkingLevel: e.target.value as ThinkingLevel }))}
style={{ width: "100%" }}
>
<option value="off">Off</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-max-turns">Max Turns</label>
<input
id="agent-max-turns"
type="number"
className="input"
min={1}
max={500}
value={runtimeConfig.maxTurns}
onChange={e => setRuntimeConfig(c => ({ ...c, maxTurns: Math.max(1, parseInt(e.target.value, 10) || 1) }))}
style={{ width: "100%", boxSizing: "border-box" }}
/>
</div>
</div>
)}
{step === 2 && (
<div>
<p style={{ color: "var(--text-muted)", fontSize: 13, marginTop: 0, marginBottom: 12 }}>
Review your agent configuration before creating.
</p>
<div className="agent-dialog-summary">
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Name</span>
<span style={{ fontWeight: 600 }}>{name}</span>
</div>
{title && (
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Title</span>
<span>{title}</span>
</div>
)}
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Role</span>
<span>{selectedRole?.icon} {selectedRole?.label}</span>
</div>
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Model</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>{runtimeConfig.model || <em style={{ color: "var(--text-muted)" }}>default</em>}</span>
</div>
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Thinking</span>
<span style={{ textTransform: "capitalize" }}>{runtimeConfig.thinkingLevel}</span>
</div>
<div className="agent-dialog-summary-row">
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>Max Turns</span>
<span>{runtimeConfig.maxTurns}</span>
</div>
</div>
{error && (
<p style={{ color: "var(--state-error-text, #f85149)", fontSize: 13, marginTop: 12 }}>{error}</p>
)}
</div>
)}
</div>
{/* Footer */}
<div className="agent-dialog-footer">
{step > 0 && (
<button className="btn" onClick={() => setStep(s => s - 1)} disabled={isSubmitting}>
Back
</button>
)}
<button className="btn" onClick={handleClose} disabled={isSubmitting}>
Cancel
</button>
{step < 2 ? (
<button
className="btn btn--primary"
onClick={() => setStep(s => s + 1)}
disabled={step === 0 && !name.trim()}
>
Next
</button>
) : (
<button
className="btn btn--primary"
onClick={() => void handleCreate()}
disabled={isSubmitting || !name.trim()}
>
{isSubmitting ? "Creating..." : "Create"}
</button>
)}
</div>
</div>
</div>
);
}