3469 lines
157 KiB
TypeScript
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>
|
|
);
|
|
}
|