Files
fusion/packages/dashboard/app/components/ExperimentalAgentOnboardingModal.tsx
Fusion f586ceca40 feat(FN-3024): add experimental agent onboarding modal with create form han
This merge delivers v0.12.0, which includes a new experimental agent onboarding modal (`ExperimentalAgentOnboardingModal`) that streamlines the handoff from onboarding to the create-agent form, along with backend lifecycle tests and documentation for the experimental flag. The release bumps all work

Fusion-Task-Id: FN-3024
2026-05-01 08:56:10 -07:00

210 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Agent, AgentOnboardingSummary, ConversationHistoryEntry } from "../api";
import {
cancelAgentOnboarding,
connectAgentOnboardingStream,
respondToAgentOnboarding,
startAgentOnboardingStreaming,
} from "../api";
import { AGENT_PRESETS } from "./agent-presets";
import { ConversationHistory } from "./ConversationHistory";
import "./ExperimentalAgentOnboardingModal.css";
type ViewState = "initial" | "loading" | "question" | "summary" | "error";
interface ExperimentalAgentOnboardingModalProps {
isOpen: boolean;
onClose: () => void;
onUseDraft: (summary: AgentOnboardingSummary) => void;
projectId?: string;
existingAgents: Agent[];
}
export function ExperimentalAgentOnboardingModal({
isOpen,
onClose,
onUseDraft,
projectId,
existingAgents,
}: ExperimentalAgentOnboardingModalProps) {
const [viewState, setViewState] = useState<ViewState>("initial");
const [intent, setIntent] = useState("");
const [sessionId, setSessionId] = useState<string | null>(null);
const [currentQuestion, setCurrentQuestion] = useState("");
const [currentQuestionId, setCurrentQuestionId] = useState("answer");
const [answer, setAnswer] = useState("");
const [summary, setSummary] = useState<AgentOnboardingSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<ConversationHistoryEntry[]>([]);
const resetState = useCallback(() => {
setViewState("initial");
setIntent("");
setSessionId(null);
setCurrentQuestion("");
setCurrentQuestionId("answer");
setAnswer("");
setSummary(null);
setError(null);
setHistory([]);
}, []);
const templateOptions = useMemo(
() => AGENT_PRESETS.map((preset) => ({ id: preset.id, label: preset.name, description: preset.description })),
[],
);
useEffect(() => {
if (!sessionId) return;
const stream = connectAgentOnboardingStream(sessionId, projectId, {
onThinking: (data) => {
setHistory((current) => {
const next = [...current];
const last = next[next.length - 1];
if (last && !last.question) {
next[next.length - 1] = { ...last, thinkingOutput: `${last.thinkingOutput ?? ""}${data}` };
return next;
}
return [...next, { response: {}, thinkingOutput: data }];
});
},
onQuestion: (q) => {
setCurrentQuestion(q.question);
setCurrentQuestionId(q.id);
setViewState("question");
},
onSummary: (nextSummary) => {
setSummary(nextSummary);
setViewState("summary");
},
onError: (message) => {
setError(message);
setViewState("error");
},
});
return () => stream.close();
}, [sessionId, projectId]);
const handleClose = async () => {
try {
if (sessionId) {
await cancelAgentOnboarding(sessionId, projectId);
}
} catch {
// Best-effort server-side cleanup; always allow modal dismissal.
} finally {
resetState();
onClose();
}
};
useEffect(() => {
if (!isOpen) {
resetState();
}
}, [isOpen, resetState]);
if (!isOpen) return null;
const start = async () => {
setViewState("loading");
setError(null);
try {
const result = await startAgentOnboardingStreaming(
intent,
{
existingAgents: existingAgents.map((agent) => ({ id: agent.id, name: agent.name, role: agent.role })),
templates: templateOptions,
},
projectId,
);
setSessionId(result.sessionId);
} catch (err) {
setError((err as Error).message);
setViewState("error");
}
};
const submitAnswer = async () => {
if (!sessionId) return;
setViewState("loading");
setError(null);
try {
const responsePayload = { [currentQuestionId]: answer };
setHistory((current) => [
...current,
{
question: { id: currentQuestionId, type: "text", question: currentQuestion },
response: responsePayload,
},
]);
await respondToAgentOnboarding(sessionId, responsePayload, projectId);
setAnswer("");
} catch (err) {
setError((err as Error).message);
setViewState("error");
}
};
return (
<div className="modal-overlay open" role="presentation">
<div className="modal modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="Experimental agent onboarding">
<div className="modal-header">
<h3>Experimental Agent Onboarding</h3>
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
</div>
{history.length > 0 && <ConversationHistory entries={history} />}
{viewState === "initial" && (
<div className="form-group">
<label htmlFor="agent-onboarding-intent">What should this new agent own?</label>
<textarea id="agent-onboarding-intent" className="input experimental-agent-onboarding-modal__textarea" value={intent} onChange={(e) => setIntent(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" disabled={!intent.trim()} onClick={() => void start()}>Start onboarding</button>
</div>
</div>
)}
{(viewState === "loading" || viewState === "question") && (
<div className="form-group">
<label htmlFor="agent-onboarding-answer">{currentQuestion || "Thinking..."}</label>
<textarea id="agent-onboarding-answer" className="input experimental-agent-onboarding-modal__textarea" value={answer} onChange={(e) => setAnswer(e.target.value)} />
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" disabled={viewState === "loading" || !answer.trim()} onClick={() => void submitAnswer()}>Continue</button>
</div>
</div>
)}
{viewState === "summary" && summary && (
<div className="form-group">
<label>Draft ready for review</label>
<div className="experimental-agent-onboarding-modal__summary card">
<p><strong>Name:</strong> {summary.name}</p>
<p><strong>Role:</strong> {summary.role}</p>
{summary.templateId && <p><strong>Template:</strong> {summary.templateId}</p>}
{summary.patternAgentId && <p><strong>Pattern agent:</strong> {summary.patternAgentId}</p>}
{summary.rationale && <p><strong>Why:</strong> {summary.rationale}</p>}
</div>
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
<button className="btn btn-primary" onClick={() => onUseDraft(summary)}>Continue to agent form</button>
</div>
</div>
)}
{viewState === "error" && error && (
<div className="form-group">
<div className="form-error">{error}</div>
<div className="modal-actions">
<button className="btn" onClick={() => void handleClose()}>Close</button>
</div>
</div>
)}
</div>
</div>
);
}