feat(FN-3553): improve agent creation with native shell connection and onbo
This merge adds a canonical in-dialog AI interview flow for agent onboarding (FN-3553), replacing the old wizard with a multi-step dialog that guides users through creating and reviewing agent prompts before spawning, plus tokenized typography fixes. It also introduces native shell connection handof Fusion-Task-Id: FN-3553
This commit is contained in:
@@ -11,7 +11,6 @@ import { AgentEmptyState } from "./AgentEmptyState";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
@@ -194,7 +193,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
showSystemAgents,
|
||||
});
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isOnboardingOpen, setIsOnboardingOpen] = useState(false);
|
||||
const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
@@ -738,12 +736,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const showInitialAgentsLoading = isLoading && agents.length === 0;
|
||||
|
||||
const handleOpenNewAgent = useCallback(() => {
|
||||
if (agentOnboardingEnabled) {
|
||||
setIsOnboardingOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsCreating(true);
|
||||
}, [agentOnboardingEnabled]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="agents-view">
|
||||
@@ -934,18 +928,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
onCreated={() => { setIsCreating(false); setOnboardingDraft(null); void loadAgents(); }}
|
||||
projectId={projectId}
|
||||
prefillDraft={onboardingDraft}
|
||||
/>
|
||||
|
||||
<ExperimentalAgentOnboardingModal
|
||||
isOpen={isOnboardingOpen}
|
||||
onClose={() => setIsOnboardingOpen(false)}
|
||||
onUseDraft={(draft) => {
|
||||
setOnboardingDraft(draft);
|
||||
setIsOnboardingOpen(false);
|
||||
setIsCreating(true);
|
||||
}}
|
||||
projectId={projectId}
|
||||
agentOnboardingEnabled={agentOnboardingEnabled}
|
||||
existingAgents={agents}
|
||||
onPrefillDraft={setOnboardingDraft}
|
||||
/>
|
||||
|
||||
<AgentImportModal
|
||||
|
||||
@@ -5,12 +5,38 @@
|
||||
.experimental-agent-onboarding-modal__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.experimental-agent-onboarding-modal__summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.experimental-agent-onboarding-modal__summary-section h4 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
|
||||
}
|
||||
|
||||
.experimental-agent-onboarding-modal__summary-section p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.experimental-agent-onboarding-modal__summary-block {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.experimental-agent-onboarding-modal__textarea {
|
||||
min-height: calc(var(--space-2xl) * 3);
|
||||
}
|
||||
|
||||
.experimental-agent-onboarding-modal__summary {
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +148,9 @@ export function ExperimentalAgentOnboardingModal({
|
||||
|
||||
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 modal-lg experimental-agent-onboarding-modal" role="dialog" aria-modal="true" aria-label="AI Interview">
|
||||
<div className="modal-header">
|
||||
<h3>Experimental Agent Onboarding</h3>
|
||||
<h3>AI Interview</h3>
|
||||
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
@@ -182,11 +182,49 @@ export function ExperimentalAgentOnboardingModal({
|
||||
<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 className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Profile</h4>
|
||||
<p><strong>Name:</strong> {summary.name}</p>
|
||||
<p><strong>Role:</strong> {summary.role}</p>
|
||||
{summary.title && <p><strong>Title:</strong> {summary.title}</p>}
|
||||
{summary.icon && <p><strong>Icon:</strong> {summary.icon}</p>}
|
||||
{summary.templateId && <p><strong>Template:</strong> {summary.templateId}</p>}
|
||||
{summary.patternAgentId && <p><strong>Pattern agent:</strong> {summary.patternAgentId}</p>}
|
||||
{summary.reportsTo && <p><strong>Reports to:</strong> {summary.reportsTo}</p>}
|
||||
{summary.rationale && <p><strong>Why:</strong> {summary.rationale}</p>}
|
||||
</div>
|
||||
|
||||
{summary.soul && (
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Soul / personality</h4>
|
||||
<p className="experimental-agent-onboarding-modal__summary-block">{summary.soul}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Core instructions</h4>
|
||||
<p className="experimental-agent-onboarding-modal__summary-block">{summary.instructionsText}</p>
|
||||
</div>
|
||||
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Runtime hints</h4>
|
||||
<p><strong>Thinking level:</strong> {summary.thinkingLevel}</p>
|
||||
<p><strong>Max turns:</strong> {summary.maxTurns}</p>
|
||||
</div>
|
||||
|
||||
{summary.memory && (
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Starter memory / playbook</h4>
|
||||
<p className="experimental-agent-onboarding-modal__summary-block">{summary.memory}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.skills && summary.skills.length > 0 && (
|
||||
<div className="experimental-agent-onboarding-modal__summary-section">
|
||||
<h4>Skills</h4>
|
||||
<p>{summary.skills.join(", ")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => void handleClose()}>Cancel</button>
|
||||
|
||||
@@ -115,6 +115,21 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-dialog-step-zero-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-dialog-interview-btn {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-dialog-interview-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-dialog-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -647,4 +662,12 @@
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-dialog-step-zero-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.agent-dialog-interview-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AgentGenerationModal } from "./AgentGenerationModal";
|
||||
import { AGENT_PRESETS, type AgentPreset } from "./agent-presets";
|
||||
import { SkillMultiselect } from "./SkillMultiselect";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
|
||||
export interface NewAgentDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,6 +18,9 @@ export interface NewAgentDialogProps {
|
||||
onCreated: () => void;
|
||||
projectId?: string;
|
||||
prefillDraft?: AgentOnboardingSummary | null;
|
||||
agentOnboardingEnabled?: boolean;
|
||||
existingAgents?: Agent[];
|
||||
onPrefillDraft?: (draft: AgentOnboardingSummary | null) => void;
|
||||
}
|
||||
|
||||
const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
@@ -42,7 +46,16 @@ interface RuntimeConfig {
|
||||
|
||||
type StepZeroTab = "presets" | "custom";
|
||||
|
||||
export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillDraft = null }: NewAgentDialogProps) {
|
||||
export function NewAgentDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreated,
|
||||
projectId,
|
||||
prefillDraft = null,
|
||||
agentOnboardingEnabled = false,
|
||||
existingAgents = [],
|
||||
onPrefillDraft,
|
||||
}: NewAgentDialogProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [stepZeroTab, setStepZeroTab] = useState<StepZeroTab>("presets");
|
||||
const [name, setName] = useState("");
|
||||
@@ -65,6 +78,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
|
||||
const [isInterviewOpen, setIsInterviewOpen] = useState(false);
|
||||
|
||||
// Model dropdown state
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
@@ -220,25 +234,29 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
setStep(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !prefillDraft) return;
|
||||
const applyDraft = useCallback((draft: AgentOnboardingSummary) => {
|
||||
setStep(1);
|
||||
setStepZeroTab("custom");
|
||||
setName(prefillDraft.name ?? "");
|
||||
setTitle(prefillDraft.title ?? "");
|
||||
setIcon(prefillDraft.icon ?? "");
|
||||
setRole((VALID_CAPABILITIES.has(prefillDraft.role) ? prefillDraft.role : "custom") as AgentCapability);
|
||||
setReportsTo(prefillDraft.reportsTo ?? "");
|
||||
setInstructionsText(prefillDraft.instructionsText ?? "");
|
||||
setSoul(prefillDraft.soul ?? "");
|
||||
setMemory(prefillDraft.memory ?? "");
|
||||
setSelectedSkills(prefillDraft.skills ?? []);
|
||||
setName(draft.name ?? "");
|
||||
setTitle(draft.title ?? "");
|
||||
setIcon(draft.icon ?? "");
|
||||
setRole((VALID_CAPABILITIES.has(draft.role) ? draft.role : "custom") as AgentCapability);
|
||||
setReportsTo(draft.reportsTo ?? "");
|
||||
setInstructionsText(draft.instructionsText ?? "");
|
||||
setSoul(draft.soul ?? "");
|
||||
setMemory(draft.memory ?? "");
|
||||
setSelectedSkills(draft.skills ?? []);
|
||||
setRuntimeConfig((current) => ({
|
||||
...current,
|
||||
thinkingLevel: prefillDraft.thinkingLevel ?? current.thinkingLevel,
|
||||
maxTurns: prefillDraft.maxTurns ?? current.maxTurns,
|
||||
thinkingLevel: draft.thinkingLevel ?? current.thinkingLevel,
|
||||
maxTurns: draft.maxTurns ?? current.maxTurns,
|
||||
}));
|
||||
}, [isOpen, prefillDraft]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !prefillDraft) return;
|
||||
applyDraft(prefillDraft);
|
||||
}, [isOpen, prefillDraft, applyDraft]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -262,6 +280,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
setSelectedSkills([]);
|
||||
setError(null);
|
||||
setIsGenerationModalOpen(false);
|
||||
setIsInterviewOpen(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -420,6 +439,17 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
<div className="agent-dialog-body">
|
||||
{step === 0 && (
|
||||
<div>
|
||||
{agentOnboardingEnabled && (
|
||||
<div className="agent-dialog-step-zero-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn agent-dialog-interview-btn"
|
||||
onClick={() => setIsInterviewOpen(true)}
|
||||
>
|
||||
AI Interview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-tabs" role="tablist" aria-label="Agent setup mode">
|
||||
<button
|
||||
id="agent-dialog-tab-presets"
|
||||
@@ -855,6 +885,18 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
|
||||
onGenerated={handleGenerated}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<ExperimentalAgentOnboardingModal
|
||||
isOpen={isInterviewOpen}
|
||||
onClose={() => setIsInterviewOpen(false)}
|
||||
onUseDraft={(draft) => {
|
||||
onPrefillDraft?.(draft);
|
||||
applyDraft(draft);
|
||||
setIsInterviewOpen(false);
|
||||
}}
|
||||
projectId={projectId}
|
||||
existingAgents={existingAgents}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1614,10 +1614,11 @@ describe("AgentsView", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "AI Interview" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens onboarding modal and not legacy dialog when agent onboarding flag is enabled", async () => {
|
||||
it("keeps New Agent launch on the standard dialog when agent onboarding flag is enabled", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} agentOnboardingEnabled={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1627,8 +1628,8 @@ describe("AgentsView", () => {
|
||||
fireEvent.click(screen.getByText("New Agent"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Experimental agent onboarding" })).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog", { name: "Create new agent" })).toBeNull();
|
||||
expect(screen.getByRole("dialog", { name: "Create new agent" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "AI Interview" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,9 +21,12 @@ vi.mock("../../api", () => ({
|
||||
streamHandlers?.onSummary?.({
|
||||
name: "Docs Reviewer",
|
||||
role: "reviewer",
|
||||
instructionsText: "Review docs",
|
||||
instructionsText: "Review docs for accuracy and clarity.",
|
||||
thinkingLevel: "medium",
|
||||
maxTurns: 20,
|
||||
soul: "Thorough and empathetic reviewer.",
|
||||
memory: "- Follow docs style guide\n- Call out unclear steps",
|
||||
skills: ["docs", "review"],
|
||||
templateId: "reviewer-template",
|
||||
rationale: "Matched your request to the reviewer preset",
|
||||
}),
|
||||
@@ -61,6 +64,17 @@ describe("ExperimentalAgentOnboardingModal", () => {
|
||||
expect(screen.getByText("Template:")).toBeTruthy();
|
||||
expect(screen.getByText("reviewer-template")).toBeTruthy();
|
||||
expect(screen.getByText(/Matched your request/)).toBeTruthy();
|
||||
expect(screen.getByText("Soul / personality")).toBeTruthy();
|
||||
expect(screen.getByText("Thorough and empathetic reviewer.")).toBeTruthy();
|
||||
expect(screen.getByText("Core instructions")).toBeTruthy();
|
||||
expect(screen.getByText("Review docs for accuracy and clarity.")).toBeTruthy();
|
||||
expect(screen.getByText("Runtime hints")).toBeTruthy();
|
||||
expect(screen.getByText("Thinking level:")).toBeTruthy();
|
||||
expect(screen.getByText("Max turns:")).toBeTruthy();
|
||||
expect(screen.getByText("Starter memory / playbook")).toBeTruthy();
|
||||
expect(screen.getByText(/Follow docs style guide/)).toBeTruthy();
|
||||
expect(screen.getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText("docs, review")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Continue to agent form"));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -27,6 +27,29 @@ vi.mock("../SkillMultiselect", () => ({
|
||||
}));
|
||||
|
||||
// Mock AgentGenerationModal
|
||||
vi.mock("../ExperimentalAgentOnboardingModal", () => ({
|
||||
ExperimentalAgentOnboardingModal: ({ isOpen, onClose, onUseDraft }: { isOpen: boolean; onClose: () => void; onUseDraft: (draft: any) => void }) => {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div role="dialog" aria-label="AI Interview">
|
||||
<button onClick={onClose}>Close Interview</button>
|
||||
<button
|
||||
onClick={() => onUseDraft({
|
||||
name: "Interview Draft",
|
||||
role: "reviewer",
|
||||
title: "Interview Title",
|
||||
instructionsText: "Interview instructions",
|
||||
thinkingLevel: "low",
|
||||
maxTurns: 12,
|
||||
})}
|
||||
>
|
||||
Apply Interview Draft
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../AgentGenerationModal", () => ({
|
||||
AgentGenerationModal: ({ isOpen, onClose, onGenerated }: { isOpen: boolean; onClose: () => void; onGenerated: (spec: any) => void }) => {
|
||||
if (!isOpen) return null;
|
||||
@@ -234,6 +257,47 @@ describe("NewAgentDialog", () => {
|
||||
const nameInput = getStepZeroField(/Name/) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("Custom Value");
|
||||
});
|
||||
|
||||
it("shows AI Interview button only when onboarding flag is enabled", async () => {
|
||||
const { rerender } = render(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} agentOnboardingEnabled={false} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "AI Interview" })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<NewAgentDialog isOpen={true} onClose={mockOnClose} onCreated={mockOnCreated} agentOnboardingEnabled={true} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "AI Interview" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens interview modal and applies draft back into the form", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPrefillDraft = vi.fn();
|
||||
render(
|
||||
<NewAgentDialog
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onCreated={mockOnCreated}
|
||||
agentOnboardingEnabled={true}
|
||||
onPrefillDraft={onPrefillDraft}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "AI Interview" }));
|
||||
expect(screen.getByRole("dialog", { name: "AI Interview" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Apply Interview Draft" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPrefillDraft).toHaveBeenCalledWith(expect.objectContaining({ name: "Interview Draft" }));
|
||||
expect(screen.getByRole("button", { name: "Model" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect((getStepZeroField(/Name/) as HTMLInputElement).value).toBe("Interview Draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("manager dropdown", () => {
|
||||
|
||||
Reference in New Issue
Block a user