feat(nexus): add promote-to-project state machine hook

Phase 12 — idle/prompting/creating/done/error state machine that drives
the 700ms compress-and-rise animation and calls projectsApi.create with
a brainstormer payload (goal, acceptance criteria, gates, engineer
template). Also exports buildCreatePayload helper for testing.

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

View file

@ -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> = {}): 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<string, unknown>,
) => Promise<Project>;
}) {
current = usePromoteToProject({
conversationId: props.conversationId,
companyId: props.companyId,
createProject: props.createProject,
});
return null;
}
function render(props: Parameters<typeof Probe>[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<typeof makeHarness>;
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");
});
});

View file

@ -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 <PromoteTransition> 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<string, unknown>) => Promise<Project>;
}
export interface UsePromoteToProjectResult {
state: PromoteState;
startPrompting: () => void;
confirm: (payload: BrainstormerPayload) => Promise<void>;
cancel: () => void;
reset: () => void;
}
/**
* Build the create-project payload from the brainstormer form. The backend
* endpoint accepts a free-form Record<string, unknown>, 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<string, unknown> {
// 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<PromoteState>({ 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 };
}