Files
hive/core/frontend/src/pages/workspace.tsx
T
2026-03-18 20:05:48 -07:00

3469 lines
157 KiB
TypeScript

import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import ReactDOM from "react-dom";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Plus, KeyRound, Sparkles, Layers, ChevronLeft, Bot, Loader2, WifiOff, X } from "lucide-react";
import type { GraphNode, NodeStatus } from "@/components/graph-types";
import DraftGraph from "@/components/DraftGraph";
import ChatPanel, { type ChatMessage } from "@/components/ChatPanel";
import TopBar from "@/components/TopBar";
import { TAB_STORAGE_KEY, loadPersistedTabs, savePersistedTabs, type PersistedTabState } from "@/lib/tab-persistence";
import NodeDetailPanel from "@/components/NodeDetailPanel";
import CredentialsModal, { type Credential, createFreshCredentials, cloneCredentials, allRequiredCredentialsMet, clearCredentialCache } from "@/components/CredentialsModal";
import { agentsApi } from "@/api/agents";
import { executionApi } from "@/api/execution";
import { graphsApi } from "@/api/graphs";
import { sessionsApi } from "@/api/sessions";
import { useMultiSSE } from "@/hooks/use-sse";
import type { LiveSession, AgentEvent, DiscoverEntry, NodeSpec, DraftGraph as DraftGraphData } from "@/api/types";
import { sseEventToChatMessage, formatAgentDisplayName } from "@/lib/chat-helpers";
import { topologyToGraphNodes } from "@/lib/graph-converter";
import { cronToLabel } from "@/lib/graphUtils";
import { ApiError } from "@/api/client";
const makeId = () => Math.random().toString(36).slice(2, 9);
/**
* Strip the instance suffix added when multiple tabs share the same agentType.
* e.g. "exports/deep_research::abc123" → "exports/deep_research"
* First-instance keys (no "::") are returned unchanged.
*/
const baseAgentType = (key: string): string => key.split("::")[0];
/** Format seconds into a compact countdown string. */
function formatCountdown(totalSecs: number): string {
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = Math.floor(totalSecs % 60);
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
return `${m}m ${String(s).padStart(2, "0")}s`;
}
/** Live countdown from an initial seconds value, ticking every second. */
function TimerCountdown({ initialSeconds }: { initialSeconds: number }) {
const [remaining, setRemaining] = useState(Math.max(0, Math.round(initialSeconds)));
const startRef = useRef({ wallTime: Date.now(), initial: Math.max(0, Math.round(initialSeconds)) });
useEffect(() => {
startRef.current = { wallTime: Date.now(), initial: Math.max(0, Math.round(initialSeconds)) };
setRemaining(Math.max(0, Math.round(initialSeconds)));
}, [initialSeconds]);
useEffect(() => {
const id = setInterval(() => {
const elapsed = (Date.now() - startRef.current.wallTime) / 1000;
setRemaining(Math.max(0, Math.round(startRef.current.initial - elapsed)));
}, 1000);
return () => clearInterval(id);
}, []);
if (remaining <= 0) return <span className="text-amber-400/80">firing...</span>;
return <span>{formatCountdown(remaining)}</span>;
}
// --- Session types ---
interface Session {
id: string;
agentType: string;
/** The key used in sessionsByAgent / agentStates for this specific tab instance.
* Equals agentType for the first tab; equals "agentType::frontendSessionId" for
* additional tabs opened for the same agent so each gets its own isolated slot. */
tabKey?: string;
label: string;
messages: ChatMessage[];
graphNodes: GraphNode[];
credentials: Credential[];
backendSessionId?: string;
/** The cold history session ID this tab was originally opened from (if any).
* Used to detect "already open" even after backendSessionId is updated to a
* new live session ID when the cold session is revived. */
historySourceId?: string;
}
function createSession(agentType: string, label: string, existingCredentials?: Credential[]): Session {
return {
id: makeId(),
agentType,
label,
messages: [],
graphNodes: [],
credentials: existingCredentials ? cloneCredentials(existingCredentials) : createFreshCredentials(agentType),
};
}
// --- NewTabPopover ---
type PopoverStep = "root" | "new-agent-choice" | "clone-pick";
interface NewTabPopoverProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
activeWorker: string;
discoverAgents: DiscoverEntry[];
onFromScratch: () => void;
onCloneAgent: (agentPath: string, agentName: string) => void;
}
function NewTabPopover({ open, onClose, anchorRef, discoverAgents, onFromScratch, onCloneAgent }: NewTabPopoverProps) {
const [step, setStep] = useState<PopoverStep>("root");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { if (open) setStep("root"); }, [open]);
// Compute position from anchor button
useEffect(() => {
if (open && anchorRef.current) {
const rect = anchorRef.current.getBoundingClientRect();
const POPUP_WIDTH = 240; // w-60 = 15rem = 240px
const overflows = rect.left + POPUP_WIDTH > window.innerWidth - 8;
console.log("Anchor rect:", rect, "Overflows:", overflows);
setPos({
top: rect.bottom + 4,
left: overflows ? rect.right - POPUP_WIDTH : rect.left,
});
}
}, [open, anchorRef]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
ref.current && !ref.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, onClose, anchorRef]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !pos) return null;
const optionClass =
"flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm text-left transition-colors hover:bg-muted/60 text-foreground";
const iconWrap =
"w-7 h-7 rounded-md flex items-center justify-center bg-muted/80 flex-shrink-0";
return ReactDOM.createPortal(
<div
ref={ref}
style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 9999 }}
className="w-60 rounded-xl border border-border/60 bg-card shadow-xl shadow-black/30 overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/40">
{step !== "root" && (
<button
onClick={() => setStep(step === "clone-pick" ? "new-agent-choice" : "root")}
className="p-0.5 rounded hover:bg-muted/60 transition-colors text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
)}
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{step === "root" ? "Add Tab" : step === "new-agent-choice" ? "New Agent" : "Open Agent"}
</span>
</div>
<div className="p-1.5">
{step === "root" && (
<>
<button className={optionClass} onClick={() => setStep("clone-pick")}>
<span className={iconWrap}><Layers className="w-3.5 h-3.5 text-muted-foreground" /></span>
<div>
<div className="font-medium leading-tight">Existing agent</div>
<div className="text-xs text-muted-foreground mt-0.5">Open another agent's workspace</div>
</div>
</button>
<button className={optionClass} onClick={() => setStep("new-agent-choice")}>
<span className={iconWrap}><Sparkles className="w-3.5 h-3.5 text-primary" /></span>
<div>
<div className="font-medium leading-tight">New agent</div>
<div className="text-xs text-muted-foreground mt-0.5">Build or clone a fresh agent</div>
</div>
</button>
</>
)}
{step === "new-agent-choice" && (
<>
<button className={optionClass} onClick={() => { onFromScratch(); onClose(); }}>
<span className={iconWrap}><Sparkles className="w-3.5 h-3.5 text-primary" /></span>
<div>
<div className="font-medium leading-tight">From scratch</div>
<div className="text-xs text-muted-foreground mt-0.5">Empty pipeline + Queen Bee setup</div>
</div>
</button>
<button className={optionClass} onClick={() => setStep("clone-pick")}>
<span className={iconWrap}><Layers className="w-3.5 h-3.5 text-muted-foreground" /></span>
<div>
<div className="font-medium leading-tight">Clone existing</div>
<div className="text-xs text-muted-foreground mt-0.5">Start from an existing agent</div>
</div>
</button>
</>
)}
{step === "clone-pick" && (
<div className="flex flex-col max-h-64 overflow-y-auto">
{discoverAgents.map(agent => (
<button
key={agent.path}
onClick={() => { onCloneAgent(agent.path, agent.name); onClose(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-left transition-colors hover:bg-muted/60 text-foreground"
>
<div className="w-6 h-6 rounded-md bg-muted/80 flex items-center justify-center flex-shrink-0">
<Bot className="w-3.5 h-3.5 text-muted-foreground" />
</div>
<span className="text-sm font-medium">{agent.name}</span>
</button>
))}
{discoverAgents.length === 0 && (
<p className="text-xs text-muted-foreground px-3 py-2">No agents found</p>
)}
</div>
)}
</div>
</div>,
document.body
);
}
function fmtLogTs(ts: string): string {
try {
const d = new Date(ts);
return `[${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}]`;
} catch {
return "[--:--:--]";
}
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "..." : s;
}
type SessionRestoreResult = {
messages: ChatMessage[];
restoredPhase: "planning" | "building" | "staging" | "running" | null;
/** Last flowchart map from events — used to restore flowchart overlay on cold resume. */
flowchartMap: Record<string, string[]> | null;
/** Last original draft from events — used to restore flowchart overlay on cold resume. */
originalDraft: DraftGraphData | null;
};
/**
* Restore session messages from the persisted event log.
* Returns an empty result if no event log exists.
*/
async function restoreSessionMessages(
sessionId: string,
thread: string,
agentDisplayName: string,
): Promise<SessionRestoreResult> {
try {
const { events } = await sessionsApi.eventsHistory(sessionId);
if (events.length > 0) {
const messages: ChatMessage[] = [];
let runningPhase: ChatMessage["phase"] = undefined;
let flowchartMap: Record<string, string[]> | null = null;
let originalDraft: DraftGraphData | null = null;
for (const evt of events) {
// Track phase transitions so each message gets the phase it was created in
const p = evt.type === "queen_phase_changed" ? evt.data?.phase as string
: evt.type === "node_loop_iteration" ? evt.data?.phase as string | undefined
: undefined;
if (p && ["planning", "building", "staging", "running"].includes(p)) {
runningPhase = p as ChatMessage["phase"];
}
// Track last flowchart state for cold restore
if (evt.type === "flowchart_map_updated" && evt.data) {
const mapData = evt.data as { map?: Record<string, string[]>; original_draft?: DraftGraphData };
flowchartMap = mapData.map ?? null;
originalDraft = mapData.original_draft ?? null;
}
const msg = sseEventToChatMessage(evt, thread, agentDisplayName);
if (!msg) continue;
if (evt.stream_id === "queen") {
msg.role = "queen";
msg.phase = runningPhase;
}
messages.push(msg);
}
return { messages, restoredPhase: runningPhase ?? null, flowchartMap, originalDraft };
}
} catch {
// Event log not available — session will start fresh.
}
return { messages: [], restoredPhase: null, flowchartMap: null, originalDraft: null };
}
// --- Per-agent backend state (consolidated) ---
interface AgentBackendState {
sessionId: string | null;
loading: boolean;
ready: boolean;
queenReady: boolean;
error: string | null;
displayName: string | null;
graphId: string | null;
nodeSpecs: NodeSpec[];
awaitingInput: boolean;
/** The message ID of the current worker input request (for inline reply box) */
workerInputMessageId: string | null;
queenBuilding: boolean;
/** Queen operating phase — "planning" (design), "building" (coding), "staging" (loaded), or "running" (executing) */
queenPhase: "planning" | "building" | "staging" | "running";
/** Draft graph from planning phase (before code generation) */
draftGraph: DraftGraphData | null;
/** Original draft (pre-dissolution) for flowchart display during runtime */
originalDraft: DraftGraphData | null;
/** Runtime node ID → list of original draft node IDs it absorbed */
flowchartMap: Record<string, string[]> | null;
workerRunState: "idle" | "deploying" | "running";
currentExecutionId: string | null;
currentRunId: string | null;
nodeLogs: Record<string, string[]>;
nodeActionPlans: Record<string, string>;
subagentReports: { subagent_id: string; message: string; data?: Record<string, unknown>; timestamp: string }[];
isTyping: boolean;
isStreaming: boolean;
/** True only when the queen's LLM is actively processing (not worker) */
queenIsTyping: boolean;
/** True only when a worker's LLM is actively processing (not queen) */
workerIsTyping: boolean;
llmSnapshots: Record<string, string>;
activeToolCalls: Record<string, { name: string; done: boolean; streamId: string }>;
/** True while save_agent_draft tool is running (between tool_call_started and draft_graph_updated) */
designingDraft: boolean;
/** Agent folder path — set after scaffolding, used for credential queries */
agentPath: string | null;
/** Structured question text from ask_user with options */
pendingQuestion: string | null;
/** Predefined choices from ask_user (1-3 items); UI appends "Other" */
pendingOptions: string[] | null;
/** Multiple questions from ask_user_multiple */
pendingQuestions: { id: string; prompt: string; options?: string[] }[] | null;
/** Whether the pending question came from queen or worker */
pendingQuestionSource: "queen" | "worker" | null;
/** Per-node context window usage (from context_usage_updated events) */
contextUsage: Record<string, { usagePct: number; messageCount: number; estimatedTokens: number; maxTokens: number }>;
}
function defaultAgentState(): AgentBackendState {
return {
sessionId: null,
loading: true,
ready: false,
queenReady: false,
error: null,
displayName: null,
graphId: null,
nodeSpecs: [],
awaitingInput: false,
workerInputMessageId: null,
queenBuilding: false,
queenPhase: "planning",
designingDraft: false,
draftGraph: null,
originalDraft: null,
flowchartMap: null,
agentPath: null,
workerRunState: "idle",
currentExecutionId: null,
currentRunId: null,
nodeLogs: {},
nodeActionPlans: {},
subagentReports: [],
isTyping: false,
isStreaming: false,
queenIsTyping: false,
workerIsTyping: false,
llmSnapshots: {},
activeToolCalls: {},
pendingQuestion: null,
pendingOptions: null,
pendingQuestions: null,
pendingQuestionSource: null,
contextUsage: {},
};
}
export default function Workspace() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const rawAgent = searchParams.get("agent") || "new-agent";
const hasExplicitAgent = searchParams.has("agent");
const initialPrompt = searchParams.get("prompt") || "";
// ?session= param: when navigating from the home history sidebar, this
// carries the backendSessionId to open as a tab on mount.
const initialSessionId = searchParams.get("session") || "";
// When submitting a new prompt from home for "new-agent", use a unique key
// so each prompt gets its own tab instead of overwriting the previous one.
const [initialAgent] = useState(() =>
initialPrompt && hasExplicitAgent && rawAgent === "new-agent"
? `new-agent-${makeId()}`
: rawAgent
);
// Sessions grouped by agent type — restore from localStorage if available
const [sessionsByAgent, setSessionsByAgent] = useState<Record<string, Session[]>>(() => {
const persisted = loadPersistedTabs();
const initial: Record<string, Session[]> = {};
if (persisted) {
for (const tab of persisted.tabs) {
// tabKey is the actual key used in sessionsByAgent (may contain "::" suffix).
// Fall back to agentType for tabs persisted before this field was added.
const tabKey = tab.tabKey || tab.agentType;
// New-agent tabs each have a unique key (e.g. "new-agent-abc123"),
// so they never collide with the incoming tab — always restore them.
if (!initial[tabKey]) initial[tabKey] = [];
const session = createSession(tab.agentType, tab.label);
session.id = tab.id;
session.backendSessionId = tab.backendSessionId;
session.tabKey = tab.tabKey; // restore so future persistence uses correct key
session.historySourceId = tab.historySourceId;
// Restore messages and graph from localStorage (up to 50 messages).
// If the backend session is still alive, loadAgentForType may
// append additional messages fetched from the server.
const cached = persisted.sessions?.[tab.id];
if (cached) {
session.messages = cached.messages || [];
session.graphNodes = cached.graphNodes || [];
}
initial[tabKey].push(session);
}
}
// If persisted tabs were restored and user didn't explicitly request
// a different agent via URL, return restored tabs as-is.
if (persisted && Object.keys(initial).length > 0 && !hasExplicitAgent) {
return initial;
}
// If there are already persisted tabs for this agent type, don't create
// a new one — the post-mount effect will call handleHistoryOpen if needed
// (for ?session= params coming from the home page sidebar).
if (initial[initialAgent]?.length) {
return initial;
}
// Also check for existing tabs with instance suffixes (e.g. "agentType::instanceId")
const existingKey = Object.keys(initial).find(
k => baseAgentType(k) === initialAgent && initial[k]?.length > 0
);
if (existingKey && !initialPrompt) {
return initial;
}
// If the user submitted a new prompt from the home page, always create
// a fresh session so the prompt isn't lost into an existing session.
// initialAgent is already a unique key (e.g. "new-agent-abc123") when
// coming from home, so the new tab won't overwrite existing ones.
if (initialPrompt && hasExplicitAgent) {
const rawLabel = initialAgent.startsWith("new-agent")
? "New Agent"
: formatAgentDisplayName(initialAgent);
const existingNewAgentCount = Object.keys(initial).filter(
k => (k === "new-agent" || k.startsWith("new-agent-")) && (initial[k] || []).length > 0
).length;
const label = existingNewAgentCount === 0 ? rawLabel : `${rawLabel} #${existingNewAgentCount + 1}`;
const newSession = createSession(initialAgent, label);
initial[initialAgent] = [newSession];
return initial;
}
// Only create a fresh default tab when there are no persisted tabs at all.
// If ?session= was passed we intentionally do NOT create a tab here —
// handleHistoryOpen is called post-mount and does proper dedup.
if (initialAgent === "new-agent") {
const s = createSession("new-agent", "New Agent");
initial["new-agent"] = [...(initial["new-agent"] || []), s];
} else if (!initialSessionId) {
// Only auto-create an agent tab if there's no session to restore
const s = createSession(initialAgent, formatAgentDisplayName(initialAgent));
initial[initialAgent] = [...(initial[initialAgent] || []), s];
}
return initial;
});
const [activeSessionByAgent, setActiveSessionByAgent] = useState<Record<string, string>>(() => {
const persisted = loadPersistedTabs();
// If initialSessionId maps to an already-restored tab, activate that tab
if (initialSessionId) {
for (const [tabKey, sessions] of Object.entries(sessionsByAgent)) {
const match = sessions.find(
s => s.backendSessionId === initialSessionId || s.historySourceId === initialSessionId,
);
if (match) {
return { ...(persisted?.activeSessionByAgent ?? {}), [tabKey]: match.id };
}
}
}
if (persisted) {
let restored = { ...persisted.activeSessionByAgent };
// Remove stale new-agent-* entries when starting fresh from home
if (initialPrompt && hasExplicitAgent) {
restored = Object.fromEntries(
Object.entries(restored).filter(([key]) =>
key !== "new-agent" && !key.startsWith("new-agent-")
)
);
}
const urlSessions = sessionsByAgent[initialAgent];
if (urlSessions?.length) {
// When a prompt was submitted from home, activate the newly created
// session (last in array) instead of the previously active one.
if (initialPrompt && hasExplicitAgent) {
restored[initialAgent] = urlSessions[urlSessions.length - 1].id;
} else if (!restored[initialAgent]) {
restored[initialAgent] = urlSessions[0].id;
}
}
return restored;
}
const sessions = sessionsByAgent[initialAgent];
return sessions ? { [initialAgent]: sessions[0].id } : {};
});
const [activeWorker, setActiveWorker] = useState(() => {
// If initialSessionId maps to an already-restored tab, activate that key
if (initialSessionId) {
for (const [tabKey, sessions] of Object.entries(sessionsByAgent)) {
if (sessions.some(
s => s.backendSessionId === initialSessionId || s.historySourceId === initialSessionId,
)) return tabKey;
}
}
if (!hasExplicitAgent) {
const persisted = loadPersistedTabs();
if (persisted?.activeWorker) return persisted.activeWorker;
}
return initialAgent;
});
// Clear URL params after mount — they're consumed during initialization
// and leaving them causes confusion (stale ?agent= after tab switches, etc.)
useEffect(() => {
navigate("/workspace", { replace: true });
}, []);
// Post-mount: if the URL carried a ?session= param (from the home page history
// sidebar), open it via handleHistoryOpen instead of creating a tab in init state.
// This is the single canonical path — it has robust dedup (checks backendSessionId
// AND historySourceId across all in-memory tabs) and is safe to call after persisted
// state has been hydrated.
// We capture initialSessionId and related URL params in stable refs so the effect
// only fires once on mount, regardless of re-renders.
const initialSessionIdRef = useRef(initialSessionId);
const initialAgentRef = useRef(initialAgent);
const mountedRef = useRef(false);
const [credentialsOpen, setCredentialsOpen] = useState(false);
// Explicit agent path for the credentials modal — set from 424 responses
// when activeWorker doesn't match the actual agent (e.g. "new-agent" tab).
const [credentialAgentPath, setCredentialAgentPath] = useState<string | null>(null);
const [dismissedBanner, setDismissedBanner] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [triggerTaskDraft, setTriggerTaskDraft] = useState("");
const [triggerCronDraft, setTriggerCronDraft] = useState("");
const [triggerTaskSaving, setTriggerTaskSaving] = useState(false);
const [triggerScheduleSaving, setTriggerScheduleSaving] = useState(false);
const [triggerCronSaved, setTriggerCronSaved] = useState(false);
const [triggerTaskSaved, setTriggerTaskSaved] = useState(false);
const [newTabOpen, setNewTabOpen] = useState(false);
const newTabBtnRef = useRef<HTMLButtonElement>(null);
const [graphPanelPct, setGraphPanelPct] = useState(30);
const savedGraphPanelPct = useRef(30);
const resizing = useRef(false);
// Drag-to-resize the graph panel
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (!resizing.current) return;
const pct = (e.clientX / window.innerWidth) * 100;
setGraphPanelPct(Math.max(15, Math.min(50, pct)));
};
const onMouseUp = () => {
resizing.current = false;
document.body.style.cursor = "";
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, []);
// Shrink graph panel when node detail opens, restore when it closes
const nodeIsSelected = selectedNode !== null;
useEffect(() => {
if (nodeIsSelected) {
savedGraphPanelPct.current = graphPanelPct;
setGraphPanelPct(prev => Math.min(prev, 30));
} else {
setGraphPanelPct(savedGraphPanelPct.current);
}
}, [nodeIsSelected]); // eslint-disable-line react-hooks/exhaustive-deps
// Ref mirror of sessionsByAgent so SSE callback can read current graph
// state without adding sessionsByAgent to its dependency array.
const sessionsRef = useRef(sessionsByAgent);
sessionsRef.current = sessionsByAgent;
// Ref mirror of activeSessionByAgent so setSessionsByAgent updater
// functions always read the *current* active session id, avoiding stale
// closures that can silently drop messages / graph updates.
const activeSessionRef = useRef(activeSessionByAgent);
activeSessionRef.current = activeSessionByAgent;
// Synchronous per-agent turn counter for SSE message IDs.
// Using a ref avoids stale-closure bugs when multiple SSE events
// arrive in the same React batch.
const turnCounterRef = useRef<Record<string, number>>({});
// Per-agent queen phase ref — used to stamp each message with the phase
// it was created in (avoids stale-closure when phase change and message
// events arrive in the same React batch).
const queenPhaseRef = useRef<Record<string, string>>({});
// Accumulated queen text across inner_turns within the same iteration.
// Key: `${agentType}:${execution_id}:${iteration}`, value: { [inner_turn]: snapshot }.
// This lets us merge all inner_turn text into one chat bubble per iteration.
const queenIterTextRef = useRef<Record<string, Record<number, string>>>({});
// Timestamp when designingDraft was set — used to enforce minimum spinner duration.
const designingDraftSinceRef = useRef<Record<string, number>>({});
const designingDraftTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
// Synchronous ref to suppress the queen's auto-intro SSE messages
// after a cold-restore (where we already restored the conversation from disk).
// Using a ref avoids the race condition where sessionId is set in agentState
// (opening SSE) before the suppressQueenIntro flag can be committed.
const suppressIntroRef = useRef(new Set<string>());
// --- Consolidated per-agent backend state ---
const [agentStates, setAgentStates] = useState<Record<string, AgentBackendState>>({});
const updateAgentState = useCallback((agentType: string, patch: Partial<AgentBackendState>) => {
setAgentStates(prev => ({
...prev,
[agentType]: { ...(prev[agentType] || defaultAgentState()), ...patch },
}));
}, []);
// Derive active agent's backend state
const activeAgentState = agentStates[activeWorker];
// Reset dismissed banner when the error clears so it re-appears if the same error returns
const currentError = activeAgentState?.error;
useEffect(() => { if (!currentError) setDismissedBanner(null); }, [currentError]);
// Persist tab metadata + session data to localStorage on every relevant change
useEffect(() => {
const tabs: PersistedTabState["tabs"] = [];
const sessions: Record<string, { messages: ChatMessage[]; graphNodes: GraphNode[] }> = {};
for (const agentSessions of Object.values(sessionsByAgent)) {
for (const s of agentSessions) {
const tKey = s.tabKey || s.agentType;
tabs.push({
id: s.id,
agentType: s.agentType,
tabKey: s.tabKey,
label: s.label,
// agentStates is keyed by tabKey (unique per tab), not by base agentType
backendSessionId: s.backendSessionId || agentStates[tKey]?.sessionId || undefined,
...(s.historySourceId ? { historySourceId: s.historySourceId } : {}),
});
sessions[s.id] = { messages: s.messages, graphNodes: s.graphNodes };
}
}
if (tabs.length > 0) {
savePersistedTabs({ tabs, activeSessionByAgent, activeWorker, sessions });
} else {
localStorage.removeItem(TAB_STORAGE_KEY);
}
}, [sessionsByAgent, activeSessionByAgent, activeWorker, agentStates]);
const handleRun = useCallback(async () => {
const state = agentStates[activeWorker];
if (!state?.sessionId || !state?.ready) return;
// Reset dismissed banner so a repeated 424 re-shows it
setDismissedBanner(null);
try {
updateAgentState(activeWorker, { workerRunState: "deploying" });
const result = await executionApi.trigger(state.sessionId, "default", {});
updateAgentState(activeWorker, { currentExecutionId: result.execution_id });
} catch (err) {
// 424 = credentials required — open the credentials modal
if (err instanceof ApiError && err.status === 424) {
const errBody = (err as ApiError).body as Record<string, unknown>;
const credPath = (errBody?.agent_path as string) || null;
if (credPath) setCredentialAgentPath(credPath);
updateAgentState(activeWorker, { workerRunState: "idle", error: "credentials_required" });
setCredentialsOpen(true);
return;
}
const errMsg = err instanceof Error ? err.message : String(err);
setSessionsByAgent((prev) => {
const sessions = prev[activeWorker] || [];
const activeId = activeSessionRef.current[activeWorker] || sessions[0]?.id;
return {
...prev,
[activeWorker]: sessions.map((s) => {
if (s.id !== activeId) return s;
const errorMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to trigger run: ${errMsg}`,
timestamp: "", type: "system", thread: activeWorker, createdAt: Date.now(),
};
return { ...s, messages: [...s.messages, errorMsg] };
}),
};
});
updateAgentState(activeWorker, { workerRunState: "idle" });
}
}, [agentStates, activeWorker, updateAgentState]);
// --- Fetch discovered agents for NewTabPopover ---
const [discoverAgents, setDiscoverAgents] = useState<DiscoverEntry[]>([]);
useEffect(() => {
agentsApi.discover().then(result => {
const { Framework: _fw, ...userFacing } = result;
const all = Object.values(userFacing).flat();
setDiscoverAgents(all);
}).catch(() => { });
}, []);
// --- Agent loading: loadAgentForType ---
const loadingRef = useRef(new Set<string>());
const loadAgentForType = useCallback(async (agentType: string) => {
// agentType may be a unique composite key ("exports/foo::sessionId") for additional
// tabs — extract the real agent path for selector checks and API calls.
const agentPath = baseAgentType(agentType);
// Ref-based guard: prevents double-load from React StrictMode (must be first check)
if (loadingRef.current.has(agentType)) return;
loadingRef.current.add(agentType);
if (agentPath === "new-agent" || agentType.startsWith("new-agent-")) {
// Create a queen-only session (no worker) for agent building
updateAgentState(agentType, { loading: true, error: null, ready: false, sessionId: null });
try {
const prompt = initialPrompt || undefined;
let liveSession: LiveSession | undefined;
// Find the active session for this agent type
const activeId = activeSessionRef.current[agentType];
const activeSess = sessionsRef.current[agentType]?.find(s => s.id === activeId)
|| sessionsRef.current[agentType]?.[0];
// Try to reconnect to stored backend session (e.g., after browser refresh)
const storedId = activeSess?.backendSessionId;
// When the server restarts the session is "cold" — conversation files
// survive on disk but there is no live runtime. Track the old ID so
// we can restore message history after creating a new session.
let coldRestoreId: string | undefined;
if (storedId) {
try {
const sessionData = await sessionsApi.get(storedId);
if (sessionData.cold) {
// Server restarted — files on disk, no live runtime
coldRestoreId = storedId;
} else {
liveSession = sessionData;
}
} catch {
// Session gone entirely (no disk files either)
}
}
let restoredMessageCount = 0;
// Before creating a new session, check if there's already a live backend
// session for this queen-only agent that no open tab owns.
// Skip this search when the tab has a prompt — it's a fresh agent from
// home and must always get its own session.
if (!liveSession && !coldRestoreId && !prompt) {
try {
const { sessions: allLive } = await sessionsApi.list();
const existing = allLive.find(s => !s.has_worker && !s.agent_path);
if (existing) {
const alreadyOwned = Object.values(sessionsRef.current).flat()
.some(s => s.backendSessionId === existing.session_id);
if (!alreadyOwned) {
liveSession = existing;
}
}
} catch { /* proceed to create */ }
// If no live session, check history for a cold queen-only session
if (!liveSession) {
try {
const { sessions: allHistory } = await sessionsApi.history();
const coldMatch = allHistory.find(
s => !s.agent_path && s.has_messages
);
if (coldMatch) {
coldRestoreId = coldMatch.session_id;
}
} catch { /* proceed to create fresh */ }
}
}
let restoredPhase: "planning" | "building" | "staging" | "running" | null = null;
let restoredFlowchartMap: Record<string, string[]> | null = null;
let restoredOriginalDraft: DraftGraphData | null = null;
if (!liveSession) {
// Fetch conversation history from disk BEFORE creating the new session.
// SKIP if messages were already pre-populated by handleHistoryOpen.
const restoreFrom = coldRestoreId ?? storedId;
const preRestoredMsgs: ChatMessage[] = [];
const alreadyHasMessages = (activeSess?.messages?.length ?? 0) > 0;
if (restoreFrom && !alreadyHasMessages) {
try {
const restored = await restoreSessionMessages(restoreFrom, agentType, "Queen Bee");
preRestoredMsgs.push(...restored.messages);
restoredPhase = restored.restoredPhase;
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
} catch {
// Not available — will start fresh
}
} else if (restoreFrom && alreadyHasMessages) {
// Messages already cached in localStorage — still fetch events for
// non-message state (phase, flowchart) that isn't cached.
try {
const restored = await restoreSessionMessages(restoreFrom, agentType, "Queen Bee");
restoredPhase = restored.restoredPhase;
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
} catch {
// Not critical — UI will still show cached messages
}
}
// Suppress the queen's intro cycle whenever we are about to restore a
// previous conversation, or whenever we have a stored session ID.
const willRestore = !!(restoreFrom);
if (willRestore || preRestoredMsgs.length > 0) suppressIntroRef.current.add(agentType);
// Pass coldRestoreId as queenResumeFrom so the backend writes queen
// messages into the ORIGINAL session's directory.
liveSession = await sessionsApi.create(undefined, undefined, undefined, prompt, coldRestoreId ?? undefined);
if (preRestoredMsgs.length > 0) {
preRestoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
if (activeId) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId ? { ...s, messages: preRestoredMsgs, graphNodes: [] } : s,
),
}));
}
restoredMessageCount = preRestoredMsgs.length;
} else if (restoreFrom && activeId && !alreadyHasMessages) {
// We had a stored session but no messages on disk — wipe stale localStorage cache
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId ? { ...s, messages: [], graphNodes: [] } : s,
),
}));
}
// Show the initial prompt as a user message only on a truly fresh session
if (prompt && restoredMessageCount === 0 && activeId) {
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: prompt, timestamp: "", type: "user", thread: agentType, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId ? { ...s, messages: [...s.messages, userMsg] } : s,
),
}));
}
}
// Store backendSessionId on the Session object for persistence.
// Also set historySourceId so the sidebar "already-open" check works
// even after cold-revive changes backendSessionId to a new live session ID.
if (activeId) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId ? {
...s,
backendSessionId: liveSession!.session_id,
historySourceId: s.historySourceId || coldRestoreId || undefined,
} : s,
),
}));
}
// If no messages were actually restored, lift the intro suppression
if (restoredMessageCount === 0) suppressIntroRef.current.delete(agentType);
const qPhase = restoredPhase || liveSession.queen_phase || "planning";
queenPhaseRef.current[agentType] = qPhase;
updateAgentState(agentType, {
sessionId: liveSession.session_id,
displayName: "Queen Bee",
ready: true,
loading: false,
queenReady: true,
queenPhase: qPhase,
queenBuilding: qPhase === "building",
// Restore flowchart overlay from persisted events
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
updateAgentState(agentType, { error: msg, loading: false });
}
return;
}
updateAgentState(agentType, { loading: true, error: null, ready: false, sessionId: null });
try {
let liveSession: LiveSession | undefined;
let isResumedSession = false;
// Set when the stored session is cold (server restarted) so we can restore
// messages from the old session files after creating a new live session.
let coldRestoreId: string | undefined;
// Try to reconnect to an existing backend session (e.g., after browser refresh).
// The backendSessionId is persisted in localStorage per tab.
// Also check historySourceId — handleHistoryOpen populates this with the
// original session ID from the sidebar. Use it as a fallback for stored ID.
const historySourceId = sessionsRef.current[agentType]?.[0]?.historySourceId;
const storedSessionId = sessionsRef.current[agentType]?.[0]?.backendSessionId
|| historySourceId;
if (storedSessionId) {
try {
const sessionData = await sessionsApi.get(storedSessionId);
if (sessionData.cold) {
// Server restarted — conversation files survive on disk, no live runtime.
coldRestoreId = storedSessionId;
} else {
liveSession = sessionData;
isResumedSession = true;
}
} catch {
// 404: session was explicitly stopped (via closeAgentTab) but conversation
// files likely still exist on disk. Treat it as cold so we can restore.
coldRestoreId = historySourceId || storedSessionId;
}
}
// No stored session — check for a live or cold session for this agent
// that we can reuse (e.g., tab was closed but backend session survived,
// or server restarted with conversation files on disk).
if (!liveSession && !coldRestoreId) {
try {
const { sessions: allLive } = await sessionsApi.list();
const existingLive = allLive.find(s => s.agent_path.endsWith(agentPath));
if (existingLive) {
const alreadyOwned = Object.values(sessionsRef.current).flat()
.some(s => s.backendSessionId === existingLive.session_id);
if (!alreadyOwned) {
liveSession = existingLive;
isResumedSession = true;
}
}
} catch { /* proceed */ }
// If no live session, check history for a cold session to restore
if (!liveSession) {
try {
const { sessions: allHistory } = await sessionsApi.history();
const coldMatch = allHistory.find(
s => s.agent_path?.endsWith(agentPath) && s.has_messages
);
if (coldMatch) {
coldRestoreId = coldMatch.session_id;
}
} catch { /* proceed to create fresh */ }
}
}
// Track the last queen phase seen in the event log for cold restore
let restoredPhase: "planning" | "building" | "staging" | "running" | null = null;
let restoredFlowchartMap: Record<string, string[]> | null = null;
let restoredOriginalDraft: DraftGraphData | null = null;
if (!liveSession) {
// Reconnect failed — clear stale cached messages from localStorage restore.
// NEVER wipe when: (a) doing a cold restore (we'll restore from disk) or
// (b) handleHistoryOpen already pre-populated messages (alreadyHasMessages).
const alreadyHasMessages = (sessionsRef.current[agentType] || [])[0]?.messages?.length > 0;
if (storedSessionId && !coldRestoreId && !alreadyHasMessages) {
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: [], graphNodes: [] } : s,
),
}));
}
// CRITICAL: Pre-fetch queen messages from the old session directory BEFORE
// creating the new session. When queen_resume_from is set the new session writes
// to the SAME directory, so if we fetch after creation we risk capturing the
// new queen's greeting in the restored history.
// SKIP if messages were already pre-populated by handleHistoryOpen (avoids
// double-fetch and greeting leakage).
let preQueenMsgs: ChatMessage[] = [];
if (coldRestoreId && !alreadyHasMessages) {
const displayNameTemp = formatAgentDisplayName(agentPath);
const restored = await restoreSessionMessages(coldRestoreId, agentType, displayNameTemp);
preQueenMsgs = restored.messages;
restoredPhase = restored.restoredPhase;
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
} else if (coldRestoreId && alreadyHasMessages) {
// Messages already cached — still fetch events for non-message state (phase, flowchart)
try {
const displayNameTemp = formatAgentDisplayName(agentPath);
const restored = await restoreSessionMessages(coldRestoreId, agentType, displayNameTemp);
restoredPhase = restored.restoredPhase;
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
} catch {
// Not critical — UI will still show cached messages
}
}
// Suppress intro whenever we are about to restore a previous conversation.
// The user never expects a greeting when reopening a session.
if (coldRestoreId) suppressIntroRef.current.add(agentType);
try {
// Pass coldRestoreId as queenResumeFrom so the backend writes queen
// messages into the ORIGINAL session's directory — all conversation
// history accumulates in one place across server restarts.
liveSession = await sessionsApi.create(agentPath, undefined, undefined, undefined, coldRestoreId ?? undefined);
} catch (loadErr: unknown) {
// 424 = credentials required — open the credentials modal
if (loadErr instanceof ApiError && loadErr.status === 424) {
const errBody = loadErr.body as Record<string, unknown>;
const credPath = (errBody.agent_path as string) || null;
if (credPath) setCredentialAgentPath(credPath);
updateAgentState(agentType, { loading: false, error: "credentials_required" });
setCredentialsOpen(true);
return;
}
if (!(loadErr instanceof ApiError) || loadErr.status !== 409) {
throw loadErr;
}
const body = loadErr.body as Record<string, unknown>;
const existingSessionId = body.session_id as string | undefined;
if (!existingSessionId) throw loadErr;
isResumedSession = true;
if (body.loading) {
liveSession = await (async () => {
const maxAttempts = 30;
const delay = 1000;
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, delay));
try {
const result = await sessionsApi.get(existingSessionId);
if (result.loading) continue;
return result as LiveSession;
} catch (pollErr) {
// 404 = agent failed to load and was cleaned up — stop immediately
if (pollErr instanceof ApiError && pollErr.status === 404) {
throw new Error("Agent failed to load");
}
if (i === maxAttempts - 1) throw loadErr;
}
}
throw loadErr;
})();
} else {
liveSession = body as unknown as LiveSession;
}
}
// If we pre-fetched messages for a cold restore, populate the UI immediately.
// This happens before the SSE connection opens so no greeting can slip through.
if (preQueenMsgs.length > 0) {
preQueenMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: preQueenMsgs, graphNodes: [] } : s,
),
}));
}
}
// At this point liveSession is guaranteed set — if both reconnect and create
// failed, the throw inside the catch exits the outer try block.
const session = liveSession!;
const displayName = formatAgentDisplayName(session.worker_name || agentType);
const initialPhase = restoredPhase || session.queen_phase || (session.has_worker ? "staging" : "planning");
queenPhaseRef.current[agentType] = initialPhase;
updateAgentState(agentType, {
sessionId: session.session_id,
displayName,
queenPhase: initialPhase,
queenBuilding: initialPhase === "building",
// Restore flowchart overlay from persisted events
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
});
// Update the session label + backendSessionId. Also set historySourceId
// so the sidebar "already-open" check works even after cold-revive changes
// backendSessionId to a new live session ID.
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
if (!sessions.length) return prev;
return {
...prev,
[agentType]: sessions.map((s, i) =>
i === 0 ? {
...s,
// Preserve existing label if it was already set with a #N suffix by
// addAgentSession/handleHistoryOpen. Only overwrite with the bare
// displayName when the label doesn't match the resolved display name.
label: s.label.startsWith(displayName) ? s.label : displayName,
backendSessionId: session.session_id,
// Preserve existing historySourceId; set it from coldRestoreId if missing
historySourceId: s.historySourceId || coldRestoreId || undefined,
} : s,
),
};
});
// Restore messages when rejoining an existing session OR cold-restoring from disk.
let isWorkerRunning = false;
const restoredMsgs: ChatMessage[] = [];
// For cold-restore, use the old session ID. For live resume, use current session.
const historyId = coldRestoreId ?? (isResumedSession ? session.session_id : undefined);
// For LIVE resume (not cold restore), fetch event log + worker status now.
// For cold restore they were already pre-fetched above (before create) so we skip to avoid
// double-restoring and to avoid capturing the new greeting.
if (historyId && !coldRestoreId) {
const restored = await restoreSessionMessages(historyId, agentType, displayName);
restoredMsgs.push(...restored.messages);
// Use flowchart from event log if not already set
if (restored.flowchartMap && !restoredFlowchartMap) {
restoredFlowchartMap = restored.flowchartMap;
restoredOriginalDraft = restored.originalDraft;
}
// Check worker status (needed for isWorkerRunning flag)
try {
const { sessions: workerSessions } = await sessionsApi.workerSessions(historyId);
const resumable = workerSessions.find(
(s) => s.status === "active" || s.status === "paused",
);
isWorkerRunning = resumable?.status === "active";
} catch {
// Worker session listing failed — not critical
}
}
// Merge messages in chronological order (only for live resume; cold restore
// was already applied above before create).
if (restoredMsgs.length > 0) {
restoredMsgs.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
setSessionsByAgent((prev) => ({
...prev,
[agentType]: (prev[agentType] || []).map((s, i) =>
i === 0 ? { ...s, messages: [...restoredMsgs, ...s.messages] } : s,
),
}));
}
// If no messages were actually restored, lift the intro suppression gate
if (restoredMsgs.length === 0 && !coldRestoreId) suppressIntroRef.current.delete(agentType);
// Mark queenReady immediately only when resuming a session that already
// has messages (live resume or cold restore). For a fresh session the
// queen still needs to process the thinking hook before its first
// response, so leave queenReady false and let the SSE handler flip it
// on the first queen event — this keeps the "Connecting to queen..."
// loading indicator visible until the queen actually responds.
const hasRestoredContent = restoredMsgs.length > 0 || !!coldRestoreId;
updateAgentState(agentType, {
sessionId: session.session_id,
displayName,
ready: true,
loading: false,
queenReady: !!(isResumedSession || hasRestoredContent),
...(isWorkerRunning ? { workerRunState: "running" } : {}),
// Restore flowchart overlay from persisted events
...(restoredFlowchartMap ? { flowchartMap: restoredFlowchartMap } : {}),
...(restoredOriginalDraft ? { originalDraft: restoredOriginalDraft, draftGraph: null } : {}),
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
updateAgentState(agentType, { error: msg, loading: false });
} finally {
loadingRef.current.delete(agentType);
}
}, [updateAgentState, initialPrompt]);
// Auto-load agents when new tabs appear in sessionsByAgent.
// Only eagerly load the active tab — background tabs are deferred until the
// user switches to them to avoid creating duplicate backend sessions on mount.
useEffect(() => {
for (const agentType of Object.keys(sessionsByAgent)) {
if (agentStates[agentType]?.sessionId || agentStates[agentType]?.loading || agentStates[agentType]?.error) continue;
if (agentType !== activeWorker) continue;
loadAgentForType(agentType);
}
}, [sessionsByAgent, agentStates, loadAgentForType, updateAgentState, activeWorker]);
// --- Fetch graph topology when a session becomes ready ---
const fetchGraphForAgent = useCallback(async (agentType: string, sessionId: string, knownGraphId?: string) => {
try {
let graphId = knownGraphId;
if (!graphId) {
const { graphs } = await sessionsApi.graphs(sessionId);
if (!graphs.length) return;
graphId = graphs[0];
}
const topology = await graphsApi.nodes(sessionId, graphId);
updateAgentState(agentType, { graphId, nodeSpecs: topology.nodes });
const graphNodes = topologyToGraphNodes(topology);
if (graphNodes.length === 0) return;
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
if (!sessions.length) return prev;
return {
...prev,
[agentType]: sessions.map((s, i) =>
i === 0 ? { ...s, graphNodes } : s,
),
};
});
} catch {
// Graph fetch failed — keep using empty data
}
}, [updateAgentState]);
// Track which sessions already have an in-flight or completed graph fetch
// to prevent the flood of duplicate API calls. agentStates changes on every
// SSE event (text delta, tool_call, etc.) which re-triggers this effect
// before the first response has returned.
const fetchedGraphSessionsRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const [agentType, state] of Object.entries(agentStates)) {
if (!state.sessionId || !state.ready || state.nodeSpecs.length > 0 || state.graphId) continue;
if (fetchedGraphSessionsRef.current.has(state.sessionId)) continue;
fetchedGraphSessionsRef.current.add(state.sessionId);
fetchGraphForAgent(agentType, state.sessionId);
}
}, [agentStates, fetchGraphForAgent]);
// --- Fetch draft graph when a session is in planning phase ---
// Covers initial load, tab switches, reconnects, and cold restores.
const fetchedDraftSessionsRef = useRef<Set<string>>(new Set());
const fetchedFlowchartMapSessionsRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const [agentType, state] of Object.entries(agentStates)) {
if (!state.sessionId || !state.ready) continue;
if (state.queenPhase === "planning") {
// Fetch draft graph for planning phase
if (state.draftGraph) continue;
if (fetchedDraftSessionsRef.current.has(state.sessionId)) continue;
fetchedDraftSessionsRef.current.add(state.sessionId);
graphsApi.draftGraph(state.sessionId).then(({ draft }) => {
if (draft) updateAgentState(agentType, { draftGraph: draft });
}).catch(() => {});
} else if (state.queenPhase !== "building") {
// Fetch flowchart map for non-building phases (staging, running)
if (state.originalDraft) continue; // already have it
if (fetchedFlowchartMapSessionsRef.current.has(state.sessionId)) continue;
fetchedFlowchartMapSessionsRef.current.add(state.sessionId);
graphsApi.flowchartMap(state.sessionId).then(({ map, original_draft }) => {
if (original_draft) {
updateAgentState(agentType, {
flowchartMap: map,
originalDraft: original_draft,
draftGraph: null,
});
}
}).catch(() => {});
}
}
}, [agentStates, updateAgentState]);
// Poll entry points every second to keep next_fire_in countdowns fresh
// and discover dynamically created triggers (via set_trigger).
useEffect(() => {
const id = setInterval(async () => {
for (const [agentType, sessions] of Object.entries(sessionsByAgent)) {
const session = sessions[0];
if (!session) continue;
const state = agentStates[agentType];
if (!state?.sessionId) continue;
try {
const { entry_points } = await sessionsApi.entryPoints(state.sessionId);
// Skip non-manual triggers only
const triggerEps = entry_points.filter(ep => ep.trigger_type !== "manual");
if (triggerEps.length === 0) continue;
const fireMap = new Map<string, number>();
const taskMap = new Map<string, string>();
const labelMap = new Map<string, string>();
const targetMap = new Map<string, string>();
for (const ep of triggerEps) {
const nodeId = `__trigger_${ep.id}`;
if (ep.next_fire_in != null) {
fireMap.set(nodeId, ep.next_fire_in);
}
if (ep.task != null) {
taskMap.set(nodeId, ep.task);
}
const cron = ep.trigger_config?.cron as string | undefined;
const interval = ep.trigger_config?.interval_minutes as number | undefined;
const epLabel = cron
? cronToLabel(cron)
: interval
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
: ep.name || undefined;
if (epLabel) {
labelMap.set(nodeId, epLabel);
}
if (ep.entry_node) {
targetMap.set(nodeId, ep.entry_node);
}
}
setSessionsByAgent((prev) => {
const ss = prev[agentType];
if (!ss?.length) return prev;
const existingIds = new Set(ss[0].graphNodes.map(n => n.id));
// Update existing trigger nodes (countdown, task, label, target)
let updated = ss[0].graphNodes.map((n) => {
if (n.nodeType !== "trigger") return n;
const nfi = fireMap.get(n.id);
const task = taskMap.get(n.id);
const label = labelMap.get(n.id);
const target = targetMap.get(n.id);
if (nfi == null && task == null && !label && !target) return n;
return {
...n,
...(label && label !== n.label ? { label } : {}),
...(target ? { next: [target] } : {}),
triggerConfig: {
...n.triggerConfig,
...(nfi != null ? { next_fire_in: nfi } : {}),
...(task != null ? { task } : {}),
},
};
});
// Discover new triggers not yet in the graph
const fallbackEntry = ss[0].graphNodes.find(n => n.nodeType !== "trigger")?.id;
const newNodes: GraphNode[] = [];
for (const ep of triggerEps) {
const nodeId = `__trigger_${ep.id}`;
if (existingIds.has(nodeId)) continue;
const target = ep.entry_node || fallbackEntry;
newNodes.push({
id: nodeId,
label: labelMap.get(nodeId) || ep.name || ep.id,
status: "pending",
nodeType: "trigger",
triggerType: ep.trigger_type,
triggerConfig: {
...ep.trigger_config,
...(ep.next_fire_in != null ? { next_fire_in: ep.next_fire_in } : {}),
...(ep.task ? { task: ep.task } : {}),
},
...(target ? { next: [target] } : {}),
});
}
if (newNodes.length > 0) {
updated = [...newNodes, ...updated];
}
// Skip update if nothing changed
if (newNodes.length === 0 && updated.every((n, idx) => n === ss[0].graphNodes[idx])) return prev;
return {
...prev,
[agentType]: ss.map((s, i) => (i === 0 ? { ...s, graphNodes: updated } : s)),
};
});
} catch {
// Entry points fetch failed — skip this tick
}
}
}, 1_000);
return () => clearInterval(id);
}, [sessionsByAgent, agentStates]);
// --- Graph node status helpers (now accept agentType) ---
const updateGraphNodeStatus = useCallback(
(agentType: string, nodeId: string, status: NodeStatus, extra?: Partial<GraphNode>) => {
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map((s) => {
if (s.id !== activeId) return s;
return {
...s,
graphNodes: s.graphNodes.map((n) =>
n.id === nodeId ? { ...n, status, ...extra } : n
),
};
}),
};
});
},
[],
);
const markAllNodesAs = useCallback(
(agentType: string, fromStatus: NodeStatus | NodeStatus[], toStatus: NodeStatus) => {
const fromArr = Array.isArray(fromStatus) ? fromStatus : [fromStatus];
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map((s) => {
if (s.id !== activeId) return s;
return {
...s,
graphNodes: s.graphNodes.map((n) =>
fromArr.includes(n.status) ? { ...n, status: toStatus } : n
),
};
}),
};
});
},
[],
);
const handlePause = useCallback(async () => {
const state = agentStates[activeWorker];
if (!state?.sessionId) return;
// If we don't have an execution ID, the UI is stale — just reset state
if (!state.currentExecutionId) {
updateAgentState(activeWorker, { workerRunState: "idle", currentExecutionId: null });
markAllNodesAs(activeWorker, ["running", "looping"], "pending");
return;
}
try {
const result = await executionApi.pause(state.sessionId, state.currentExecutionId);
// If the backend says "not found", the execution already finished —
// reset UI state instead of showing an error.
if (result && !result.stopped) {
updateAgentState(activeWorker, { workerRunState: "idle", currentExecutionId: null });
markAllNodesAs(activeWorker, ["running", "looping"], "pending");
return;
}
updateAgentState(activeWorker, { workerRunState: "idle", currentExecutionId: null });
markAllNodesAs(activeWorker, ["running", "looping"], "pending");
} catch (err) {
// Network errors or non-2xx responses — still reset the UI since
// the execution is likely gone, but also surface the error.
updateAgentState(activeWorker, { workerRunState: "idle", currentExecutionId: null });
markAllNodesAs(activeWorker, ["running", "looping"], "pending");
const errMsg = err instanceof Error ? err.message : String(err);
setSessionsByAgent((prev) => {
const sessions = prev[activeWorker] || [];
const activeId = activeSessionRef.current[activeWorker] || sessions[0]?.id;
return {
...prev,
[activeWorker]: sessions.map((s) => {
if (s.id !== activeId) return s;
const errorMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to pause: ${errMsg}`,
timestamp: "", type: "system", thread: activeWorker, createdAt: Date.now(),
};
return { ...s, messages: [...s.messages, errorMsg] };
}),
};
});
}
}, [agentStates, activeWorker, markAllNodesAs, updateAgentState]);
const handleCancelQueen = useCallback(async () => {
const state = agentStates[activeWorker];
if (!state?.sessionId) return;
try {
await executionApi.cancelQueen(state.sessionId);
} catch {
// Best-effort — queen may have already finished
}
updateAgentState(activeWorker, { isTyping: false, isStreaming: false, queenIsTyping: false, workerIsTyping: false });
}, [agentStates, activeWorker, updateAgentState]);
// --- Node log helper (writes into agentStates) ---
const appendNodeLog = useCallback((agentType: string, nodeId: string, line: string) => {
setAgentStates((prev) => {
const state = prev[agentType];
if (!state) return prev;
const existing = state.nodeLogs[nodeId] || [];
return {
...prev,
[agentType]: {
...state,
nodeLogs: {
...state.nodeLogs,
[nodeId]: [...existing, line].slice(-200),
},
},
};
});
}, []);
// --- SSE event handler ---
const upsertChatMessage = useCallback(
(agentType: string, chatMsg: ChatMessage, options?: { reconcileOptimisticUser?: boolean }) => {
setSessionsByAgent((prev) => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map((s) => {
if (s.id !== activeId) return s;
const idx = s.messages.findIndex((m) => m.id === chatMsg.id);
let newMessages: ChatMessage[];
if (idx >= 0) {
// Update existing message in place, preserve position
newMessages = s.messages.map((m, i) =>
i === idx ? { ...chatMsg, createdAt: m.createdAt ?? chatMsg.createdAt } : m,
);
} else {
const shouldReconcileOptimisticUser =
!!options?.reconcileOptimisticUser && chatMsg.type === "user" && s.messages.length > 0;
if (shouldReconcileOptimisticUser) {
const lastIdx = s.messages.length - 1;
const lastMsg = s.messages[lastIdx];
const incomingTs = chatMsg.createdAt ?? Date.now();
const lastTs = lastMsg.createdAt ?? incomingTs;
const sameMessage =
lastMsg.type === "user"
&& lastMsg.content === chatMsg.content
&& Math.abs(incomingTs - lastTs) <= 15000;
if (sameMessage) {
newMessages = s.messages.map((m, i) =>
i === lastIdx ? { ...m, id: chatMsg.id } : m,
);
return { ...s, messages: newMessages };
}
}
// Append — SSE events arrive in server-timestamp order via the
// shared EventBus, so arrival order already interleaves queen
// and worker correctly. Local user messages are always created
// before their server responses, so append is safe there too.
newMessages = [...s.messages, chatMsg];
}
return { ...s, messages: newMessages };
}),
};
});
},
[],
);
const handleSSEEvent = useCallback(
(agentType: string, event: AgentEvent) => {
const streamId = event.stream_id;
const isQueen = streamId === "queen";
if (isQueen) console.log('[QUEEN] handleSSEEvent:', event.type, 'agentType:', agentType);
// Drop queen message content while suppressing the auto-intro after a cold-restore.
// Uses a synchronous ref to avoid race conditions with React state batching.
const suppressQueenMessages = isQueen && suppressIntroRef.current.has(agentType);
const agentDisplayName = agentStates[agentType]?.displayName;
const displayName = isQueen ? "Queen Bee" : (agentDisplayName || undefined);
const role = isQueen ? "queen" as const : "worker" as const;
const ts = fmtLogTs(event.timestamp);
// Turn counter is per-stream so queen and worker tool pills don't
// interfere. A worker node_loop_iteration no longer increments
// the queen's turn counter (which would cause pill ID mismatches
// between tool_call_started and tool_call_completed).
const turnKey = `${agentType}:${streamId}`;
const currentTurn = turnCounterRef.current[turnKey] ?? 0;
// Backend event timestamp for correct queen/worker message ordering
const eventCreatedAt = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
// Mark queen as ready on the first queen SSE event.
// Deferred to individual event handlers below so we can batch it with
// other state updates (e.g. queenIsTyping) and avoid a flash frame
// where queenReady=true but queenIsTyping=false.
const shouldMarkQueenReady = isQueen && !agentStates[agentType]?.queenReady;
switch (event.type) {
case "execution_started":
if (isQueen) {
turnCounterRef.current[turnKey] = currentTurn + 1;
updateAgentState(agentType, { isTyping: true, queenIsTyping: true, ...(shouldMarkQueenReady && { queenReady: true }) });
} else {
// Warn if prior LLM snapshots are being dropped (edge case: execution_completed never arrived)
const priorSnapshots = agentStates[agentType]?.llmSnapshots || {};
if (Object.keys(priorSnapshots).length > 0) {
console.debug(`[hive] execution_started: dropping ${Object.keys(priorSnapshots).length} unflushed LLM snapshot(s)`);
}
// Insert a run divider when a new run_id is detected
const incomingRunId = event.run_id || null;
const prevRunId = agentStates[agentType]?.currentRunId;
if (incomingRunId && incomingRunId !== prevRunId) {
const dividerMsg: ChatMessage = {
id: `run-divider-${incomingRunId}`,
agent: "",
agentColor: "",
content: prevRunId ? "New Run" : "Run Started",
timestamp: ts,
type: "run_divider",
role: "worker",
thread: agentType,
createdAt: eventCreatedAt,
};
upsertChatMessage(agentType, dividerMsg);
}
turnCounterRef.current[turnKey] = currentTurn + 1;
updateAgentState(agentType, {
isTyping: true,
isStreaming: false,
workerIsTyping: true,
awaitingInput: false,
workerRunState: "running",
currentExecutionId: event.execution_id || agentStates[agentType]?.currentExecutionId || null,
currentRunId: incomingRunId,
nodeLogs: {},
subagentReports: [],
llmSnapshots: {},
activeToolCalls: {},
pendingQuestion: null,
pendingOptions: null,
pendingQuestions: null,
pendingQuestionSource: null,
});
markAllNodesAs(agentType, ["running", "looping", "complete", "error"], "pending");
}
break;
case "execution_completed":
if (isQueen) {
suppressIntroRef.current.delete(agentType);
updateAgentState(agentType, { isTyping: false, queenIsTyping: false });
} else {
// Flush any remaining LLM snapshots before clearing state
const completedSnapshots = agentStates[agentType]?.llmSnapshots || {};
for (const [nid, text] of Object.entries(completedSnapshots)) {
if (text?.trim()) {
appendNodeLog(agentType, nid, `${ts} INFO LLM: ${truncate(text.trim(), 300)}`);
}
}
updateAgentState(agentType, {
isTyping: false,
isStreaming: false,
workerIsTyping: false,
awaitingInput: false,
workerInputMessageId: null,
workerRunState: "idle",
currentExecutionId: null,
llmSnapshots: {},
pendingQuestion: null,
pendingOptions: null,
pendingQuestions: null,
pendingQuestionSource: null,
});
markAllNodesAs(agentType, ["running", "looping"], "complete");
// Re-fetch graph topology so timer countdowns refresh
const sid = agentStates[agentType]?.sessionId;
const gid = agentStates[agentType]?.graphId;
if (sid) fetchGraphForAgent(agentType, sid, gid || undefined);
}
break;
case "execution_paused":
case "execution_failed":
case "client_output_delta":
case "client_input_received":
case "client_input_requested":
case "llm_text_delta": {
const chatMsg = sseEventToChatMessage(event, agentType, displayName, currentTurn);
if (isQueen) console.log('[QUEEN] chatMsg:', chatMsg?.id, chatMsg?.content?.slice(0, 50), 'turn:', currentTurn);
if (chatMsg && !suppressQueenMessages) {
// Queen emits multiple client_output_delta / llm_text_delta snapshots
// across iterations and inner tool-loop turns. Merge all inner_turns
// within the same iteration into ONE bubble so the queen's multi-step
// tool loop (text → tool → text → tool → text) appears as one cohesive
// message rather than many small fragments.
if (isQueen && (event.type === "client_output_delta" || event.type === "llm_text_delta") && event.execution_id) {
const iter = event.data?.iteration ?? 0;
const inner = (event.data?.inner_turn as number) ?? 0;
const iterKey = `${agentType}:${event.execution_id}:${iter}`;
// Store the latest snapshot for this inner_turn
if (!queenIterTextRef.current[iterKey]) {
queenIterTextRef.current[iterKey] = {};
}
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
queenIterTextRef.current[iterKey][inner] = snapshot;
// Concatenate all inner_turn snapshots in order
const parts = queenIterTextRef.current[iterKey];
const sortedInners = Object.keys(parts).map(Number).sort((a, b) => a - b);
chatMsg.content = sortedInners.map(k => parts[k]).join("\n");
// Single ID per iteration — no inner_turn in the ID
chatMsg.id = `queen-stream-${event.execution_id}-${iter}`;
}
if (isQueen) {
chatMsg.role = role;
chatMsg.phase = queenPhaseRef.current[agentType] as ChatMessage["phase"];
}
upsertChatMessage(agentType, chatMsg, {
reconcileOptimisticUser: event.type === "client_input_received",
});
}
// Mark streaming when LLM text is actively arriving
if (event.type === "llm_text_delta" || event.type === "client_output_delta") {
updateAgentState(agentType, { isStreaming: true, ...(isQueen ? {} : { workerIsTyping: false }) });
}
if (event.type === "llm_text_delta" && !isQueen && event.node_id) {
const snapshot = (event.data?.snapshot as string) || "";
if (snapshot) {
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
return {
...prev,
[agentType]: {
...state,
llmSnapshots: { ...state.llmSnapshots, [event.node_id!]: snapshot },
},
};
});
}
}
if (event.type === "client_input_requested") {
console.log('[CLIENT_INPUT_REQ] stream_id:', streamId, 'isQueen:', isQueen, 'node_id:', event.node_id, 'prompt:', (event.data?.prompt as string)?.slice(0, 80), 'agentType:', agentType);
const rawOptions = event.data?.options;
const options = Array.isArray(rawOptions) ? (rawOptions as string[]) : null;
const rawQuestions = event.data?.questions;
const questions = Array.isArray(rawQuestions)
? (rawQuestions as { id: string; prompt: string; options?: string[] }[])
: null;
if (isQueen) {
const prompt = (event.data?.prompt as string) || "";
const isAutoBlock = !prompt && !options && !questions;
// Queen auto-block (empty prompt, no options) should not
// overwrite a pending worker question — the worker's
// QuestionWidget must stay visible. Use the updater form
// to read the latest state and avoid stale-closure races
// when worker and queen events arrive in the same batch.
setAgentStates(prev => {
const cur = prev[agentType] || defaultAgentState();
const workerQuestionActive = cur.pendingQuestionSource === "worker";
if (isAutoBlock && workerQuestionActive) {
return {
...prev, [agentType]: {
...cur,
awaitingInput: true,
isTyping: false,
isStreaming: false,
queenIsTyping: false,
queenBuilding: false,
}
};
}
return {
...prev, [agentType]: {
...cur,
awaitingInput: true,
isTyping: false,
isStreaming: false,
queenIsTyping: false,
queenBuilding: false,
pendingQuestion: prompt || null,
pendingOptions: options,
pendingQuestions: questions,
pendingQuestionSource: "queen",
}
};
});
} else {
// Worker input request.
// If the prompt is non-empty (explicit ask_user), create a visible
// message bubble. For auto-block (empty prompt), the worker's text
// was already streamed via client_output_delta — just activate the
// reply box below the last worker message.
const eid = event.execution_id ?? "";
const prompt = (event.data?.prompt as string) || "";
if (prompt) {
const workerInputMsg: ChatMessage = {
id: `worker-input-${eid}-${event.node_id || Date.now()}`,
agent: displayName || event.node_id || "Worker",
agentColor: "",
content: prompt,
timestamp: "",
type: "worker_input_request",
role: "worker",
thread: agentType,
createdAt: eventCreatedAt,
};
console.log('[CLIENT_INPUT_REQ] creating worker_input_request msg:', workerInputMsg.id, 'content:', prompt.slice(0, 80));
upsertChatMessage(agentType, workerInputMsg);
}
updateAgentState(agentType, {
awaitingInput: true,
isTyping: false,
isStreaming: false,
queenIsTyping: false,
pendingQuestion: prompt || null,
pendingOptions: options,
pendingQuestionSource: "worker",
});
}
}
if (event.type === "execution_paused") {
updateAgentState(agentType, { isTyping: false, isStreaming: false, queenIsTyping: false, workerIsTyping: false, awaitingInput: false, workerInputMessageId: null, pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
if (!isQueen) {
updateAgentState(agentType, { workerRunState: "idle", currentExecutionId: null });
markAllNodesAs(agentType, ["running", "looping"], "pending");
}
}
if (event.type === "execution_failed") {
updateAgentState(agentType, { isTyping: false, isStreaming: false, queenIsTyping: false, workerIsTyping: false, awaitingInput: false, workerInputMessageId: null, pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
if (!isQueen) {
updateAgentState(agentType, { workerRunState: "idle", currentExecutionId: null });
if (event.node_id) {
updateGraphNodeStatus(agentType, event.node_id, "error");
const errMsg = (event.data?.error as string) || "unknown error";
appendNodeLog(agentType, event.node_id, `${ts} ERROR Execution failed: ${errMsg}`);
}
markAllNodesAs(agentType, ["running", "looping"], "pending");
}
}
break;
}
case "node_loop_started":
turnCounterRef.current[turnKey] = currentTurn + 1;
updateAgentState(agentType, { isTyping: true, activeToolCalls: {} });
if (!isQueen && event.node_id) {
const sessions = sessionsRef.current[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
const session = sessions.find((s) => s.id === activeId);
const existing = session?.graphNodes.find((n) => n.id === event.node_id);
const isRevisit = existing?.status === "complete";
updateGraphNodeStatus(agentType, event.node_id, isRevisit ? "looping" : "running", {
maxIterations: (event.data?.max_iterations as number) ?? undefined,
});
appendNodeLog(agentType, event.node_id, `${ts} INFO Node started`);
}
break;
case "node_loop_iteration":
turnCounterRef.current[turnKey] = currentTurn + 1;
if (isQueen) {
updateAgentState(agentType, { isStreaming: false, activeToolCalls: {}, awaitingInput: false, pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
} else {
updateAgentState(agentType, { isStreaming: false, workerIsTyping: true, activeToolCalls: {}, awaitingInput: false, pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
}
if (!isQueen && event.node_id) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
}
const iter = (event.data?.iteration as number) ?? undefined;
updateGraphNodeStatus(agentType, event.node_id, "looping", { iterations: iter });
appendNodeLog(agentType, event.node_id, `${ts} INFO Iteration ${iter ?? "?"}`);
}
break;
case "node_loop_completed":
if (!isQueen && event.node_id) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
}
updateGraphNodeStatus(agentType, event.node_id, "complete");
appendNodeLog(agentType, event.node_id, `${ts} INFO Node completed`);
}
break;
case "edge_traversed": {
if (!isQueen) {
const sourceNode = event.data?.source_node as string | undefined;
const targetNode = event.data?.target_node as string | undefined;
if (sourceNode) updateGraphNodeStatus(agentType, sourceNode, "complete");
if (targetNode) updateGraphNodeStatus(agentType, targetNode, "running");
}
break;
}
case "tool_call_started": {
console.log('[TOOL_PILL] tool_call_started received:', { isQueen, nodeId: event.node_id, streamId: event.stream_id, agentType, executionId: event.execution_id, toolName: event.data?.tool_name });
// queenBuilding is now driven by queen_phase_changed events
if (event.node_id) {
if (!isQueen) {
const pendingText = agentStates[agentType]?.llmSnapshots[event.node_id];
if (pendingText?.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO LLM: ${truncate(pendingText.trim(), 300)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const { [event.node_id!]: _, ...rest } = state.llmSnapshots;
return { ...prev, [agentType]: { ...state, llmSnapshots: rest } };
});
}
appendNodeLog(agentType, event.node_id, `${ts} INFO Calling ${(event.data?.tool_name as string) || "unknown"}(${event.data?.tool_input ? truncate(JSON.stringify(event.data.tool_input), 200) : ""})`);
// Track subagent delegation start
if ((event.data?.tool_name as string) === "delegate_to_sub_agent") {
const saInput = event.data?.tool_input as Record<string, unknown> | undefined;
const saId = (saInput?.agent_id as string) || "";
if (saId) {
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
return {
...prev,
[agentType]: {
...state,
subagentReports: [
...state.subagentReports,
{ subagent_id: saId, message: "Delegating...", timestamp: event.timestamp, status: "running" as const },
],
},
};
});
}
}
}
const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || "";
// Flag when the queen starts designing/updating the flowchart
if (isQueen && toolName === "save_agent_draft") {
designingDraftSinceRef.current[agentType] = Date.now();
// Clear any pending delayed-clear timer from a previous call
const prev = designingDraftTimerRef.current[agentType];
if (prev) clearTimeout(prev);
updateAgentState(agentType, { designingDraft: true });
}
// Track active (in-flight) tools and upsert activity row into chat
const sid = event.stream_id;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const newActive = { ...state.activeToolCalls, [toolUseId]: { name: toolName, done: false, streamId: sid } };
// Only include tools from this stream in the pill
const tools = Object.values(newActive).filter(t => t.streamId === sid).map(t => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every(t => t.done);
upsertChatMessage(agentType, {
id: `tool-pill-${sid}-${event.execution_id || "exec"}-${currentTurn}`,
agent: agentDisplayName || event.node_id || "Agent",
agentColor: "",
content: JSON.stringify({ tools, allDone }),
timestamp: "",
type: "tool_status",
role,
thread: agentType,
createdAt: eventCreatedAt,
});
return {
...prev,
[agentType]: { ...state, isStreaming: false, activeToolCalls: newActive },
};
});
} else {
console.log('[TOOL_PILL] SKIPPED: no node_id', event.node_id);
}
break;
}
case "tool_call_completed": {
if (event.node_id) {
const toolName = (event.data?.tool_name as string) || "unknown";
const toolUseId = (event.data?.tool_use_id as string) || "";
const isError = event.data?.is_error as boolean | undefined;
const result = event.data?.result as string | undefined;
if (isError) {
appendNodeLog(agentType, event.node_id, `${ts} ERROR ${toolName} failed: ${truncate(result || "unknown error", 200)}`);
} else {
const resultStr = result ? ` (${truncate(result, 200)})` : "";
appendNodeLog(agentType, event.node_id, `${ts} INFO ${toolName} done${resultStr}`);
}
// Track subagent delegation completion
if (toolName === "delegate_to_sub_agent" && result) {
try {
const parsed = JSON.parse(result);
const saId = (parsed?.metadata?.agent_id as string) || "";
const success = parsed?.metadata?.success as boolean;
if (saId) {
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
return {
...prev,
[agentType]: {
...state,
subagentReports: [
...state.subagentReports,
{ subagent_id: saId, message: success ? "Completed" : "Failed", timestamp: event.timestamp, status: success ? "complete" as const : "error" as const },
],
},
};
});
}
} catch { /* ignore parse errors */ }
}
// Mark tool as done and update activity row
const sid = event.stream_id;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
const updated = { ...state.activeToolCalls };
if (updated[toolUseId]) {
updated[toolUseId] = { ...updated[toolUseId], done: true };
}
const tools = Object.values(updated).filter(t => t.streamId === sid).map(t => ({ name: t.name, done: t.done }));
const allDone = tools.length > 0 && tools.every(t => t.done);
upsertChatMessage(agentType, {
id: `tool-pill-${sid}-${event.execution_id || "exec"}-${currentTurn}`,
agent: agentDisplayName || event.node_id || "Agent",
agentColor: "",
content: JSON.stringify({ tools, allDone }),
timestamp: "",
type: "tool_status",
role,
thread: agentType,
createdAt: eventCreatedAt,
});
return {
...prev,
[agentType]: { ...state, activeToolCalls: updated },
};
});
}
break;
}
case "node_internal_output":
if (!isQueen && event.node_id) {
const content = (event.data?.content as string) || "";
if (content.trim()) {
appendNodeLog(agentType, event.node_id, `${ts} INFO ${content}`);
}
}
break;
case "subagent_report": {
if (!isQueen && event.node_id) {
const subagentId = (event.data?.subagent_id as string) || "";
const message = (event.data?.message as string) || "";
const data = event.data?.data as Record<string, unknown> | undefined;
// Extract parent node ID from "parentNodeId:subagent:agentId" format
const parentNodeId = event.node_id.split(":subagent:")[0] || event.node_id;
appendNodeLog(agentType, parentNodeId, `${ts} INFO [Subagent:${subagentId}] ${truncate(message, 200)}`);
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
return {
...prev,
[agentType]: {
...state,
subagentReports: [
...state.subagentReports,
{ subagent_id: subagentId, message, data, timestamp: event.timestamp },
],
},
};
});
}
break;
}
case "node_stalled":
if (!isQueen && event.node_id) {
const reason = (event.data?.reason as string) || "unknown";
appendNodeLog(agentType, event.node_id, `${ts} WARN Stalled: ${reason}`);
}
break;
case "node_retry":
if (!isQueen && event.node_id) {
const retryCount = (event.data?.retry_count as number) ?? "?";
const maxRetries = (event.data?.max_retries as number) ?? "?";
const retryError = (event.data?.error as string) || "";
appendNodeLog(agentType, event.node_id, `${ts} WARN Retry ${retryCount}/${maxRetries}${retryError ? `: ${retryError}` : ""}`);
}
break;
case "node_tool_doom_loop":
if (!isQueen && event.node_id) {
const description = (event.data?.description as string) || "tool cycle detected";
appendNodeLog(agentType, event.node_id, `${ts} WARN Doom loop: ${description}`);
}
break;
case "context_compacted":
if (!isQueen && event.node_id) {
const usageBefore = (event.data?.usage_before as number) ?? "?";
const usageAfter = (event.data?.usage_after as number) ?? "?";
appendNodeLog(agentType, event.node_id, `${ts} INFO Context compacted: ${usageBefore}% -> ${usageAfter}%`);
}
break;
case "context_usage_updated": {
const streamKey = isQueen ? "__queen__" : (event.node_id || streamId);
const usagePct = (event.data?.usage_pct as number) ?? 0;
const messageCount = (event.data?.message_count as number) ?? 0;
const estimatedTokens = (event.data?.estimated_tokens as number) ?? 0;
const maxTokens = (event.data?.max_context_tokens as number) ?? 0;
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
return {
...prev,
[agentType]: {
...state,
contextUsage: {
...state.contextUsage,
[streamKey]: { usagePct, messageCount, estimatedTokens, maxTokens },
},
},
};
});
}
break;
case "node_action_plan":
if (!isQueen && event.node_id) {
const plan = (event.data?.plan as string) || "";
if (plan.trim()) {
setAgentStates(prev => {
const state = prev[agentType];
if (!state) return prev;
return {
...prev,
[agentType]: {
...state,
nodeActionPlans: { ...state.nodeActionPlans, [event.node_id!]: plan },
},
};
});
}
}
break;
case "credentials_required": {
updateAgentState(agentType, { workerRunState: "idle", error: "credentials_required" });
const credAgentPath = event.data?.agent_path as string | undefined;
if (credAgentPath) setCredentialAgentPath(credAgentPath);
setCredentialsOpen(true);
break;
}
case "queen_phase_changed": {
const rawPhase = event.data?.phase as string;
const eventAgentPath = (event.data?.agent_path as string) || null;
const newPhase: "planning" | "building" | "staging" | "running" =
rawPhase === "running" ? "running"
: rawPhase === "staging" ? "staging"
: rawPhase === "planning" ? "planning"
: "building";
queenPhaseRef.current[agentType] = newPhase;
updateAgentState(agentType, {
queenPhase: newPhase,
queenBuilding: newPhase === "building",
// Sync workerRunState so the RunButton reflects the phase
workerRunState: newPhase === "running" ? "running" : "idle",
// Clear originalDraft/flowchartMap when re-entering planning.
// draftGraph is cleared later when originalDraft arrives, so the
// entrance animation has data to render during the handoff.
...(newPhase === "planning"
? { originalDraft: null, flowchartMap: null }
: {}),
// Store agent path for credential queries
...(eventAgentPath ? { agentPath: eventAgentPath } : {}),
});
{
const sid = agentStates[agentType]?.sessionId;
if (sid) {
if (newPhase !== "planning" && newPhase !== "building") {
fetchedDraftSessionsRef.current.delete(sid);
fetchedFlowchartMapSessionsRef.current.delete(sid);
// Fetch the flowchart map (original draft + dissolution mapping)
graphsApi.flowchartMap(sid).then(({ map, original_draft }) => {
updateAgentState(agentType, {
flowchartMap: map,
originalDraft: original_draft,
});
}).catch(() => {});
} else if (newPhase === "planning") {
// Only clear dedup sets when re-entering planning (not building)
fetchedDraftSessionsRef.current.delete(sid);
fetchedFlowchartMapSessionsRef.current.delete(sid);
}
}
}
break;
}
case "draft_graph_updated": {
// The draft dict is published directly as event.data (not nested under a key)
const draft = event.data as unknown as DraftGraphData | undefined;
if (draft?.nodes) {
// Ensure the "Designing flowchart…" spinner stays visible for a
// minimum duration so users see feedback before the draft appears.
const MIN_SPINNER_MS = 600;
const since = designingDraftSinceRef.current[agentType] || 0;
const elapsed = Date.now() - since;
const remaining = Math.max(0, MIN_SPINNER_MS - elapsed);
const applyDraft = () => {
delete designingDraftTimerRef.current[agentType];
updateAgentState(agentType, { draftGraph: draft, designingDraft: false });
};
if (remaining > 0 && since > 0) {
// Update draftGraph now (so data is ready) but keep spinner visible
updateAgentState(agentType, { draftGraph: draft });
designingDraftTimerRef.current[agentType] = setTimeout(() => {
updateAgentState(agentType, { designingDraft: false });
delete designingDraftTimerRef.current[agentType];
}, remaining);
} else {
applyDraft();
}
}
break;
}
case "flowchart_map_updated": {
const mapData = event.data as { map?: Record<string, string[]>; original_draft?: DraftGraphData } | undefined;
if (mapData) {
updateAgentState(agentType, {
flowchartMap: mapData.map ?? null,
originalDraft: mapData.original_draft ?? null,
draftGraph: null,
});
}
break;
}
case "worker_loaded": {
const workerName = event.data?.worker_name as string | undefined;
const agentPathFromEvent = event.data?.agent_path as string | undefined;
const displayName = formatAgentDisplayName(workerName || baseAgentType(agentType));
// Invalidate cached credential requirements so the modal fetches
// fresh data the next time it opens (the new agent may have
// different credential needs than the previous one).
clearCredentialCache(agentPathFromEvent);
clearCredentialCache(baseAgentType(agentType));
// Update agent state: new display name, reset graph so topology refetch triggers
updateAgentState(agentType, {
displayName,
queenBuilding: false,
workerRunState: "idle",
graphId: null,
nodeSpecs: [],
});
// Update ONLY the active session's label + graph nodes — never touch
// sessions belonging to a different tab sharing the same agentType key.
// Also clear worker messages so the fresh worker starts with a clean slate.
const activeId = activeSessionRef.current[agentType];
setSessionsByAgent(prev => ({
...prev,
[agentType]: (prev[agentType] || []).map(s =>
s.id === activeId || (!activeId && prev[agentType]?.[0]?.id === s.id)
? { ...s, label: displayName, graphNodes: [], messages: s.messages.filter(m => m.role !== "worker") }
: s
),
}));
// Explicitly fetch graph topology for the newly loaded worker
// (don't rely solely on the effect — state may already be null/empty)
const sessionId = agentStates[agentType]?.sessionId;
if (sessionId) {
fetchGraphForAgent(agentType, sessionId);
}
break;
}
case "trigger_activated": {
const triggerId = event.data?.trigger_id as string;
if (triggerId) {
const nodeId = `__trigger_${triggerId}`;
// If the trigger node doesn't exist yet (dynamically created via set_trigger),
// synthesize it before updating status.
setSessionsByAgent(prev => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map(s => {
if (s.id !== activeId) return s;
const exists = s.graphNodes.some(n => n.id === nodeId);
if (exists) {
return {
...s,
graphNodes: s.graphNodes.map(n =>
n.id === nodeId ? { ...n, status: "running" as const } : n,
),
};
}
// Synthesize new trigger node at the front of the graph
const triggerType = (event.data?.trigger_type as string) || "timer";
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
const entryNode = (event.data?.entry_node as string) || s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
const triggerName = (event.data?.name as string) || triggerId;
const _cron = triggerConfig.cron as string | undefined;
const _interval = triggerConfig.interval_minutes as number | undefined;
const computedLabel = _cron
? cronToLabel(_cron)
: _interval
? `Every ${_interval >= 60 ? `${_interval / 60}h` : `${_interval}m`}`
: triggerName;
const newNode: GraphNode = {
id: nodeId,
label: computedLabel,
status: "running",
nodeType: "trigger",
triggerType,
triggerConfig,
...(entryNode ? { next: [entryNode] } : {}),
};
return { ...s, graphNodes: [newNode, ...s.graphNodes] };
}),
};
});
}
break;
}
case "trigger_deactivated": {
const triggerId = event.data?.trigger_id as string;
if (triggerId) {
// Clear next_fire_in so countdown hides when inactive
setSessionsByAgent(prev => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map(s => {
if (s.id !== activeId) return s;
return {
...s,
graphNodes: s.graphNodes.map(n => {
if (n.id !== `__trigger_${triggerId}`) return n;
const { next_fire_in: _, ...restConfig } = (n.triggerConfig || {}) as Record<string, unknown> & { next_fire_in?: unknown };
return { ...n, status: "pending" as const, triggerConfig: restConfig };
}),
};
}),
};
});
}
break;
}
case "trigger_fired": {
const triggerId = event.data?.trigger_id as string;
if (triggerId) {
const nodeId = `__trigger_${triggerId}`;
updateGraphNodeStatus(agentType, nodeId, "complete");
setTimeout(() => updateGraphNodeStatus(agentType, nodeId, "running"), 1500);
}
break;
}
case "trigger_available": {
const triggerId = event.data?.trigger_id as string;
if (triggerId) {
const nodeId = `__trigger_${triggerId}`;
setSessionsByAgent(prev => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map(s => {
if (s.id !== activeId) return s;
if (s.graphNodes.some(n => n.id === nodeId)) return s;
const triggerType = (event.data?.trigger_type as string) || "timer";
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
const entryNode = (event.data?.entry_node as string) || s.graphNodes.find(n => n.nodeType !== "trigger")?.id;
const triggerName = (event.data?.name as string) || triggerId;
const _cron2 = triggerConfig.cron as string | undefined;
const _interval2 = triggerConfig.interval_minutes as number | undefined;
const computedLabel2 = _cron2
? cronToLabel(_cron2)
: _interval2
? `Every ${_interval2 >= 60 ? `${_interval2 / 60}h` : `${_interval2}m`}`
: triggerName;
const newNode: GraphNode = {
id: nodeId,
label: computedLabel2,
status: "pending",
nodeType: "trigger",
triggerType,
triggerConfig,
...(entryNode ? { next: [entryNode] } : {}),
};
return { ...s, graphNodes: [newNode, ...s.graphNodes] };
}),
};
});
}
break;
}
case "trigger_updated": {
const triggerId = event.data?.trigger_id as string;
if (triggerId) {
const nodeId = `__trigger_${triggerId}`;
const triggerConfig = (event.data?.trigger_config as Record<string, unknown>) || {};
const cron = triggerConfig.cron as string | undefined;
const interval = triggerConfig.interval_minutes as number | undefined;
const newLabel = cron
? cronToLabel(cron)
: interval
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
: undefined;
setSessionsByAgent(prev => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map(s => {
if (s.id !== activeId) return s;
return {
...s,
graphNodes: s.graphNodes.map(n => {
if (n.id !== nodeId) return n;
return {
...n,
...(newLabel ? { label: newLabel } : {}),
triggerConfig: { ...n.triggerConfig, ...triggerConfig },
};
}),
};
}),
};
});
}
break;
}
case "trigger_removed": {
const triggerId = event.data?.trigger_id as string;
if (triggerId) {
const nodeId = `__trigger_${triggerId}`;
setSessionsByAgent(prev => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map(s => {
if (s.id !== activeId) return s;
return { ...s, graphNodes: s.graphNodes.filter(n => n.id !== nodeId) };
}),
};
});
}
break;
}
default:
// Fallback: ensure queenReady is set even for unexpected first events
if (shouldMarkQueenReady) updateAgentState(agentType, { queenReady: true });
break;
}
},
[agentStates, updateAgentState, updateGraphNodeStatus, markAllNodesAs, upsertChatMessage, appendNodeLog, fetchGraphForAgent],
);
// --- Multi-session SSE subscription ---
const sseSessions = useMemo(() => {
const map: Record<string, string> = {};
for (const [agentType, state] of Object.entries(agentStates)) {
if (state.sessionId && state.ready) {
map[agentType] = state.sessionId;
}
}
return map;
}, [agentStates]);
useMultiSSE({ sessions: sseSessions, onEvent: handleSSEEvent });
const currentSessions = sessionsByAgent[activeWorker] || [];
const activeSessionId = activeSessionByAgent[activeWorker] || currentSessions[0]?.id;
const activeSession = currentSessions.find(s => s.id === activeSessionId) || currentSessions[0];
const currentGraph = activeSession
? { nodes: activeSession.graphNodes, title: activeAgentState?.displayName || formatAgentDisplayName(baseAgentType(activeWorker)) }
: { nodes: [] as GraphNode[], title: "" };
// Keep selectedNode in sync with live graphNodes (trigger status updates via SSE)
const liveSelectedNode = selectedNode && currentGraph.nodes.find(n => n.id === selectedNode.id);
const resolvedSelectedNode = liveSelectedNode || selectedNode;
// Sync trigger drafts when selected trigger node changes
useEffect(() => {
if (resolvedSelectedNode?.nodeType === "trigger") {
const tc = resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined;
setTriggerTaskDraft((tc?.task as string) || "");
setTriggerCronDraft((tc?.cron as string) || "");
}
}, [resolvedSelectedNode?.id]);
const patchTriggerNode = useCallback((agentType: string, triggerNodeId: string, patch: { task?: string; trigger_config?: Record<string, unknown>; label?: string }) => {
setSessionsByAgent(prev => {
const sessions = prev[agentType] || [];
const activeId = activeSessionRef.current[agentType] || sessions[0]?.id;
return {
...prev,
[agentType]: sessions.map(s => {
if (s.id !== activeId) return s;
return {
...s,
graphNodes: s.graphNodes.map(n => {
if (n.id !== triggerNodeId) return n;
return {
...n,
...(patch.label !== undefined ? { label: patch.label } : {}),
triggerConfig: {
...n.triggerConfig,
...(patch.trigger_config || {}),
...(patch.task !== undefined ? { task: patch.task } : {}),
},
};
}),
};
}),
};
});
}, []);
// Build a flat list of all agent-type tabs for the tab bar
const agentTabs = Object.entries(sessionsByAgent)
.filter(([, sessions]) => sessions.length > 0)
.map(([agentType, sessions]) => {
const activeId = activeSessionByAgent[agentType] || sessions[0]?.id;
const session = sessions.find(s => s.id === activeId) || sessions[0];
return {
agentType,
sessionId: session.id,
label: session.label,
isActive: agentType === activeWorker,
hasRunning: session.graphNodes.some(n => n.status === "running" || n.status === "looping"),
};
});
// --- handleSend ---
const handleSend = useCallback((text: string, thread: string) => {
if (!activeSession) return;
const state = agentStates[activeWorker];
if (!allRequiredCredentialsMet(activeSession.credentials)) {
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: text, timestamp: "", type: "user", thread, createdAt: Date.now(),
};
const promptMsg: ChatMessage = {
id: makeId(), agent: "Queen Bee", agentColor: "",
content: "Before we get started, you'll need to configure your credentials. Click the **Credentials** button in the top bar to connect the required integrations for this agent.",
timestamp: "", role: "queen" as const, thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg, promptMsg] } : s
),
}));
return;
}
// If worker is awaiting free-text input (no options / no QuestionWidget),
// route the message directly to the worker instead of the queen.
if (agentStates[activeWorker]?.awaitingInput && agentStates[activeWorker]?.pendingQuestionSource === "worker" && !agentStates[activeWorker]?.pendingOptions) {
const state = agentStates[activeWorker];
if (state?.sessionId && state?.ready) {
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: text, timestamp: "", type: "user", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
updateAgentState(activeWorker, { awaitingInput: false, workerInputMessageId: null, isTyping: true, pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
executionApi.workerInput(state.sessionId, text).catch((err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
const errorChatMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to send to worker: ${errMsg}`,
timestamp: "", type: "system", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, errorChatMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: false, isStreaming: false });
});
}
return;
}
// If queen has a pending question widget, dismiss it when user types directly
if (agentStates[activeWorker]?.pendingQuestionSource === "queen") {
updateAgentState(activeWorker, { pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
}
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: text, timestamp: "", type: "user", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
suppressIntroRef.current.delete(activeWorker);
updateAgentState(activeWorker, { isTyping: true, queenIsTyping: true });
if (state?.sessionId && state?.ready) {
executionApi.chat(state.sessionId, text).catch((err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
const errorChatMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to send message: ${errMsg}`,
timestamp: "", type: "system", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, errorChatMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: false, isStreaming: false, queenIsTyping: false });
});
} else {
const errorMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: "Cannot send message: backend is not connected. Please wait for the agent to load.",
timestamp: "", type: "system", thread, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, errorMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: false, isStreaming: false });
}
}, [activeWorker, activeSession, agentStates, updateAgentState]);
// --- handleWorkerReply: send user input to the worker via dedicated endpoint ---
const handleWorkerReply = useCallback((text: string) => {
if (!activeSession) return;
const state = agentStates[activeWorker];
if (!state?.sessionId || !state?.ready) return;
// Add user reply to chat thread
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: text, timestamp: "", type: "user", thread: activeWorker, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
// Clear awaiting state optimistically
updateAgentState(activeWorker, { awaitingInput: false, workerInputMessageId: null, isTyping: true, pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
executionApi.workerInput(state.sessionId, text).catch((err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
const errorChatMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to send to worker: ${errMsg}`,
timestamp: "", type: "system", thread: activeWorker, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, errorChatMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: false, isStreaming: false });
});
}, [activeWorker, activeSession, agentStates, updateAgentState]);
// --- handleWorkerQuestionAnswer: route predefined answers direct to worker, "Other" through queen ---
const handleWorkerQuestionAnswer = useCallback((answer: string, isOther: boolean) => {
if (!activeSession) return;
const state = agentStates[activeWorker];
const question = state?.pendingQuestion || "";
const opts = state?.pendingOptions;
if (isOther) {
// "Other" free-text → route through queen for evaluation
updateAgentState(activeWorker, { pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
if (question && opts && state?.sessionId && state?.ready) {
const formatted = `[Worker asked: "${question}" | Options: ${opts.join(", ")}]\nUser answered: "${answer}"`;
const userMsg: ChatMessage = {
id: makeId(), agent: "You", agentColor: "",
content: answer, timestamp: "", type: "user", thread: activeWorker, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, userMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: true, queenIsTyping: true });
executionApi.chat(state.sessionId, formatted).catch((err: unknown) => {
const errMsg = err instanceof Error ? err.message : String(err);
const errorChatMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to send message: ${errMsg}`,
timestamp: "", type: "system", thread: activeWorker, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id ? { ...s, messages: [...s.messages, errorChatMsg] } : s
),
}));
updateAgentState(activeWorker, { isTyping: false, isStreaming: false, queenIsTyping: false });
});
} else {
handleSend(answer, activeWorker);
}
} else {
// Predefined option → send directly to worker
handleWorkerReply(answer);
// Queue context for queen (fire-and-forget, no LLM response triggered)
if (question && state?.sessionId && state?.ready) {
const notification = `[Worker asked: "${question}" | User selected: "${answer}"]`;
executionApi.queenContext(state.sessionId, notification).catch(() => { });
}
}
}, [activeWorker, activeSession, agentStates, handleWorkerReply, handleSend, updateAgentState, setSessionsByAgent]);
// --- handleQueenQuestionAnswer: submit queen's own question answer via /chat ---
// The queen asked the question herself, so she already has context — just send the raw answer.
const handleQueenQuestionAnswer = useCallback((answer: string, _isOther: boolean) => {
updateAgentState(activeWorker, { pendingQuestion: null, pendingOptions: null, pendingQuestions: null, pendingQuestionSource: null });
handleSend(answer, activeWorker);
}, [activeWorker, handleSend, updateAgentState]);
// --- handleMultiQuestionAnswer: submit answers to ask_user_multiple ---
const handleMultiQuestionAnswer = useCallback((answers: Record<string, string>) => {
updateAgentState(activeWorker, {
pendingQuestion: null, pendingOptions: null,
pendingQuestions: null, pendingQuestionSource: null,
});
// Format as structured text the LLM can parse
const lines = Object.entries(answers).map(
([id, answer]) => `[${id}]: ${answer}`,
);
handleSend(lines.join("\n"), activeWorker);
}, [activeWorker, handleSend, updateAgentState]);
// --- handleQuestionDismiss: user closed the question widget without answering ---
// Injects a dismiss signal so the blocked node can continue.
const handleQuestionDismiss = useCallback(() => {
const state = agentStates[activeWorker];
if (!state?.sessionId) return;
const source = state.pendingQuestionSource;
const question = state.pendingQuestion || "";
// Clear UI state immediately
updateAgentState(activeWorker, {
pendingQuestion: null,
pendingOptions: null,
pendingQuestions: null,
pendingQuestionSource: null,
awaitingInput: false,
});
// Unblock the waiting node with a dismiss signal
const dismissMsg = `[User dismissed the question: "${question}"]`;
if (source === "worker") {
executionApi.workerInput(state.sessionId, dismissMsg).catch(() => { });
} else {
executionApi.chat(state.sessionId, dismissMsg).catch(() => { });
}
}, [agentStates, activeWorker, updateAgentState]);
const handleLoadAgent = useCallback(async (agentPath: string) => {
const state = agentStates[activeWorker];
if (!state?.sessionId) return;
try {
await sessionsApi.loadWorker(state.sessionId, agentPath);
// Success: worker_loaded SSE event will handle UI updates automatically
} catch (err) {
// 424 = credentials required — open the credentials modal
if (err instanceof ApiError && err.status === 424) {
const body = err.body as Record<string, unknown>;
setCredentialAgentPath((body.agent_path as string) || null);
setCredentialsOpen(true);
return;
}
const errMsg = err instanceof Error ? err.message : String(err);
const activeId = activeSessionRef.current[activeWorker];
const errorMsg: ChatMessage = {
id: makeId(), agent: "System", agentColor: "",
content: `Failed to load agent: ${errMsg}`,
timestamp: "", type: "system", thread: activeWorker, createdAt: Date.now(),
};
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: (prev[activeWorker] || []).map(s =>
s.id === activeId ? { ...s, messages: [...s.messages, errorMsg] } : s
),
}));
}
}, [activeWorker, agentStates]);
void handleLoadAgent; // Used by load-agent modal (wired dynamically)
const closeAgentTab = useCallback((agentType: string) => {
setSelectedNode(null);
// Pause worker execution if running (saves checkpoint), then kill the
// entire backend session so the queen doesn't keep running.
const state = agentStates[agentType];
if (state?.sessionId) {
const pausePromise = (state.currentExecutionId && state.workerRunState === "running")
? executionApi.pause(state.sessionId, state.currentExecutionId)
: Promise.resolve();
pausePromise
.catch(() => { }) // pause failure shouldn't block kill
.then(() => sessionsApi.stop(state.sessionId!))
.catch(() => { }); // fire-and-forget
}
const allTypes = Object.keys(sessionsByAgent).filter(k => (sessionsByAgent[k] || []).length > 0);
const remaining = allTypes.filter(k => k !== agentType);
setSessionsByAgent(prev => {
const next = { ...prev };
delete next[agentType];
return next;
});
setActiveSessionByAgent(prev => {
const next = { ...prev };
delete next[agentType];
return next;
});
// Remove per-agent backend state (SSE connection closes automatically)
setAgentStates(prev => {
const next = { ...prev };
delete next[agentType];
return next;
});
if (remaining.length === 0) {
navigate("/");
} else if (activeWorker === agentType) {
setActiveWorker(remaining[0]);
}
}, [sessionsByAgent, activeWorker, navigate, agentStates]);
// Open a tab for an agent type. If a tab already exists, switch to it
// instead of creating a duplicate — each agent gets one session.
// Exception: "new-agent" tabs always create a new instance since each
// represents a distinct conversation the user is starting from scratch.
const addAgentSession = useCallback((agentType: string, agentLabel?: string) => {
const isNewAgent = agentType === "new-agent" || agentType.startsWith("new-agent-");
if (!isNewAgent) {
const existingTabKey = Object.keys(sessionsByAgent).find(
k => baseAgentType(k) === agentType && (sessionsByAgent[k] || []).length > 0,
);
if (existingTabKey) {
setActiveWorker(existingTabKey);
const existing = sessionsByAgent[existingTabKey]?.[0];
if (existing) {
setActiveSessionByAgent(prev => ({ ...prev, [existingTabKey]: existing.id }));
}
return;
}
}
const tabKey = isNewAgent ? `new-agent-${makeId()}` : agentType;
const existingNewAgentCount = isNewAgent
? Object.keys(sessionsByAgent).filter(
k => (k === "new-agent" || k.startsWith("new-agent-")) && (sessionsByAgent[k] || []).length > 0
).length
: 0;
const rawLabel = agentLabel || (isNewAgent ? "New Agent" : formatAgentDisplayName(agentType));
const displayLabel = existingNewAgentCount === 0 ? rawLabel : `${rawLabel} #${existingNewAgentCount + 1}`;
const newSession = createSession(tabKey, displayLabel);
setSessionsByAgent(prev => ({
...prev,
[tabKey]: [newSession],
}));
setActiveSessionByAgent(prev => ({ ...prev, [tabKey]: newSession.id }));
setActiveWorker(tabKey);
}, [sessionsByAgent]);
// Open a history session: switch to its existing tab, or open a new tab.
// Async so we can pre-fetch messages before creating the tab — this gives
// instant visual feedback without waiting for loadAgentForType.
const handleHistoryOpen = useCallback(async (sessionId: string, agentPath?: string | null, agentName?: string | null) => {
// Already open as a tab — just switch to it.
for (const [type, sessions] of Object.entries(sessionsByAgent)) {
for (const s of sessions) {
if (s.backendSessionId === sessionId || s.historySourceId === sessionId) {
setActiveWorker(type);
setActiveSessionByAgent(prev => ({ ...prev, [type]: s.id }));
if (s.messages.length > 0) {
suppressIntroRef.current.add(type);
}
return;
}
}
}
// Pre-fetch messages from disk so the tab opens with conversation already shown.
// Prefer the persisted event log for full UI reconstruction; fall back to parts.
let prefetchedMessages: ChatMessage[] = [];
try {
const resolvedType = agentPath || "new-agent";
const displayNameTemp = agentName || formatAgentDisplayName(resolvedType);
const restored = await restoreSessionMessages(sessionId, resolvedType, displayNameTemp);
prefetchedMessages = restored.messages;
if (prefetchedMessages.length > 0) {
prefetchedMessages.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
}
} catch {
// Not available — session will open empty and loadAgentForType will try again
}
const resolvedAgentType = agentPath || "new-agent";
const existingTabCount = Object.keys(sessionsByAgent).filter(
k => baseAgentType(k) === resolvedAgentType && (sessionsByAgent[k] || []).length > 0
).length;
const rawLabel = agentName ||
(agentPath ? agentPath.replace(/\/$/, "").split("/").pop()?.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) || agentPath : null) ||
"New Agent";
const label = existingTabCount === 0 ? rawLabel : `${rawLabel} #${existingTabCount + 1}`;
const newSession = createSession(resolvedAgentType, label);
newSession.backendSessionId = sessionId;
newSession.historySourceId = sessionId;
// Pre-populate messages so the chat panel immediately shows the conversation
if (prefetchedMessages.length > 0) {
newSession.messages = prefetchedMessages;
}
const tabKey = existingTabCount === 0 ? resolvedAgentType : `${resolvedAgentType}::${newSession.id}`;
if (tabKey !== resolvedAgentType) newSession.tabKey = tabKey;
// Suppress queen intro BEFORE the tab is created so loadAgentForType
// never sees an unsuppressed window — the user never expects a greeting on reopen.
if (prefetchedMessages.length > 0 || sessionId) {
suppressIntroRef.current.add(tabKey);
}
setSessionsByAgent(prev => ({ ...prev, [tabKey]: [newSession] }));
setActiveSessionByAgent(prev => ({ ...prev, [tabKey]: newSession.id }));
setActiveWorker(tabKey);
}, [sessionsByAgent]);
// Post-mount: open the session from the URL ?session= param via handleHistoryOpen.
// This runs AFTER persisted tabs are hydrated, so dedup works correctly.
// Use a ref guard so it fires exactly once even in React StrictMode.
useEffect(() => {
if (mountedRef.current) return;
mountedRef.current = true;
const sid = initialSessionIdRef.current;
if (!sid) return;
// Fetch agent metadata from the backend so handleHistoryOpen gets the right
// agentPath and agentName (needed to label the tab correctly).
sessionsApi.history().then(r => {
const match = r.sessions.find((s: { session_id: string }) => s.session_id === sid);
handleHistoryOpen(
sid,
match?.agent_path ?? initialAgentRef.current !== "new-agent" ? initialAgentRef.current : null,
match?.agent_name ?? null,
);
}).catch(() => {
// History fetch failed — still open the session with what we know.
handleHistoryOpen(
sid,
initialAgentRef.current !== "new-agent" ? initialAgentRef.current : null,
null,
);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const activeWorkerLabel = activeAgentState?.displayName || formatAgentDisplayName(baseAgentType(activeWorker));
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
<TopBar
tabs={agentTabs}
onTabClick={(agentType) => {
const tab = agentTabs.find(t => t.agentType === agentType);
if (tab) {
setActiveWorker(agentType);
setActiveSessionByAgent(prev => ({ ...prev, [agentType]: tab.sessionId }));
setSelectedNode(null);
}
}}
onCloseTab={closeAgentTab}
afterTabs={
<>
<button
ref={newTabBtnRef}
onClick={() => setNewTabOpen(o => !o)}
className="flex-shrink-0 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
title="Add tab"
>
<Plus className="w-3.5 h-3.5" />
</button>
<NewTabPopover
open={newTabOpen}
onClose={() => setNewTabOpen(false)}
anchorRef={newTabBtnRef}
activeWorker={activeWorker}
discoverAgents={discoverAgents}
onFromScratch={() => { addAgentSession("new-agent"); }}
onCloneAgent={(agentPath, agentName) => { addAgentSession(agentPath, agentName); }}
/>
</>
}
>
<button
onClick={() => setCredentialsOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
>
<KeyRound className="w-3.5 h-3.5" />
Credentials
</button>
</TopBar>
{/* Main content area */}
<div className="flex flex-1 min-h-0">
{/* ── Draft flowchart + chat ─────────────────────────────────── */}
<div
className="bg-card/30 flex flex-col border-r border-border/30 relative"
style={{ width: `${graphPanelPct}%`, minWidth: 240, flexShrink: 0 }}
>
<div className="flex-1 min-h-0">
<DraftGraph
key={activeWorker}
draft={activeAgentState?.originalDraft ?? activeAgentState?.draftGraph ?? null}
originalDraft={activeAgentState?.originalDraft ?? null}
loadingMessage={
activeAgentState?.designingDraft
? "Designing flowchart…"
: !activeAgentState?.originalDraft && !activeAgentState?.draftGraph && activeAgentState?.queenPhase !== "planning"
? "Loading flowchart…"
: null
}
building={activeAgentState?.queenBuilding}
onRun={handleRun}
onPause={handlePause}
runState={activeAgentState?.workerRunState ?? "idle"}
flowchartMap={activeAgentState?.flowchartMap ?? undefined}
runtimeNodes={currentGraph.nodes}
onRuntimeNodeClick={(runtimeNodeId) => {
const node = currentGraph.nodes.find(n => n.id === runtimeNodeId);
if (node) setSelectedNode(prev => prev?.id === node.id ? null : node);
}}
/>
</div>
{/* Resize handle */}
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/40 transition-colors z-10"
onMouseDown={() => { resizing.current = true; document.body.style.cursor = "col-resize"; }}
/>
</div>
<div className="flex-1 min-w-0 flex">
<div className="flex-1 min-w-0 relative">
{/* Loading overlay */}
{activeAgentState?.loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Connecting to agent...</span>
</div>
</div>
)}
{/* Queen connecting overlay — agent loaded but queen not yet alive */}
{!activeAgentState?.loading && activeAgentState?.ready && !activeAgentState?.queenReady && (
<div className="absolute top-0 left-0 right-0 z-10 px-4 py-2 bg-background border-b border-primary/20 flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary/60" />
<span className="text-xs text-primary/80">Connecting to queen...</span>
</div>
)}
{/* Connection error banner */}
{activeAgentState?.error && !activeAgentState?.loading && dismissedBanner !== activeAgentState.error && (
activeAgentState.error === "credentials_required" ? (
<div className="absolute top-0 left-0 right-0 z-10 px-4 py-2 bg-background border-b border-amber-500/30 flex items-center gap-2">
<KeyRound className="w-4 h-4 text-amber-600" />
<span className="text-xs text-amber-700">Missing credentials configure them to continue</span>
<button
onClick={() => setCredentialsOpen(true)}
className="ml-auto text-xs font-medium text-primary hover:underline"
>
Open Credentials
</button>
<button
onClick={() => setDismissedBanner(activeAgentState.error!)}
className="p-0.5 rounded text-amber-600 hover:text-amber-800 hover:bg-amber-500/20 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<div className="absolute top-0 left-0 right-0 z-10 px-4 py-2 bg-background border-b border-destructive/30 flex items-center gap-2">
<WifiOff className="w-4 h-4 text-destructive" />
<span className="text-xs text-destructive">Backend unavailable: {activeAgentState.error}</span>
<button
onClick={() => setDismissedBanner(activeAgentState.error!)}
className="ml-auto p-0.5 rounded text-destructive hover:text-destructive hover:bg-destructive/20 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)
)}
{activeSession && (
<ChatPanel
messages={activeSession.messages}
onSend={handleSend}
onCancel={handleCancelQueen}
activeThread={activeWorker}
isWaiting={(activeAgentState?.queenIsTyping && !activeAgentState?.isStreaming) ?? false}
isWorkerWaiting={(activeAgentState?.workerIsTyping && !activeAgentState?.isStreaming) ?? false}
isBusy={activeAgentState?.queenIsTyping ?? false}
disabled={
(activeAgentState?.loading ?? true) ||
!(activeAgentState?.queenReady)
}
queenPhase={activeAgentState?.queenPhase ?? "building"}
pendingQuestion={activeAgentState?.awaitingInput ? activeAgentState.pendingQuestion : null}
pendingOptions={activeAgentState?.awaitingInput ? activeAgentState.pendingOptions : null}
pendingQuestions={activeAgentState?.awaitingInput ? activeAgentState.pendingQuestions : null}
onQuestionSubmit={
activeAgentState?.pendingQuestionSource === "queen"
? handleQueenQuestionAnswer
: handleWorkerQuestionAnswer
}
onMultiQuestionSubmit={handleMultiQuestionAnswer}
onQuestionDismiss={handleQuestionDismiss}
contextUsage={activeAgentState?.contextUsage}
/>
)}
</div>
{resolvedSelectedNode && (
<div className="w-[480px] min-w-[400px] flex-shrink-0">
{resolvedSelectedNode.nodeType === "trigger" ? (
<div className="flex flex-col h-full border-l border-border/40 bg-card/20 animate-in slide-in-from-right">
<div className="px-4 pt-4 pb-3 border-b border-border/30 flex items-start justify-between gap-2">
<div className="flex items-start gap-3 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 bg-[hsl(210,40%,55%)]/15 border border-[hsl(210,40%,55%)]/25">
<span className="text-sm" style={{ color: "hsl(210,40%,55%)" }}>
{{ "webhook": "\u26A1", "timer": "\u23F1", "api": "\u2192", "event": "\u223F" }[resolvedSelectedNode.triggerType || ""] || "\u26A1"}
</span>
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground leading-tight">{resolvedSelectedNode.label}</h3>
<p className="text-[11px] text-muted-foreground mt-0.5 capitalize flex items-center gap-1.5">
{resolvedSelectedNode.triggerType} trigger
<span className={`inline-block w-1.5 h-1.5 rounded-full ${
resolvedSelectedNode.status === "running" || resolvedSelectedNode.status === "complete"
? "bg-emerald-400" : "bg-muted-foreground/40"
}`} />
<span className={`text-[10px] ${
resolvedSelectedNode.status === "running" || resolvedSelectedNode.status === "complete"
? "text-emerald-400" : "text-muted-foreground/60"
}`}>
{resolvedSelectedNode.status === "running" || resolvedSelectedNode.status === "complete" ? "active" : "inactive"}
</span>
</p>
</div>
</div>
<button onClick={() => setSelectedNode(null)} className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-4 flex flex-col gap-3">
{(() => {
const tc = resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined;
const cron = tc?.cron as string | undefined;
const interval = tc?.interval_minutes as number | undefined;
const eventTypes = tc?.event_types as string[] | undefined;
const scheduleLabel = cron
? cronToLabel(cron)
: interval
? `Every ${interval >= 60 ? `${interval / 60}h` : `${interval}m`}`
: eventTypes?.length
? eventTypes.join(", ")
: null;
const canEditCron = resolvedSelectedNode.triggerType === "timer";
const cronChanged = canEditCron && triggerCronDraft.trim() !== (cron || "");
return scheduleLabel || canEditCron ? (
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Schedule</p>
{scheduleLabel && (
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
{scheduleLabel}
</p>
)}
{canEditCron && (
<>
<input
value={triggerCronDraft}
onChange={(e) => setTriggerCronDraft(e.target.value)}
placeholder="0 5 * * *"
className="mt-1.5 w-full text-xs text-foreground/80 bg-muted/30 rounded-lg px-3 py-2 border border-border/20 font-mono focus:outline-none focus:border-primary/40"
/>
<p className="text-[10px] text-muted-foreground/60 mt-1">
Edit the cron expression for this timer trigger.
</p>
{(cronChanged || triggerCronSaved) && (
<button
disabled={triggerScheduleSaving || !cronChanged}
onClick={async () => {
const sessionId = activeAgentState?.sessionId;
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
const nextCron = triggerCronDraft.trim();
if (!sessionId || !nextCron) return;
const nextTriggerConfig: Record<string, unknown> = { cron: nextCron };
setTriggerScheduleSaving(true);
try {
await sessionsApi.updateTrigger(sessionId, triggerId, {
trigger_config: nextTriggerConfig,
});
patchTriggerNode(activeWorker, resolvedSelectedNode.id, {
trigger_config: nextTriggerConfig,
label: cronToLabel(nextCron),
});
setTriggerCronSaved(true);
setTimeout(() => setTriggerCronSaved(false), 2000);
} finally {
setTriggerScheduleSaving(false);
}
}}
className="mt-1.5 w-full text-[11px] px-3 py-1.5 rounded-lg border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
>
{triggerScheduleSaving ? "Saving..." : triggerCronSaved ? "Saved" : "Save Cron"}
</button>
)}
</>
)}
</div>
) : null;
})()}
{(() => {
const nfi = (resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined)?.next_fire_in as number | undefined;
return nfi != null ? (
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Next run</p>
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
<TimerCountdown initialSeconds={nfi} />
</p>
</div>
) : null;
})()}
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Task</p>
<textarea
value={triggerTaskDraft}
onChange={(e) => setTriggerTaskDraft(e.target.value)}
placeholder="Describe what the worker should do when this trigger fires..."
className="w-full text-xs text-foreground/80 bg-muted/30 rounded-lg px-3 py-2 border border-border/20 resize-none min-h-[60px] font-mono focus:outline-none focus:border-primary/40"
rows={3}
/>
{(() => {
const currentTask = (resolvedSelectedNode.triggerConfig as Record<string, unknown> | undefined)?.task as string || "";
const hasChanged = triggerTaskDraft !== currentTask;
if (!hasChanged && !triggerTaskSaved) return null;
return (
<button
disabled={triggerTaskSaving || !hasChanged}
onClick={async () => {
const sessionId = activeAgentState?.sessionId;
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
if (!sessionId) return;
setTriggerTaskSaving(true);
try {
await sessionsApi.updateTrigger(sessionId, triggerId, { task: triggerTaskDraft });
patchTriggerNode(activeWorker, resolvedSelectedNode.id, { task: triggerTaskDraft });
setTriggerTaskSaved(true);
setTimeout(() => setTriggerTaskSaved(false), 2000);
} finally {
setTriggerTaskSaving(false);
}
}}
className="mt-1.5 w-full text-[11px] px-3 py-1.5 rounded-lg border border-primary/30 text-primary hover:bg-primary/10 transition-colors disabled:opacity-50"
>
{triggerTaskSaving ? "Saving..." : triggerTaskSaved ? "Saved" : "Save Task"}
</button>
);
})()}
{!triggerTaskDraft && (
<p className="text-[10px] text-amber-400/80 mt-1">A task is required before enabling this trigger.</p>
)}
</div>
<div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Fires into</p>
<p className="text-xs text-foreground/80 font-mono bg-muted/30 rounded-lg px-3 py-2 border border-border/20">
{resolvedSelectedNode.next?.[0]?.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ") || "—"}
</p>
</div>
{activeAgentState?.queenPhase !== "building" && (() => {
const triggerIsActive = resolvedSelectedNode.status === "running" || resolvedSelectedNode.status === "complete";
const triggerId = resolvedSelectedNode.id.replace("__trigger_", "");
const taskMissing = !triggerTaskDraft;
return (
<div className="pt-1">
<button
disabled={!triggerIsActive && taskMissing}
onClick={async () => {
const sessionId = activeAgentState?.sessionId;
if (!sessionId) return;
const action = triggerIsActive ? "Disable" : "Enable";
await executionApi.chat(sessionId, `${action} trigger ${triggerId}`);
}}
className={`w-full text-xs px-3 py-2 rounded-lg border transition-colors ${
triggerIsActive
? "border-red-500/30 text-red-400 hover:bg-red-500/10"
: taskMissing
? "border-border/30 text-muted-foreground/40 cursor-not-allowed"
: "border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/10"
}`}
>
{triggerIsActive ? "Disable Trigger" : "Enable Trigger"}
</button>
{!triggerIsActive && taskMissing && (
<p className="text-[10px] text-muted-foreground/50 mt-1 text-center">Configure a task first</p>
)}
</div>
);
})()}
</div>
</div>
) : (
<NodeDetailPanel
node={resolvedSelectedNode}
nodeSpec={activeAgentState?.nodeSpecs.find(n => n.id === resolvedSelectedNode.id) ?? null}
allNodeSpecs={activeAgentState?.nodeSpecs}
subagentReports={activeAgentState?.subagentReports}
sessionId={activeAgentState?.sessionId || undefined}
graphId={activeAgentState?.graphId || undefined}
workerSessionId={null}
nodeLogs={activeAgentState?.nodeLogs[resolvedSelectedNode.id] || []}
actionPlan={activeAgentState?.nodeActionPlans[resolvedSelectedNode.id]}
contextUsage={activeAgentState?.contextUsage[resolvedSelectedNode.id]}
onClose={() => setSelectedNode(null)}
/>
)}
</div>
)}
</div>
</div>
<CredentialsModal
agentType={activeWorker}
agentLabel={activeWorkerLabel}
agentPath={credentialAgentPath || activeAgentState?.agentPath || (!activeWorker.startsWith("new-agent") ? activeWorker : undefined)}
open={credentialsOpen}
onClose={() => {
setCredentialsOpen(false);
setCredentialAgentPath(null);
// Keep credentials_required error set — clearing it here triggers
// the auto-load effect which retries session creation immediately,
// causing an infinite modal loop when credentials are still missing.
// The error is only cleared in onCredentialChange (below) when the
// user actually saves valid credentials.
}}
credentials={activeSession?.credentials || []}
onCredentialChange={() => {
// Clear credential error so the auto-load effect retries session creation
if (agentStates[activeWorker]?.error === "credentials_required") {
updateAgentState(activeWorker, { error: null });
}
if (!activeSession) return;
setSessionsByAgent(prev => ({
...prev,
[activeWorker]: prev[activeWorker].map(s =>
s.id === activeSession.id
? { ...s, credentials: s.credentials.map(c => ({ ...c, connected: true })) }
: s
),
}));
}}
/>
</div>
);
}