diff --git a/ui/src/components/assistant/BrainstormerPanel.test.tsx b/ui/src/components/assistant/BrainstormerPanel.test.tsx new file mode 100644 index 00000000..bb0f0106 --- /dev/null +++ b/ui/src/components/assistant/BrainstormerPanel.test.tsx @@ -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("", () => { + let container: HTMLDivElement; + let root: ReturnType | 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + + 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( + , + ); + 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( + , + ); + 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( + , + ); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("disables inputs and shows 'Creating…' on submit button when isCreating", () => { + render( + , + ); + 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( + , + ); + const err = container.querySelector('[data-testid="brainstormer-error"]'); + expect(err?.textContent).toContain("boom"); + }); +}); diff --git a/ui/src/components/assistant/BrainstormerPanel.tsx b/ui/src/components/assistant/BrainstormerPanel.tsx new file mode 100644 index 00000000..fce75d72 --- /dev/null +++ b/ui/src/components/assistant/BrainstormerPanel.tsx @@ -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; + 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>( + () => new Set(["pm-review", "code-review"]), + ); + const [engineerTemplateId, setEngineerTemplateId] = useState(""); + const [validationError, setValidationError] = useState(null); + + const goalRef = useRef(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 ( +
+
+

+ Brainstormer +

+
+ PM agent auto-assigned +
+
+ + {/* Goal */} +