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:
Fusion
2026-05-06 05:46:26 -07:00
committed by gsxdsm
parent 419c6f3b31
commit 409f06c5e3
11 changed files with 247 additions and 52 deletions

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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%;
}
}

View File

@@ -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,
);

View File

@@ -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();
});
});

View File

@@ -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(() => {

View File

@@ -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", () => {