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:
parent
8953fb13ab
commit
d2d41886fa
2 changed files with 383 additions and 0 deletions
246
ui/src/hooks/usePromoteToProject.test.ts
Normal file
246
ui/src/hooks/usePromoteToProject.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
137
ui/src/hooks/usePromoteToProject.ts
Normal file
137
ui/src/hooks/usePromoteToProject.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue