feat(nexus): add brainstormer panel for promote-to-project flow

Phase 12 — the form that rises into the bottom 70% during the promote
animation. Collects goal (required), acceptance criteria (one per
line), default gate checklist, and engineer template picker. ESC key
cancels when not mid-create; goal field autofocuses on mount.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:19:18 +00:00
parent d2d41886fa
commit 70698b9e58
2 changed files with 470 additions and 0 deletions

View file

@ -0,0 +1,222 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BrainstormerPanel } from "./BrainstormerPanel";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("<BrainstormerPanel />", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null = null;
const noop = () => {};
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root!.unmount();
});
root = null;
}
if (container.parentNode) container.remove();
});
function render(node: React.ReactNode) {
root = createRoot(container);
act(() => {
root!.render(node);
});
}
function setValue(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: string) {
const tag = el.tagName.toLowerCase();
const proto =
tag === "textarea"
? HTMLTextAreaElement.prototype
: tag === "select"
? HTMLSelectElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
setter?.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
it("renders all form fields, gates, and action buttons", () => {
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={noop}
onCancel={noop}
/>,
);
expect(container.querySelector('[data-testid="brainstormer-goal"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-acceptance"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-engineer-template"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-submit"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-cancel"]')).not.toBeNull();
// default gates
expect(container.querySelector('[data-testid="brainstormer-gate-pm-review"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-gate-code-review"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-gate-design-review"]')).not.toBeNull();
expect(container.querySelector('[data-testid="brainstormer-gate-deploy-approval"]')).not.toBeNull();
});
it("threads conversation id onto the form element", () => {
render(
<BrainstormerPanel
conversationId="c-42"
onConfirm={noop}
onCancel={noop}
/>,
);
const panel = container.querySelector('[data-testid="brainstormer-panel"]') as HTMLElement;
expect(panel.getAttribute("data-conversation-id")).toBe("c-42");
});
it("shows a validation error and blocks confirm when goal is empty", () => {
const onConfirm = vi.fn();
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={onConfirm}
onCancel={noop}
/>,
);
const submit = container.querySelector('[data-testid="brainstormer-submit"]') as HTMLButtonElement;
act(() => {
submit.click();
});
expect(onConfirm).not.toHaveBeenCalled();
const error = container.querySelector('[data-testid="brainstormer-validation-error"]');
expect(error).not.toBeNull();
expect(error?.textContent).toContain("Goal is required");
});
it("calls onConfirm with the full payload on submit", () => {
const onConfirm = vi.fn();
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={onConfirm}
onCancel={noop}
engineerTemplates={[{ id: "tpl-1", label: "Frontend" }]}
/>,
);
const goal = container.querySelector('[data-testid="brainstormer-goal"]') as HTMLTextAreaElement;
const acceptance = container.querySelector('[data-testid="brainstormer-acceptance"]') as HTMLTextAreaElement;
const tpl = container.querySelector('[data-testid="brainstormer-engineer-template"]') as HTMLSelectElement;
act(() => {
setValue(goal, "Ship Phase 12");
setValue(acceptance, "animation lands\nESC cancels\n");
setValue(tpl, "tpl-1");
});
// toggle design-review on, pm-review stays on.
const designGate = container.querySelector('[data-testid="brainstormer-gate-design-review"]') as HTMLInputElement;
act(() => {
designGate.click();
});
const submit = container.querySelector('[data-testid="brainstormer-submit"]') as HTMLButtonElement;
act(() => {
submit.click();
});
expect(onConfirm).toHaveBeenCalledTimes(1);
const payload = onConfirm.mock.calls[0]![0];
expect(payload).toMatchObject({
goal: "Ship Phase 12",
acceptanceCriteria: ["animation lands", "ESC cancels"],
engineerTemplateId: "tpl-1",
});
expect(payload.gates).toEqual(expect.arrayContaining(["pm-review", "code-review", "design-review"]));
});
it("calls onCancel on Cancel click", () => {
const onCancel = vi.fn();
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={noop}
onCancel={onCancel}
/>,
);
const cancel = container.querySelector('[data-testid="brainstormer-cancel"]') as HTMLButtonElement;
act(() => {
cancel.click();
});
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("cancels on Escape key when not creating", () => {
const onCancel = vi.fn();
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={noop}
onCancel={onCancel}
/>,
);
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("does NOT cancel on Escape when isCreating", () => {
const onCancel = vi.fn();
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={noop}
onCancel={onCancel}
isCreating
/>,
);
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onCancel).not.toHaveBeenCalled();
});
it("disables inputs and shows 'Creating…' on submit button when isCreating", () => {
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={noop}
onCancel={noop}
isCreating
/>,
);
const submit = container.querySelector('[data-testid="brainstormer-submit"]') as HTMLButtonElement;
expect(submit.disabled).toBe(true);
expect(submit.textContent).toContain("Creating");
const goal = container.querySelector('[data-testid="brainstormer-goal"]') as HTMLTextAreaElement;
expect(goal.disabled).toBe(true);
});
it("renders an error alert when errorMessage is provided", () => {
render(
<BrainstormerPanel
conversationId="c-1"
onConfirm={noop}
onCancel={noop}
errorMessage="boom"
/>,
);
const err = container.querySelector('[data-testid="brainstormer-error"]');
expect(err?.textContent).toContain("boom");
});
});

View file

@ -0,0 +1,248 @@
// [nexus] Phase 12 — BrainstormerPanel.
//
// The form that rises into the bottom 70% during the promote-to-project
// transition (spec §5.6). Collects:
//
// - Goal (required)
// - Acceptance criteria (one per line)
// - Gates (checklist with default suggestions)
// - Engineer template picker
//
// The panel is controlled: the parent hook (usePromoteToProject) owns
// the commit step. This component just gathers input and calls
// `onConfirm(payload)` or `onCancel()`.
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { BrainstormerPayload } from "../../hooks/usePromoteToProject";
export interface BrainstormerPanelProps {
conversationId: string;
onConfirm: (payload: BrainstormerPayload) => void | Promise<void>;
onCancel: () => void;
isCreating?: boolean;
errorMessage?: string | null;
/**
* Optional list of engineer templates to pick from. Phase 12 ships with
* no backend wiring caller passes in the catalog if available.
*/
engineerTemplates?: Array<{ id: string; label: string }>;
className?: string;
}
const DEFAULT_GATES: ReadonlyArray<{ id: string; label: string }> = [
{ id: "pm-review", label: "PM review before implementation" },
{ id: "design-review", label: "Design review on visual changes" },
{ id: "code-review", label: "Code review before merge" },
{ id: "deploy-approval", label: "Deploy approval" },
];
export function BrainstormerPanel({
conversationId,
onConfirm,
onCancel,
isCreating = false,
errorMessage = null,
engineerTemplates = [],
className,
}: BrainstormerPanelProps) {
const [goal, setGoal] = useState("");
const [acceptance, setAcceptance] = useState("");
const [selectedGates, setSelectedGates] = useState<Set<string>>(
() => new Set(["pm-review", "code-review"]),
);
const [engineerTemplateId, setEngineerTemplateId] = useState<string>("");
const [validationError, setValidationError] = useState<string | null>(null);
const goalRef = useRef<HTMLTextAreaElement>(null);
// Autofocus goal field when mounted.
useEffect(() => {
goalRef.current?.focus();
}, []);
// ESC cancels.
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape" && !isCreating) {
e.preventDefault();
onCancel();
}
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onCancel, isCreating]);
const toggleGate = useCallback((id: string) => {
setSelectedGates((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!goal.trim()) {
setValidationError("Goal is required.");
goalRef.current?.focus();
return;
}
setValidationError(null);
const payload: BrainstormerPayload = {
goal: goal.trim(),
acceptanceCriteria: acceptance
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0),
gates: Array.from(selectedGates),
engineerTemplateId: engineerTemplateId || undefined,
};
void onConfirm(payload);
},
[goal, acceptance, selectedGates, engineerTemplateId, onConfirm],
);
return (
<form
data-testid="brainstormer-panel"
data-conversation-id={conversationId}
onSubmit={handleSubmit}
className={cn(
"flex h-full w-full flex-col gap-4 overflow-auto bg-card p-6",
"border-t border-border",
className,
)}
aria-label="Promote conversation to project"
>
<header className="flex items-center justify-between">
<h2 className="text-[14px] font-semibold uppercase tracking-[0.14em] text-foreground">
Brainstormer
</h2>
<div className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
PM agent auto-assigned
</div>
</header>
{/* Goal */}
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
Goal
</span>
<textarea
ref={goalRef}
data-testid="brainstormer-goal"
value={goal}
onChange={(e) => setGoal(e.target.value)}
rows={3}
disabled={isCreating}
placeholder="What are we building?"
className="rounded-[4px] border border-border bg-background px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
</label>
{/* Acceptance criteria */}
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
Acceptance criteria
</span>
<textarea
data-testid="brainstormer-acceptance"
value={acceptance}
onChange={(e) => setAcceptance(e.target.value)}
rows={4}
disabled={isCreating}
placeholder={"One per line\nAll tests pass\nDesign review approved"}
className="rounded-[4px] border border-border bg-background px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
</label>
{/* Gates */}
<fieldset className="flex flex-col gap-2" disabled={isCreating}>
<legend className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
Gates
</legend>
<div className="flex flex-col gap-1">
{DEFAULT_GATES.map((gate) => (
<label
key={gate.id}
className="flex items-center gap-2 text-[13px] text-foreground"
>
<input
type="checkbox"
data-testid={`brainstormer-gate-${gate.id}`}
checked={selectedGates.has(gate.id)}
onChange={() => toggleGate(gate.id)}
className="h-3.5 w-3.5 rounded-[2px] border-border accent-primary"
/>
<span>{gate.label}</span>
</label>
))}
</div>
</fieldset>
{/* Engineer template */}
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
Engineer template
</span>
<select
data-testid="brainstormer-engineer-template"
value={engineerTemplateId}
onChange={(e) => setEngineerTemplateId(e.target.value)}
disabled={isCreating}
className="rounded-[4px] border border-border bg-background px-3 py-2 text-[13px] text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
>
<option value=""> Pick later </option>
{engineerTemplates.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</label>
{/* Validation / error */}
{validationError && (
<p
data-testid="brainstormer-validation-error"
role="alert"
className="text-[12px] text-destructive"
>
{validationError}
</p>
)}
{errorMessage && (
<p
data-testid="brainstormer-error"
role="alert"
className="text-[12px] text-destructive"
>
{errorMessage}
</p>
)}
{/* Actions */}
<div className="mt-auto flex items-center justify-end gap-2 pt-2">
<button
type="button"
data-testid="brainstormer-cancel"
onClick={onCancel}
disabled={isCreating}
className="rounded-[4px] px-3 py-1.5 text-[12px] font-medium uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
data-testid="brainstormer-submit"
disabled={isCreating}
className="inline-flex items-center gap-2 rounded-[4px] border border-primary px-3 py-1.5 text-[12px] font-medium uppercase tracking-[0.1em] text-primary hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{isCreating ? "Creating…" : "Create project"}
</button>
</div>
</form>
);
}