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:
parent
d2d41886fa
commit
70698b9e58
2 changed files with 470 additions and 0 deletions
222
ui/src/components/assistant/BrainstormerPanel.test.tsx
Normal file
222
ui/src/components/assistant/BrainstormerPanel.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
248
ui/src/components/assistant/BrainstormerPanel.tsx
Normal file
248
ui/src/components/assistant/BrainstormerPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue