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