diff --git a/ui/src/hooks/usePromoteToProject.test.ts b/ui/src/hooks/usePromoteToProject.test.ts new file mode 100644 index 00000000..788ed2ad --- /dev/null +++ b/ui/src/hooks/usePromoteToProject.test.ts @@ -0,0 +1,246 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Project } from "@paperclipai/shared"; +import { + buildCreatePayload, + usePromoteToProject, + type BrainstormerPayload, + type PromoteState, + type UsePromoteToProjectResult, +} from "./usePromoteToProject"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function fakeProject(overrides: Partial = {}): Project { + return { + id: "proj-xyz", + companyId: "co-1", + urlKey: "my-new-project", + goalId: null, + goalIds: [], + goals: [], + name: "My New Project", + description: null, + status: "planned", + leadAgentId: null, + targetDate: null, + color: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { kind: "none" } as unknown as Project["codebase"], + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Project; +} + +// Harness: render a tiny component that calls the hook so we can drive it +// from tests via a ref. Keeps us in the Phase 8 createRoot+act pattern and +// avoids @testing-library/react-hooks (not installed). +function makeHarness() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let current: UsePromoteToProjectResult | null = null; + + function Probe(props: { + conversationId: string | null; + companyId: string | null; + createProject?: ( + companyId: string, + data: Record, + ) => Promise; + }) { + current = usePromoteToProject({ + conversationId: props.conversationId, + companyId: props.companyId, + createProject: props.createProject, + }); + return null; + } + + function render(props: Parameters[0]) { + act(() => { + root.render(createElement(Probe, props)); + }); + } + + function unmount() { + act(() => { + root.unmount(); + }); + if (container.parentNode) container.remove(); + } + + return { + render, + unmount, + get hook() { + if (!current) throw new Error("Hook not rendered yet"); + return current; + }, + getState(): PromoteState { + return current!.state; + }, + }; +} + +describe("usePromoteToProject", () => { + let harness: ReturnType; + + beforeEach(() => { + harness = makeHarness(); + }); + + afterEach(() => { + harness.unmount(); + }); + + const payload: BrainstormerPayload = { + goal: "Ship nexus phase 12", + acceptanceCriteria: ["animation ≤ 700ms", "ESC cancels"], + gates: ["pm-review"], + engineerTemplateId: undefined, + }; + + it("starts in idle state", () => { + harness.render({ conversationId: "c-1", companyId: "co-1" }); + expect(harness.getState()).toEqual({ kind: "idle" }); + }); + + it("transitions idle → prompting on startPrompting()", () => { + harness.render({ conversationId: "c-1", companyId: "co-1" }); + act(() => harness.hook.startPrompting()); + expect(harness.getState()).toEqual({ kind: "prompting", conversationId: "c-1" }); + }); + + it("does not transition when conversationId is null", () => { + harness.render({ conversationId: null, companyId: "co-1" }); + act(() => harness.hook.startPrompting()); + expect(harness.getState()).toEqual({ kind: "idle" }); + }); + + it("transitions prompting → creating → done on successful confirm()", async () => { + const createProject = vi.fn().mockResolvedValue( + fakeProject({ id: "proj-7", urlKey: "nexus-phase-12", name: "Nexus Phase 12" }), + ); + harness.render({ conversationId: "c-1", companyId: "co-1", createProject }); + act(() => harness.hook.startPrompting()); + + await act(async () => { + await harness.hook.confirm(payload); + }); + + expect(createProject).toHaveBeenCalledTimes(1); + const [calledCompany, calledBody] = createProject.mock.calls[0]!; + expect(calledCompany).toBe("co-1"); + expect(calledBody).toMatchObject({ + name: "Ship nexus phase 12", + description: "Ship nexus phase 12", + status: "planned", + originConversationId: "c-1", + brainstormer: { + goal: "Ship nexus phase 12", + acceptanceCriteria: ["animation ≤ 700ms", "ESC cancels"], + gates: ["pm-review"], + }, + }); + + expect(harness.getState()).toEqual({ + kind: "done", + projectId: "proj-7", + projectSlug: "nexus-phase-12", + projectName: "Nexus Phase 12", + }); + }); + + it("transitions to error state when createProject rejects", async () => { + const createProject = vi.fn().mockRejectedValue(new Error("boom")); + harness.render({ conversationId: "c-1", companyId: "co-1", createProject }); + act(() => harness.hook.startPrompting()); + + await act(async () => { + await harness.hook.confirm(payload); + }); + + expect(harness.getState()).toEqual({ kind: "error", message: "boom" }); + }); + + it("cancel() returns to idle from prompting", () => { + harness.render({ conversationId: "c-1", companyId: "co-1" }); + act(() => harness.hook.startPrompting()); + expect(harness.getState().kind).toBe("prompting"); + act(() => harness.hook.cancel()); + expect(harness.getState()).toEqual({ kind: "idle" }); + }); + + it("refuses confirm() when companyId is null", async () => { + const createProject = vi.fn(); + harness.render({ conversationId: "c-1", companyId: null, createProject }); + act(() => harness.hook.startPrompting()); + await act(async () => { + await harness.hook.confirm(payload); + }); + expect(createProject).not.toHaveBeenCalled(); + expect(harness.getState().kind).toBe("error"); + }); +}); + +describe("buildCreatePayload", () => { + it("derives name from the first 60 chars of goal first line", () => { + const body = buildCreatePayload( + { + goal: "Rewrite the promote flow\nwith animated transitions", + acceptanceCriteria: [], + gates: [], + }, + "conv-1", + ); + expect(body.name).toBe("Rewrite the promote flow"); + }); + + it("falls back to 'Untitled project' when goal is empty", () => { + const body = buildCreatePayload( + { goal: "", acceptanceCriteria: [], gates: [] }, + "conv-1", + ); + expect(body.name).toBe("Untitled project"); + expect(body.description).toBeUndefined(); + }); + + it("truncates goal first line to 60 chars", () => { + const longLine = "a".repeat(120); + const body = buildCreatePayload( + { goal: longLine, acceptanceCriteria: [], gates: [] }, + "conv-1", + ); + expect((body.name as string).length).toBe(60); + }); + + it("includes brainstormer metadata under brainstormer key", () => { + const body = buildCreatePayload( + { + goal: "G", + acceptanceCriteria: ["a", "b"], + gates: ["pm-review"], + engineerTemplateId: "tpl-1", + }, + "conv-2", + ); + expect(body.brainstormer).toEqual({ + goal: "G", + acceptanceCriteria: ["a", "b"], + gates: ["pm-review"], + engineerTemplateId: "tpl-1", + }); + expect(body.originConversationId).toBe("conv-2"); + }); +}); diff --git a/ui/src/hooks/usePromoteToProject.ts b/ui/src/hooks/usePromoteToProject.ts new file mode 100644 index 00000000..b6b4061e --- /dev/null +++ b/ui/src/hooks/usePromoteToProject.ts @@ -0,0 +1,137 @@ +// [nexus] Phase 12 — promote-to-project state machine. +// +// Drives the 700ms compress-and-rise animation from spec §5.6. The caller +// (PersonalAssistant) wires `startPrompting` to the ActionStrip's Promote +// button, mounts the overlay when state is +// `prompting` or `creating`, and renders a banner when state is `done`. +// +// This hook does not depend on react-query: create-project is a one-shot +// mutation triggered by `confirm(payload)`, so we hand-roll the state +// transitions for testability. Consumers can pass a custom `createProject` +// implementation in tests. +import { useCallback, useState } from "react"; +import type { Project } from "@paperclipai/shared"; +import { projectsApi } from "../api/projects"; + +export interface BrainstormerPayload { + goal: string; + acceptanceCriteria: string[]; + gates: string[]; + engineerTemplateId?: string; +} + +export type PromoteState = + | { kind: "idle" } + | { kind: "prompting"; conversationId: string } + | { kind: "creating"; conversationId: string; payload: BrainstormerPayload } + | { kind: "done"; projectId: string; projectSlug: string; projectName: string } + | { kind: "error"; message: string }; + +export interface UsePromoteToProjectArgs { + /** The conversation that will become a project. */ + conversationId: string | null; + /** The company id used by the create-project API. */ + companyId: string | null; + /** + * Injectable API for tests. Defaults to the real `projectsApi.create`. + */ + createProject?: (companyId: string, data: Record) => Promise; +} + +export interface UsePromoteToProjectResult { + state: PromoteState; + startPrompting: () => void; + confirm: (payload: BrainstormerPayload) => Promise; + cancel: () => void; + reset: () => void; +} + +/** + * Build the create-project payload from the brainstormer form. The backend + * endpoint accepts a free-form Record, so we stuff the + * brainstormer metadata under a `brainstormer` key which the server can + * ignore today and consume later. + */ +export function buildCreatePayload( + payload: BrainstormerPayload, + conversationId: string, +): Record { + // Derive a name from the goal's first line (first 60 chars, trimmed). + const firstLine = payload.goal.split("\n")[0]?.trim() ?? ""; + const name = firstLine.slice(0, 60) || "Untitled project"; + + return { + name, + description: payload.goal.trim() || undefined, + status: "planned", + originConversationId: conversationId, + brainstormer: { + goal: payload.goal, + acceptanceCriteria: payload.acceptanceCriteria, + gates: payload.gates, + engineerTemplateId: payload.engineerTemplateId ?? null, + }, + }; +} + +export function usePromoteToProject( + args: UsePromoteToProjectArgs, +): UsePromoteToProjectResult { + const { conversationId, companyId } = args; + const createProject = args.createProject ?? projectsApi.create; + + const [state, setState] = useState({ kind: "idle" }); + + const startPrompting = useCallback(() => { + if (!conversationId) return; + setState({ kind: "prompting", conversationId }); + }, [conversationId]); + + const cancel = useCallback(() => { + setState({ kind: "idle" }); + }, []); + + const reset = useCallback(() => { + setState({ kind: "idle" }); + }, []); + + const confirm = useCallback( + async (payload: BrainstormerPayload) => { + if (!companyId) { + setState({ kind: "error", message: "No workspace selected." }); + return; + } + // confirm only valid from prompting; fall back to stored conversationId + let convId: string | null = null; + setState((prev) => { + if (prev.kind === "prompting") convId = prev.conversationId; + else if (prev.kind === "creating") convId = prev.conversationId; + return prev; + }); + if (!convId) convId = conversationId; + if (!convId) { + setState({ kind: "error", message: "No conversation to promote." }); + return; + } + + setState({ kind: "creating", conversationId: convId, payload }); + + try { + const body = buildCreatePayload(payload, convId); + const project = await createProject(companyId, body); + setState({ + kind: "done", + projectId: project.id, + projectSlug: project.urlKey, + projectName: project.name, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create project."; + setState({ kind: "error", message }); + } + }, + [companyId, conversationId, createProject], + ); + + return { state, startPrompting, confirm, cancel, reset }; +}