fix(onboarding): carry an existing company's mission into the wizard (#11416)
Ports the substance of #11259, which predated the recent onboarding work and was still open. One of the parts it solves is a regression #11352 introduced. A company that already has its mission opens on the agent step, which is the point of #11352. What that missed is that the mission field is filled only by the step being skipped, and that the field is not decoration: `composeCeoInstructions` seeds the lead agent's instructions from it, and the Review checklist reads it. So every Cloud-seeded company hired its lead agent with no Mission line at all, having been routed there precisely *because* it had a mission. Before #11352 those companies dead-ended on the mission step; a dead end became a quiet data loss, which is worse, because it completes. `selectExistingCompanyMission` reads the company's own goal back into the shape the mission field holds, and the wizard hydrates from it - only when the field is empty, so a customer editing their mission is never overwritten by the stored copy. The marker recording that hydration travels with the field it describes, cleared wherever `companyGoal` is. `isExistingCompanyMissionUnresolved` holds the hire while that read is outstanding, counting an in-flight refetch over cached goals as unresolved. That is the rule #11382 settled on a day earlier for a different consumer - `isFetching`, not `isLoading`, because retained data is not an answer to the question being asked now. #11259 had it first, on 11 August. `canGoBackFromOnboardingStep` and `canJumpToOnboardingStep` bound how far back a run can walk by the step it entered on. The Back button already applied that rule inline; the progress bar applied only the "already completed" half, so a run holding a company could still jump to step 1 - the step whose job is to create one. The entry step is captured once, when the wizard opens, for the same reason the step itself is. `planMissionPersistence` came with them and turned out to be required rather than tidying. Hydration sets `createdCompanyGoalId` from the company's existing goal, and confirming the mission read that id as "already written" and skipped the write, discarding the customer's edit. That skip was safe only while the id could arrive one way - by writing. A goal in hand now means update it. Each piece was checked by removing it and confirming a specific case fails. The hydration case asserts on `saveInstructionsFile`'s content, the actual consumer, rather than on the mission textarea, because the entry path never renders that field and the navigation bound now prevents reaching it. One caveat recorded rather than smoothed over: the reopen case fails only when both marker-clears are removed, since `reset()` also clears the company id and the next introduction routes through `clearCompanyScopedState`. They are kept as one invariant rather than one guard plus a coincidence. ui typecheck clean; full ui suite 4004 pass. Two failures remain, in IssueProperties and StatusCards/format; both are date-dependent, both reproduce on master with these changes stashed, and neither file is touched here. Co-Authored-By: Jannes Stubbemann <stubbi@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
484b1f626c
commit
6542ad1f4d
|
|
@ -32,6 +32,14 @@ const mockGoalsApi = vi.hoisted(() => ({
|
|||
update: vi.fn(),
|
||||
}));
|
||||
const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
adapterModels: vi.fn(),
|
||||
hire: vi.fn(),
|
||||
instructionsBundle: vi.fn(),
|
||||
saveInstructionsFile: vi.fn(),
|
||||
testEnvironment: vi.fn(),
|
||||
}));
|
||||
const mockCompaniesApi = vi.hoisted(() => ({ create: vi.fn() }));
|
||||
|
||||
const routerState = vi.hoisted(() => ({ pathname: "/" }));
|
||||
|
|
@ -54,9 +62,7 @@ const companyState = vi.hoisted(() => ({
|
|||
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
|
||||
vi.mock("@/api/adapters", () => ({ adaptersApi: mockAdaptersApi }));
|
||||
vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: { create: vi.fn(), adapterModels: vi.fn().mockResolvedValue([]) },
|
||||
}));
|
||||
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
vi.mock("../api/approvals", () => ({ approvalsApi: { create: vi.fn() } }));
|
||||
vi.mock("../api/issues", () => ({ issuesApi: { create: vi.fn() } }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: { list: vi.fn(), create: vi.fn() } }));
|
||||
|
|
@ -176,6 +182,16 @@ describe("OnboardingWizard — which step it lands on", () => {
|
|||
dialogState.onboardingRouteDismissed = false;
|
||||
mockAdaptersApi.list.mockResolvedValue([]);
|
||||
mockGoalsApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
|
||||
mockAgentsApi.instructionsBundle.mockResolvedValue({ entryFile: "AGENTS.md" });
|
||||
mockAgentsApi.saveInstructionsFile.mockResolvedValue({});
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue({
|
||||
adapterType: "claude_local",
|
||||
status: "pass",
|
||||
checks: [],
|
||||
testedAt: new Date("2026-03-02T00:00:00Z").toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -702,4 +718,154 @@ describe("OnboardingWizard — which step it lands on", () => {
|
|||
|
||||
expect(currentStep()).toBe("agent");
|
||||
});
|
||||
|
||||
describe("a company that already has its mission", () => {
|
||||
// It opens on the agent step, so steps 1 and 2 never run. Everything the
|
||||
// mission feeds has to come from the company instead of the form.
|
||||
|
||||
const MISSION_GOAL = {
|
||||
...COMPANY_GOAL,
|
||||
title: "Scale the marketplace",
|
||||
description: "Reach 1000 sellers",
|
||||
};
|
||||
|
||||
async function openOnAgentStep() {
|
||||
routerState.pathname = "/PC1/onboarding";
|
||||
mockGoalsApi.list.mockResolvedValue([MISSION_GOAL]);
|
||||
await render();
|
||||
await settle();
|
||||
expect(currentStep()).toBe("agent");
|
||||
}
|
||||
|
||||
it("seeds the lead agent's instructions with the mission it was never asked for", async () => {
|
||||
// The regression this exists for. The agent step feeds
|
||||
// `composeCeoInstructions` from the mission field, and a company entered
|
||||
// here never types one — so the agent was hired knowing nothing of the
|
||||
// mission the customer gave at signup, and nothing reported it.
|
||||
await openOnAgentStep();
|
||||
|
||||
const next = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Next"),
|
||||
)!;
|
||||
await act(async () => {
|
||||
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
|
||||
const connect = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Connect"),
|
||||
)!;
|
||||
expect(connect.hasAttribute("disabled")).toBe(false);
|
||||
await act(async () => {
|
||||
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalled();
|
||||
const [, file] = mockAgentsApi.saveInstructionsFile.mock.calls[0];
|
||||
expect(file.content).toContain("Scale the marketplace");
|
||||
expect(file.content).toContain("Reach 1000 sellers");
|
||||
});
|
||||
|
||||
it("will not hire while the mission is being re-read", async () => {
|
||||
// Cached goals plus an in-flight refetch: the field holds the right
|
||||
// company's mission, but not necessarily its current one. Hiring inside
|
||||
// that window seeds the agent from a value about to change, and reports
|
||||
// nothing — the same "retained data is not an answer" rule the draft
|
||||
// ownership gate follows.
|
||||
await openOnAgentStep();
|
||||
|
||||
const next = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Next"),
|
||||
)!;
|
||||
await act(async () => {
|
||||
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
expect(
|
||||
[...document.body.querySelectorAll("button")]
|
||||
.find((b) => b.textContent?.includes("Connect"))!
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(false);
|
||||
|
||||
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
|
||||
await act(async () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.list("company-1"),
|
||||
});
|
||||
});
|
||||
await settle(2);
|
||||
|
||||
const connect = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Connect"),
|
||||
)!;
|
||||
expect(connect.hasAttribute("disabled")).toBe(true);
|
||||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hydrates again when the same company comes back through onboarding", async () => {
|
||||
// The hydration marker is a ref, so it outlives the state it describes.
|
||||
// `reset()` clears the mission field; leaving the marker set would make
|
||||
// the second run believe a mission it no longer holds was already
|
||||
// fetched — and hire the agent without it, exactly as before this fix.
|
||||
await openOnAgentStep();
|
||||
|
||||
const close = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.querySelector(".sr-only")?.textContent?.includes("Close"),
|
||||
);
|
||||
expect(close).toBeDefined();
|
||||
await act(async () => {
|
||||
close!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
// `reset()` ran: the wizard is back at the front door with a cleared
|
||||
// mission field, which is precisely the state the marker must not
|
||||
// outlive.
|
||||
expect(currentStep()).not.toBe("agent");
|
||||
|
||||
routerState.pathname = "/";
|
||||
await rerender();
|
||||
await settle();
|
||||
routerState.pathname = "/PC1/onboarding";
|
||||
dialogState.onboardingRouteDismissed = false;
|
||||
await rerender();
|
||||
await settle();
|
||||
expect(currentStep()).toBe("agent");
|
||||
|
||||
const next = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Next"),
|
||||
)!;
|
||||
await act(async () => {
|
||||
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
const connect = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Connect"),
|
||||
)!;
|
||||
await act(async () => {
|
||||
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await settle();
|
||||
|
||||
expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalled();
|
||||
const [, file] = mockAgentsApi.saveInstructionsFile.mock.calls.at(-1)!;
|
||||
expect(file.content).toContain("Scale the marketplace");
|
||||
});
|
||||
|
||||
it("does not offer a way back behind the step it entered on", async () => {
|
||||
// Step 1 creates a company. A run that already holds one must not be
|
||||
// able to walk into it, by the Back button or the progress bar.
|
||||
await openOnAgentStep();
|
||||
|
||||
const back = [...document.body.querySelectorAll("button")].find((b) =>
|
||||
b.textContent?.includes("Back"),
|
||||
);
|
||||
expect(back).toBeUndefined();
|
||||
|
||||
const nameSegment = document.body.querySelector(
|
||||
'[aria-label="Step 1"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(nameSegment?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,10 +46,16 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
|||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
canGoBackFromOnboardingStep,
|
||||
canJumpToOnboardingStep,
|
||||
companyPrefixFromOnboardingPath,
|
||||
resolveRouteOnboardingOptions,
|
||||
} from "../lib/onboarding-route";
|
||||
import { useCompanyMission } from "../hooks/useCompanyMission";
|
||||
import {
|
||||
isExistingCompanyMissionUnresolved,
|
||||
planMissionPersistence,
|
||||
} from "../lib/onboarding-mission";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import { FrontDoor } from "./FrontDoor";
|
||||
import { AgentCapsule } from "./AgentCapsule";
|
||||
|
|
@ -358,6 +364,12 @@ function OnboardingWizardInner({
|
|||
const existingCompanyId = effectiveOnboardingOptions.companyId;
|
||||
|
||||
const [step, setStep] = useState<Step>((saved?.step as Step) ?? initialStep);
|
||||
// The step this run *entered* on, which bounds how far back it can walk.
|
||||
// Captured once, when the wizard opens, for the same reason the step itself
|
||||
// is: it derives from queries, so a live read would move the floor under a
|
||||
// customer mid-flow — and here that would quietly re-open the "create a
|
||||
// company" step to a run that already holds one.
|
||||
const [entryStep, setEntryStep] = useState<number>((saved?.step as Step) ?? initialStep);
|
||||
const [onboardingPath, setOnboardingPath] = useState<"create" | "grow" | null>((saved?.onboardingPath as "create" | "grow" | null) ?? null);
|
||||
|
||||
// "Grow existing" questionnaire fields
|
||||
|
|
@ -424,6 +436,54 @@ function OnboardingWizardInner({
|
|||
// the user back to the route's initial step mid-flow.
|
||||
const createdCompanyIdRef = useRef<string | null>(null);
|
||||
createdCompanyIdRef.current = createdCompanyId;
|
||||
|
||||
// The mission of the company actually in hand, which is not always the one
|
||||
// the route named - the dashboard opens the wizard with a company too. Same
|
||||
// query key as the route lookup above, so when they agree this is one cache
|
||||
// entry and no second request.
|
||||
const {
|
||||
mission: existingCompanyMission,
|
||||
settled: existingMissionSettled,
|
||||
fetching: existingMissionFetching,
|
||||
} = useCompanyMission(createdCompanyId);
|
||||
|
||||
// Seed the mission field from the company's own goal.
|
||||
//
|
||||
// A company that already has its mission opens on the agent step, so steps 1
|
||||
// and 2 never run and `companyGoal` stays empty. It is not only a display
|
||||
// field: the Review checklist reads it, and `composeCeoInstructions` seeds
|
||||
// the lead agent's instructions from it. Left empty, the agent is hired
|
||||
// knowing nothing of the mission the customer gave at signup - which is the
|
||||
// answer this whole flow exists to carry forward.
|
||||
//
|
||||
// Only when the field is empty, so a customer editing their mission is never
|
||||
// overwritten by the stored copy.
|
||||
const hydratedMissionForRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!effectiveOnboardingOpen || !createdCompanyId) return;
|
||||
if (hydratedMissionForRef.current === createdCompanyId) return;
|
||||
if (!existingMissionSettled || existingMissionFetching) return;
|
||||
hydratedMissionForRef.current = createdCompanyId;
|
||||
if (!existingCompanyMission.goalInput) return;
|
||||
setCompanyGoal((current) => (current.trim() ? current : existingCompanyMission.goalInput));
|
||||
setCreatedCompanyGoalId((current) => current ?? existingCompanyMission.goalId);
|
||||
}, [
|
||||
effectiveOnboardingOpen,
|
||||
createdCompanyId,
|
||||
existingMissionSettled,
|
||||
existingMissionFetching,
|
||||
existingCompanyMission.goalInput,
|
||||
existingCompanyMission.goalId,
|
||||
]);
|
||||
|
||||
// Hiring seeds the agent's instructions from `companyGoal`, so it must not
|
||||
// run while that field is still waiting to be hydrated - the agent would be
|
||||
// created with an empty or foreign mission and nothing would report it.
|
||||
const missionUnresolvedForHire = isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: createdCompanyId,
|
||||
goalsLoaded: existingMissionSettled,
|
||||
goalsFetching: existingMissionFetching,
|
||||
});
|
||||
// The step the request wants, mirrored for the same reason. `initialStep` is
|
||||
// *derived* - from the company list, and now from the goal list behind
|
||||
// `useCompanyMission` - so its value changes whenever one of those queries
|
||||
|
|
@ -457,6 +517,12 @@ function OnboardingWizardInner({
|
|||
setCreatedCompanyPrefix(null);
|
||||
setCompanyName("");
|
||||
setCompanyGoal("");
|
||||
// The marker travels with the field it describes. It means "companyGoal
|
||||
// holds this company's hydrated mission", so it is cleared wherever that
|
||||
// field is - here and in `reset()`. Left behind, the next run believes a
|
||||
// mission it no longer holds was already fetched, and hires the lead agent
|
||||
// without one.
|
||||
hydratedMissionForRef.current = null;
|
||||
setMissionPath(null);
|
||||
setMissionConfirmed(false);
|
||||
setCreatedCompanyGoalId(null);
|
||||
|
|
@ -479,6 +545,7 @@ function OnboardingWizardInner({
|
|||
// If explicit options are provided, they take precedence over saved state
|
||||
if (initialStepRef.current) {
|
||||
setStep(initialStepRef.current);
|
||||
setEntryStep(initialStepRef.current);
|
||||
}
|
||||
const routeCompanyId = effectiveOnboardingOptions.companyId ?? null;
|
||||
if (routeCompanyId) {
|
||||
|
|
@ -720,6 +787,8 @@ function OnboardingWizardInner({
|
|||
|
||||
function reset() {
|
||||
onboardingDraftStorage.clear();
|
||||
// Cleared with `companyGoal` below - see `clearCompanyScopedState`.
|
||||
hydratedMissionForRef.current = null;
|
||||
setStep(0);
|
||||
setOnboardingPath(null);
|
||||
setGrowWorkflows("");
|
||||
|
|
@ -941,21 +1010,16 @@ function OnboardingWizardInner({
|
|||
// without writing it would leave the company with no mission at all,
|
||||
// which is the state this whole change exists to remove.
|
||||
//
|
||||
// Only when the wizard has not already written one. Returning to step 2
|
||||
// and confirming again must not add a second goal.
|
||||
if (createdCompanyGoalId) {
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
// A goal already in hand means update it, not skip the write. It used
|
||||
// to mean skip, which was safe only while the field could not hold an
|
||||
// unsaved change: the id was set by *writing* the mission, so arriving
|
||||
// here with one meant nothing had been typed since. Hydration breaks
|
||||
// that - the id now also arrives from the company's existing goal, with
|
||||
// the customer's edits sitting in the field beside it - and skipping
|
||||
// would discard exactly the answer this step asked for.
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const parsedGoal = parseOnboardingGoalInput(companyGoal);
|
||||
const payload = {
|
||||
title: parsedGoal.title,
|
||||
...(parsedGoal.description ? { description: parsedGoal.description } : {})
|
||||
};
|
||||
|
||||
// The company may already have a mission this step could not see.
|
||||
// `useCompanyMission` fails open, so a goal lookup that exhausted its
|
||||
// retries sends a company that has one here anyway. Adding a second
|
||||
|
|
@ -966,24 +1030,29 @@ function OnboardingWizardInner({
|
|||
// customer just answered the question on a step that asked it, so
|
||||
// their answer is the mission. A read that fails still writes: an
|
||||
// unwritten mission is the failure this whole change exists to remove.
|
||||
let existingGoalId: string | null = null;
|
||||
let existingGoalId: string | null = createdCompanyGoalId;
|
||||
try {
|
||||
const goals = await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.goals.list(createdCompanyId),
|
||||
queryFn: () => goalsApi.list(createdCompanyId)
|
||||
});
|
||||
existingGoalId = selectDefaultCompanyGoalId(goals);
|
||||
existingGoalId = existingGoalId ?? selectDefaultCompanyGoalId(goals);
|
||||
} catch {
|
||||
// Still cannot tell. Fall through and write.
|
||||
}
|
||||
|
||||
const goal = existingGoalId
|
||||
? await goalsApi.update(existingGoalId, payload)
|
||||
: await goalsApi.create(createdCompanyId, {
|
||||
...payload,
|
||||
level: "company",
|
||||
status: "active"
|
||||
});
|
||||
const plan = planMissionPersistence({
|
||||
goalInput: companyGoal,
|
||||
existingGoalId,
|
||||
});
|
||||
if (plan.kind === "skip") {
|
||||
setStep(3);
|
||||
return;
|
||||
}
|
||||
const goal =
|
||||
plan.kind === "update"
|
||||
? await goalsApi.update(plan.goalId, plan.payload)
|
||||
: await goalsApi.create(createdCompanyId, plan.payload);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.goals.list(createdCompanyId)
|
||||
});
|
||||
|
|
@ -1046,6 +1115,11 @@ function OnboardingWizardInner({
|
|||
// doesn't hire a second agent.
|
||||
async function handleGiveHeartbeat() {
|
||||
if (!createdCompanyId) return;
|
||||
// Guarded at the button and the Enter path too; repeated here because this
|
||||
// seeds the agent's instructions from `companyGoal`, and hiring with an
|
||||
// unhydrated mission fails silently - the agent exists, and simply never
|
||||
// learns what the company is for.
|
||||
if (missionUnresolvedForHire) return;
|
||||
if (createdAgentId) {
|
||||
setStep(5);
|
||||
return;
|
||||
|
|
@ -1214,7 +1288,8 @@ function OnboardingWizardInner({
|
|||
if (step === 1 && companyName.trim()) setStep(2);
|
||||
else if (step === 2 && companyName.trim() && companyGoal.trim()) handleConfirmMission();
|
||||
else if (step === 3 && agentName.trim()) setStep(4);
|
||||
else if (step === 4 && agentName.trim()) handleGiveHeartbeat();
|
||||
else if (step === 4 && agentName.trim() && !missionUnresolvedForHire)
|
||||
handleGiveHeartbeat();
|
||||
else if (step === 5) handleLaunchToDashboard();
|
||||
}
|
||||
}
|
||||
|
|
@ -1272,7 +1347,11 @@ function OnboardingWizardInner({
|
|||
<div className="flex items-center gap-1.5 mb-8">
|
||||
{([1, 2, 3, 4, 5] as const).map((s) => {
|
||||
const filled = step >= s;
|
||||
const canJump = s < step;
|
||||
const canJump = canJumpToOnboardingStep({
|
||||
targetStep: s,
|
||||
currentStep: step,
|
||||
entryStep,
|
||||
});
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
|
|
@ -2101,7 +2180,7 @@ function OnboardingWizardInner({
|
|||
{/* Footer navigation */}
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div>
|
||||
{step > 1 && step > (effectiveOnboardingOptions.initialStep ?? 0) && (
|
||||
{canGoBackFromOnboardingStep({ currentStep: step, entryStep }) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -2154,7 +2233,12 @@ function OnboardingWizardInner({
|
|||
{step === 4 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!agentName.trim() || loading || adapterEnvLoading}
|
||||
disabled={
|
||||
!agentName.trim() ||
|
||||
loading ||
|
||||
adapterEnvLoading ||
|
||||
missionUnresolvedForHire
|
||||
}
|
||||
onClick={handleGiveHeartbeat}
|
||||
>
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -83,7 +83,12 @@ describe("useCompanyMission", () => {
|
|||
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
|
||||
render("company-1");
|
||||
|
||||
expect(captured).toEqual({ hasMission: undefined, settled: false });
|
||||
expect(captured).toEqual({
|
||||
hasMission: undefined,
|
||||
settled: false,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a mission when the company has a company-level goal", async () => {
|
||||
|
|
@ -91,7 +96,14 @@ describe("useCompanyMission", () => {
|
|||
render("company-1");
|
||||
await settle();
|
||||
|
||||
expect(captured).toEqual({ hasMission: true, settled: true });
|
||||
// The mission comes back in the shape the wizard's textarea holds, so the
|
||||
// agent step can seed the lead agent's instructions from it.
|
||||
expect(captured).toEqual({
|
||||
hasMission: true,
|
||||
settled: true,
|
||||
mission: { goalId: "goal-1", goalInput: "Ship the thing" },
|
||||
fetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no mission when the company has no company-level goal", async () => {
|
||||
|
|
@ -99,7 +111,12 @@ describe("useCompanyMission", () => {
|
|||
render("company-1");
|
||||
await settle();
|
||||
|
||||
expect(captured).toEqual({ hasMission: false, settled: true });
|
||||
expect(captured).toEqual({
|
||||
hasMission: false,
|
||||
settled: true,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("settles with an unknown mission when the lookup fails", async () => {
|
||||
|
|
@ -112,7 +129,12 @@ describe("useCompanyMission", () => {
|
|||
render("company-1");
|
||||
await settle();
|
||||
|
||||
expect(captured).toEqual({ hasMission: undefined, settled: true });
|
||||
expect(captured).toEqual({
|
||||
hasMission: undefined,
|
||||
settled: true,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("settles immediately when there is no company to ask about", () => {
|
||||
|
|
@ -120,7 +142,12 @@ describe("useCompanyMission", () => {
|
|||
// is the same failure as above, reached without a request.
|
||||
render(null);
|
||||
|
||||
expect(captured).toEqual({ hasMission: undefined, settled: true });
|
||||
expect(captured).toEqual({
|
||||
hasMission: undefined,
|
||||
settled: true,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: false,
|
||||
});
|
||||
expect(mockGoalsApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { goalsApi } from "../api/goals";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { selectDefaultCompanyGoalId } from "../lib/onboarding-launch";
|
||||
import {
|
||||
selectExistingCompanyMission,
|
||||
type ExistingCompanyMission,
|
||||
} from "../lib/onboarding-mission";
|
||||
|
||||
/**
|
||||
* Whether a company already has its mission, for deciding which onboarding
|
||||
|
|
@ -28,14 +32,28 @@ import { selectDefaultCompanyGoalId } from "../lib/onboarding-launch";
|
|||
* gates follow: a check that guards a convenience must never be able to block
|
||||
* the thing it guards.
|
||||
*
|
||||
* `mission` carries the same goal back in the shape the wizard's mission
|
||||
* textarea holds it. A company entered on the agent step never runs steps 1
|
||||
* and 2, so that field is otherwise empty — and it is what seeds the lead
|
||||
* agent's instructions, so an empty one costs the customer the mission they
|
||||
* gave at signup.
|
||||
*
|
||||
* `fetching` is exposed separately from `settled` for the same reason the
|
||||
* draft ownership gate distinguishes them: retained goals from a previous read
|
||||
* are the right company's but not necessarily its current mission, so a
|
||||
* consumer that must not act on a stale mission waits on this rather than on
|
||||
* `settled`.
|
||||
*
|
||||
* The goal list is read under the query key the launch path already uses, so
|
||||
* this shares that cache entry rather than adding a request.
|
||||
*/
|
||||
export function useCompanyMission(companyId: string | null | undefined): {
|
||||
hasMission: boolean | undefined;
|
||||
settled: boolean;
|
||||
mission: ExistingCompanyMission;
|
||||
fetching: boolean;
|
||||
} {
|
||||
const { data: goals, isPending } = useQuery({
|
||||
const { data: goals, isPending, isFetching } = useQuery({
|
||||
queryKey: queryKeys.goals.list(companyId ?? ""),
|
||||
queryFn: () => goalsApi.list(companyId!),
|
||||
enabled: Boolean(companyId),
|
||||
|
|
@ -46,5 +64,9 @@ export function useCompanyMission(companyId: string | null | undefined): {
|
|||
// A disabled query stays pending forever, so no company means nothing to
|
||||
// wait for rather than an answer that never comes.
|
||||
settled: !companyId || !isPending,
|
||||
mission: goals
|
||||
? selectExistingCompanyMission(goals)
|
||||
: { goalId: null, goalInput: "" },
|
||||
fetching: Boolean(companyId) && isFetching,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseOnboardingGoalInput } from "./onboarding-goal";
|
||||
import {
|
||||
formatOnboardingGoalInput,
|
||||
parseOnboardingGoalInput,
|
||||
} from "./onboarding-goal";
|
||||
|
||||
describe("parseOnboardingGoalInput", () => {
|
||||
it("uses a single-line goal as the title only", () => {
|
||||
|
|
@ -20,3 +23,31 @@ describe("parseOnboardingGoalInput", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOnboardingGoalInput", () => {
|
||||
it("renders a title-only goal as a single line", () => {
|
||||
expect(formatOnboardingGoalInput("Ship the MVP")).toBe("Ship the MVP");
|
||||
});
|
||||
|
||||
it("renders a title and description as one editable block", () => {
|
||||
expect(
|
||||
formatOnboardingGoalInput("Ship the MVP", "Launch to 10 design partners"),
|
||||
).toBe("Ship the MVP\n\nLaunch to 10 design partners");
|
||||
});
|
||||
|
||||
it("treats a null or blank description as absent", () => {
|
||||
expect(formatOnboardingGoalInput("Ship the MVP", null)).toBe("Ship the MVP");
|
||||
expect(formatOnboardingGoalInput("Ship the MVP", " ")).toBe("Ship the MVP");
|
||||
});
|
||||
|
||||
it("round-trips a parsed goal back to the same parse", () => {
|
||||
const raw = "Ship the MVP\nLaunch to 10 design partners\nMeasure retention";
|
||||
const parsed = parseOnboardingGoalInput(raw);
|
||||
|
||||
expect(
|
||||
parseOnboardingGoalInput(
|
||||
formatOnboardingGoalInput(parsed.title, parsed.description),
|
||||
),
|
||||
).toEqual(parsed);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,3 +16,23 @@ export function parseOnboardingGoalInput(raw: string): {
|
|||
description: description.length > 0 ? description : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link parseOnboardingGoalInput}: render a stored goal back into
|
||||
* the single textarea the mission step edits.
|
||||
*
|
||||
* Used when the wizard is entered on a company that already has a mission, so
|
||||
* the mission can be shown and re-saved instead of retyped from scratch.
|
||||
*/
|
||||
export function formatOnboardingGoalInput(
|
||||
title: string,
|
||||
description?: string | null,
|
||||
): string {
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedDescription = description?.trim() ?? "";
|
||||
|
||||
if (!trimmedTitle) return trimmedDescription;
|
||||
if (!trimmedDescription) return trimmedTitle;
|
||||
|
||||
return `${trimmedTitle}\n\n${trimmedDescription}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import type { Goal } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOnboardingGoalInput } from "./onboarding-goal";
|
||||
import {
|
||||
isExistingCompanyMissionUnresolved,
|
||||
selectExistingCompanyMission,
|
||||
} from "./onboarding-mission";
|
||||
|
||||
function goal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectExistingCompanyMission", () => {
|
||||
it("reads the company goal back into the mission textarea", () => {
|
||||
expect(
|
||||
selectExistingCompanyMission([
|
||||
goal({ id: "goal-1", title: "Ship the cloud onboarding walk" }),
|
||||
]),
|
||||
).toEqual({
|
||||
goalId: "goal-1",
|
||||
goalInput: "Ship the cloud onboarding walk",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the goal description below the title", () => {
|
||||
expect(
|
||||
selectExistingCompanyMission([
|
||||
goal({
|
||||
id: "goal-1",
|
||||
title: "Ship the cloud onboarding walk",
|
||||
description: "End to end, across all four apps.",
|
||||
}),
|
||||
]),
|
||||
).toEqual({
|
||||
goalId: "goal-1",
|
||||
goalInput:
|
||||
"Ship the cloud onboarding walk\n\nEnd to end, across all four apps.",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through the mission textarea's own parser", () => {
|
||||
const mission = selectExistingCompanyMission([
|
||||
goal({
|
||||
id: "goal-1",
|
||||
title: "Ship the cloud onboarding walk",
|
||||
description: "End to end, across all four apps.",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(parseOnboardingGoalInput(mission.goalInput)).toEqual({
|
||||
title: "Ship the cloud onboarding walk",
|
||||
description: "End to end, across all four apps.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no mission when the company has no company-level goal", () => {
|
||||
expect(
|
||||
selectExistingCompanyMission([
|
||||
goal({ id: "team-goal", title: "Nested", level: "team" }),
|
||||
]),
|
||||
).toEqual({ goalId: null, goalInput: "" });
|
||||
});
|
||||
|
||||
it("reports no mission for a company with no goals at all", () => {
|
||||
expect(selectExistingCompanyMission([])).toEqual({
|
||||
goalId: null,
|
||||
goalInput: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExistingCompanyMissionUnresolved", () => {
|
||||
it("holds the hire until the existing company's goals have been read", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: "company-1",
|
||||
goalsLoaded: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("releases the hire once they land", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: "company-1",
|
||||
goalsLoaded: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("holds the hire while a cached read is being superseded", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: "company-1",
|
||||
goalsLoaded: true,
|
||||
goalsFetching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still ignores an in-flight read for a company created in this run", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: null,
|
||||
goalsLoaded: false,
|
||||
goalsFetching: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never holds a company created in this run — step 2 typed its mission", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: undefined,
|
||||
goalsLoaded: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({ existingCompanyId: null, goalsLoaded: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import type { Goal } from "@paperclipai/shared";
|
||||
|
||||
import { formatOnboardingGoalInput, parseOnboardingGoalInput } from "./onboarding-goal";
|
||||
import { selectDefaultCompanyGoalId } from "./onboarding-launch";
|
||||
|
||||
export type ExistingCompanyMission = {
|
||||
goalId: string | null;
|
||||
goalInput: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read an existing company's mission back out of its goals, in the shape the
|
||||
* wizard's mission textarea holds.
|
||||
*
|
||||
* The wizard can be entered on a company that already exists — the
|
||||
* `/{prefix}/onboarding` route, the dashboard's auto-open, or an in-app "add
|
||||
* agent" entry. On those paths steps 1 and 2 never run, so the mission has to
|
||||
* come from the company rather than from the form. Two things downstream read
|
||||
* it: the Review step's checklist, and the lead agent's instructions bundle.
|
||||
*/
|
||||
export function selectExistingCompanyMission(goals: Goal[]): ExistingCompanyMission {
|
||||
const goalId = selectDefaultCompanyGoalId(goals);
|
||||
if (!goalId) return { goalId: null, goalInput: "" };
|
||||
|
||||
const goal = goals.find((entry) => entry.id === goalId) ?? null;
|
||||
|
||||
return {
|
||||
goalId,
|
||||
goalInput: goal ? formatOnboardingGoalInput(goal.title, goal.description) : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an existing company's mission has yet to be read back from the
|
||||
* server.
|
||||
*
|
||||
* On an existing-company entry the mission is never typed — it is hydrated
|
||||
* from the company's goals. Until that read lands, the wizard's mission field
|
||||
* still holds whatever the last run saved, which may be empty or may belong to
|
||||
* an entirely different company. Hiring the lead agent inside that window
|
||||
* seeds its instructions from that field and reports nothing wrong, so the
|
||||
* hire waits for the read.
|
||||
*
|
||||
* `fetching` counts as unresolved even when data is already present. Loaded
|
||||
* goals can be a cached read from before the wizard opened, with the current
|
||||
* request still in flight; they are at least the right company's, but not
|
||||
* necessarily its current mission. This is the same distinction the draft
|
||||
* ownership gate draws — `isFetching`, not `isLoading` — and for the same
|
||||
* reason: retained data is not an answer to the question being asked now.
|
||||
*/
|
||||
export function isExistingCompanyMissionUnresolved(params: {
|
||||
existingCompanyId?: string | null;
|
||||
goalsLoaded: boolean;
|
||||
goalsFetching?: boolean;
|
||||
}): boolean {
|
||||
if (!params.existingCompanyId) return false;
|
||||
if (params.goalsFetching) return true;
|
||||
|
||||
return !params.goalsLoaded;
|
||||
}
|
||||
export type MissionGoalPayload = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
level?: "company";
|
||||
status?: "active";
|
||||
};
|
||||
|
||||
export type MissionPersistencePlan =
|
||||
| { kind: "skip" }
|
||||
| { kind: "create"; payload: MissionGoalPayload }
|
||||
| { kind: "update"; goalId: string; payload: MissionGoalPayload };
|
||||
|
||||
/**
|
||||
* Decide what confirming the mission has to write.
|
||||
*
|
||||
* The wizard used to early-return whenever a company id was already present,
|
||||
* so a mission typed on an existing company was silently discarded. An existing
|
||||
* company still needs no `companies.create` — but its mission must land on the
|
||||
* company-level goal, updating the goal the company already has rather than
|
||||
* creating a second one.
|
||||
*/
|
||||
export function planMissionPersistence(params: {
|
||||
goalInput: string;
|
||||
existingGoalId: string | null;
|
||||
}): MissionPersistencePlan {
|
||||
const parsed = parseOnboardingGoalInput(params.goalInput);
|
||||
if (!parsed.title) return { kind: "skip" };
|
||||
|
||||
if (params.existingGoalId) {
|
||||
return {
|
||||
kind: "update",
|
||||
goalId: params.existingGoalId,
|
||||
payload: { title: parsed.title, description: parsed.description },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "create",
|
||||
payload: {
|
||||
title: parsed.title,
|
||||
...(parsed.description ? { description: parsed.description } : {}),
|
||||
level: "company",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -135,6 +135,40 @@ export function shouldRouteAgentlessCompanyToOnboarding(params: {
|
|||
return !isOnboardingPath(params.pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the wizard's Back button is offered on the current step.
|
||||
*
|
||||
* A run never walks back behind the step it entered on: those steps either do
|
||||
* not apply to it (an existing company is already named, and its mission is
|
||||
* hydrated rather than asked for) or were completed elsewhere. Walking back
|
||||
* into step 1 while holding a company is the sharpest case — that step creates
|
||||
* a company, so it would make a second one.
|
||||
*/
|
||||
export function canGoBackFromOnboardingStep(params: {
|
||||
currentStep: number;
|
||||
entryStep: number;
|
||||
}): boolean {
|
||||
return params.currentStep > 1 && params.currentStep > params.entryStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a completed progress-bar segment can be clicked to jump back to it.
|
||||
*
|
||||
* Same bound as {@link canGoBackFromOnboardingStep}. The progress bar applies
|
||||
* only the "already completed" half of the rule, which lets a run entered on
|
||||
* an existing company jump to the name and mission steps the Back button
|
||||
* deliberately withholds.
|
||||
*/
|
||||
export function canJumpToOnboardingStep(params: {
|
||||
targetStep: number;
|
||||
currentStep: number;
|
||||
entryStep: number;
|
||||
}): boolean {
|
||||
return (
|
||||
params.targetStep < params.currentStep && params.targetStep >= params.entryStep
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRedirectCompanylessRouteToOnboarding(params: {
|
||||
pathname: string;
|
||||
hasCompanies: boolean;
|
||||
|
|
|
|||
Loading…
Reference in New Issue