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:
73
packages/dashboard/app/components/ActiveAgentsPanel.tsx
Normal file
73
packages/dashboard/app/components/ActiveAgentsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
30
packages/dashboard/app/components/AgentMetricsBar.tsx
Normal file
30
packages/dashboard/app/components/AgentMetricsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
packages/dashboard/app/components/AgentRunHistory.tsx
Normal file
73
packages/dashboard/app/components/AgentRunHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
273
packages/dashboard/app/components/NewAgentDialog.tsx
Normal file
273
packages/dashboard/app/components/NewAgentDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user