feat: add parallel missions panel to dashboard

- Add mission_id to all event types for filtering
- Add Running Missions panel showing all parallel missions
- Filter SSE events by current mission to avoid mixing
- Add API functions for getRunningMissions, startMissionParallel, cancelMission
- Auto-show panel when multiple missions are running
This commit is contained in:
Thomas Marchand
2025-12-19 18:50:52 +00:00
parent 7ed522861f
commit 594a401888
2 changed files with 208 additions and 6 deletions

View File

@@ -16,9 +16,12 @@ import {
getCurrentMission,
uploadFile,
getProgress,
getRunningMissions,
cancelMission,
type ControlRunState,
type Mission,
type MissionStatus,
type RunningMissionInfo,
} from "@/lib/api";
import {
Send,
@@ -40,6 +43,8 @@ import {
Paperclip,
ArrowDown,
Cpu,
Layers,
RefreshCw,
} from "lucide-react";
import {
OptionList,
@@ -363,6 +368,10 @@ export default function ControlClient() {
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [missionLoading, setMissionLoading] = useState(false);
// Parallel missions state
const [runningMissions, setRunningMissions] = useState<RunningMissionInfo[]>([]);
const [showParallelPanel, setShowParallelPanel] = useState(false);
// Attachment state
const [attachments, setAttachments] = useState<
{ file: File; uploading: boolean }[]
@@ -543,6 +552,41 @@ export default function ControlClient() {
}
}, [searchParams, router, missionHistoryToItems]);
// Poll for running parallel missions
useEffect(() => {
const pollRunning = async () => {
try {
const running = await getRunningMissions();
setRunningMissions(running);
// Auto-show panel if there are parallel missions
if (running.length > 1) {
setShowParallelPanel(true);
}
} catch {
// Ignore errors
}
};
// Poll immediately and then every 3 seconds
pollRunning();
const interval = setInterval(pollRunning, 3000);
return () => clearInterval(interval);
}, []);
// Handle cancelling a parallel mission
const handleCancelMission = async (missionId: string) => {
try {
await cancelMission(missionId);
toast.success("Mission cancelled");
// Refresh running list
const running = await getRunningMissions();
setRunningMissions(running);
} catch (err) {
console.error("Failed to cancel mission:", err);
toast.error("Failed to cancel mission");
}
};
// Handle creating a new mission
const handleNewMission = async () => {
try {
@@ -600,6 +644,20 @@ export default function ControlClient() {
const handleEvent = (event: { type: string; data: unknown }) => {
const data: unknown = event.data;
// Filter events by mission_id - only show events for current mission or events without mission_id
if (isRecord(data) && data["mission_id"]) {
const eventMissionId = String(data["mission_id"]);
// Get current mission ID from the URL or state
const urlMissionId = new URLSearchParams(window.location.search).get("mission");
if (urlMissionId && eventMissionId !== urlMissionId) {
// Event is for a different mission, ignore it for UI updates
// But still process status events to update parallel missions list
if (event.type !== "status") {
return;
}
}
}
if (event.type === "status" && isRecord(data)) {
reconnectAttempts = 0;
const st = data["state"];
@@ -942,6 +1000,23 @@ export default function ControlClient() {
<span className="hidden sm:inline">New</span> Mission
</button>
{/* Parallel missions indicator */}
{runningMissions.length > 0 && (
<button
onClick={() => setShowParallelPanel(!showParallelPanel)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors",
showParallelPanel
? "border-indigo-500/30 bg-indigo-500/10 text-indigo-400"
: "border-white/[0.06] bg-white/[0.02] text-white/70 hover:bg-white/[0.04]"
)}
>
<Layers className="h-4 w-4" />
<span className="font-medium tabular-nums">{runningMissions.length}</span>
<span className="hidden sm:inline">Running</span>
</button>
)}
{/* Status panel */}
<div className="flex items-center gap-2 rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
{/* Run state indicator */}
@@ -984,6 +1059,90 @@ export default function ControlClient() {
</div>
</div>
{/* Parallel Missions Panel */}
{showParallelPanel && runningMissions.length > 0 && (
<div className="mb-4 rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-medium text-white">Running Missions</h3>
</div>
<button
onClick={async () => {
const running = await getRunningMissions();
setRunningMissions(running);
}}
className="p-1 rounded hover:bg-white/[0.04] text-white/40 hover:text-white/70 transition-colors"
title="Refresh"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{runningMissions.map((mission) => {
const isCurrentMission = currentMission?.id === mission.mission_id;
return (
<div
key={mission.mission_id}
className={cn(
"flex items-center justify-between rounded-lg border p-3 transition-colors",
isCurrentMission
? "border-indigo-500/30 bg-indigo-500/10"
: "border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04]"
)}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={cn(
"h-2 w-2 rounded-full shrink-0",
mission.state === "running" ? "bg-emerald-400 animate-pulse" : "bg-amber-400"
)}
/>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">
{mission.model_override || "Default Model"}
</p>
<p className="text-xs text-white/40 truncate">
{mission.mission_id.slice(0, 8)}... {mission.state}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{!isCurrentMission && (
<button
onClick={async () => {
try {
setMissionLoading(true);
const loaded = await loadMission(mission.mission_id);
setCurrentMission(loaded);
setItems(missionHistoryToItems(loaded));
router.replace(`/control?mission=${mission.mission_id}`, { scroll: false });
} catch {
toast.error("Failed to load mission");
} finally {
setMissionLoading(false);
}
}}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
View
</button>
)}
<button
onClick={() => handleCancelMission(mission.mission_id)}
className="p-1 rounded hover:bg-white/[0.04] text-white/40 hover:text-red-400 transition-colors"
title="Cancel mission"
>
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Chat container */}
<div className="flex-1 min-h-0 flex flex-col rounded-2xl glass-panel border border-white/[0.06] overflow-hidden relative">
{/* Messages */}

View File

@@ -324,6 +324,48 @@ export async function loadMission(id: string): Promise<Mission> {
return res.json();
}
// ==================== Parallel Missions ====================
export interface RunningMissionInfo {
mission_id: string;
model_override: string | null;
state: "queued" | "running" | "waiting_for_tool" | "finished";
queue_len: number;
history_len: number;
}
// Get all running parallel missions
export async function getRunningMissions(): Promise<RunningMissionInfo[]> {
const res = await apiFetch("/api/control/running");
if (!res.ok) throw new Error("Failed to fetch running missions");
return res.json();
}
// Start a mission in parallel
export async function startMissionParallel(
missionId: string,
content: string
): Promise<{ ok: boolean; mission_id: string }> {
const res = await apiFetch(`/api/control/missions/${missionId}/parallel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to start parallel mission: ${text}`);
}
return res.json();
}
// Cancel a specific mission
export async function cancelMission(missionId: string): Promise<void> {
const res = await apiFetch(`/api/control/missions/${missionId}/cancel`, {
method: "POST",
});
if (!res.ok) throw new Error("Failed to cancel mission");
}
// Set mission status
export async function setMissionStatus(
id: string,
@@ -342,8 +384,8 @@ export async function setMissionStatus(
export type ControlRunState = "idle" | "running" | "waiting_for_tool";
export type ControlAgentEvent =
| { type: "status"; state: ControlRunState; queue_len: number }
| { type: "user_message"; id: string; content: string }
| { type: "status"; state: ControlRunState; queue_len: number; mission_id?: string }
| { type: "user_message"; id: string; content: string; mission_id?: string }
| {
type: "assistant_message";
id: string;
@@ -351,11 +393,12 @@ export type ControlAgentEvent =
success: boolean;
cost_cents: number;
model: string | null;
mission_id?: string;
}
| { type: "thinking"; content: string; done: boolean }
| { type: "tool_call"; tool_call_id: string; name: string; args: unknown }
| { type: "tool_result"; tool_call_id: string; name: string; result: unknown }
| { type: "error"; message: string };
| { type: "thinking"; content: string; done: boolean; mission_id?: string }
| { type: "tool_call"; tool_call_id: string; name: string; args: unknown; mission_id?: string }
| { type: "tool_result"; tool_call_id: string; name: string; result: unknown; mission_id?: string }
| { type: "error"; message: string; mission_id?: string };
export async function postControlMessage(
content: string