Files
PaperClipAI/ui/src/components/OnboardingWizard.tsx
T
c07e650cd7 feat(ui): single-source design tokens, visual regression suite, and theme retune (#9134)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its UI is the operator's daily surface: task lists, boards, budgets,
agent status — all built on shadcn components and Tailwind
> - Visual values (colors, spacing, type sizes, radii) were hardcoded at
~1,600 call sites: the same "small gray label" was 9/10/11px depending
on the file, charts disagreed with chips about status colors, two
toggle-switch implementations coexisted in two greens, and there was no
visual regression coverage
> - This made the UI drift-prone and made any restyle a
hundreds-of-files project, which discourages design iteration
> - This pull request extracts visual values into a single token layer
in `ui/src/index.css`, adds a Storybook visual regression suite backed
by external immutable baseline archives, and then applies a deliberate
retune reviewed change-by-change on screenshot diffs
> - The benefit is that Paperclip's look becomes a config surface:
retheming is a token edit reviewed as a snapshot diff, drift is blocked
by a token gate, and future UI PRs can prove exactly what changed
visually without committing hundreds of PNGs

## Linked Issues or Issue Description

No existing public issue covers this work (searched "design tokens",
"visual regression", "design system" across issues and PRs). Related in
spirit: Refs #8982 (theming a hardcoded panel — a one-off instance of
the same problem class this PR addresses systematically).

**Problem (feature-request form):** UI visual values are hardcoded per
call site with no source of truth and no regression coverage;
consistency depends on reviewer memory, and restyling requires mass file
edits.
**Proposed solution (this PR):** a single token layer + enforcement gate
+ externally stored visual snapshot suite, then an intentional restyle
on top of that foundation.

## What Changed

- **Token extraction (zero visual change, machine-verified during
development):** committed codemods (`scripts/codemod-*.mjs`) moved
~1,600 hardcoded color/type/spacing/radius/shadow/misc values into named
tokens in a non-inline `:root` block of `ui/src/index.css`.
- **Visual regression suite:** `pnpm test:storybook-visual` covers 255
stories × light/dark = 510 Playwright screenshots at `maxDiffPixels: 0`,
plus new primitive-coverage stories and deterministic-render fixes.
- **External visual baselines:** committed PNG snapshots were removed.
`tests/storybook-visual/baseline-manifest.json` pins an immutable
archive URL/hash/size/count, and `scripts/storybook-visual-baseline.mjs`
handles `download`, `verify`, `pack`, and trusted maintainer `upload`
flows.
- **Opt-in visual CI artifacts:** added a `Storybook Visual` workflow
that runs on manual dispatch or PRs labeled `storybook-visual`,
downloads/verifies the baseline, runs Playwright, and uploads Playwright
report/test-result artifacts for review. Normal PR runs do not mutate
baseline objects.
- **Token gate:** `pnpm check:token-gates` — zero hex literals, zero
arbitrary bracket values, zero raw font-sizes in `ui/src/components/**`
and `ui/src/pages/**`, with a documented inline allowlist for legitimate
opt-outs.
- **Theme retune (intentional, snapshot-reviewed):** new base theme
values; radius ladder derived from a single `--radius` knob; micro-type
cluster collapsed to a named ladder (`--text-nano/micro/compact` +
Tailwind `text-xs`/`text-sm`); letter-spacing collapsed to named steps.
- **One status-color vocabulary:** charts, quota/budget bar fills,
RUNNING/live chips, and liveness indicators all use the canonical
`--status-*` hues. Light-mode legibility fixes for red alert surfaces
that used dark-tuned text classes.
- **One switch:** `ToggleSwitch` restyled to the registry capsule form,
second hand-rolled implementation removed, and all call sites unified.
- **Docs:** `DESIGN.md` is the design contract; `doc/design/` holds
audit reports, decision logs, and updated guidance for external baseline
review/update workflows.
- Dead code removed (`agentStatusBadge` duplicate map), byte-identical
contrast constants consolidated, semantic renames
(`--project-seed`/`--project-none`, `--liveness-blue`).

## Verification

- `pnpm check:token-gates` — 3/3 gates CLEAN during the design-system
run
- `pnpm typecheck` && `pnpm --filter @paperclipai/ui build` — green
during the design-system run
- `node --test scripts/__tests__/storybook-visual-baseline.test.mjs` —
pass after external-baseline rework
- `pnpm exec tsc --noEmit --pretty false --module NodeNext
--moduleResolution NodeNext --target ES2022 --types
node,@playwright/test tests/storybook-visual/playwright.config.ts
tests/storybook-visual/storybook-visual.spec.ts` — pass after
external-baseline rework
- `git diff --check origin/pr/9134..HEAD` — pass after external-baseline
rework
- `find tests/storybook-visual -type f -name '*.png' -print | wc -l` —
`0`
- `node scripts/storybook-visual-baseline.mjs verify` — intentionally
fails closed until the first trusted maintainer publishes the baseline
archive and updates `baseline-manifest.json`

## Risks

- **Large but shallow:** the PR still touches many UI files due to
mechanical token extraction and retune work, but committed PNG snapshot
churn has been removed from the branch.
- **Baseline publication required before the visual suite can pass in
clean clones:** the manifest currently has placeholder archive metadata.
A trusted maintainer must publish the first immutable archive, then
update `baseline-manifest.json`.
- **Rendering platform variance:** the external baseline should be
captured in the documented Linux/Chromium environment. Future CI runs
verify against the pinned archive and fail closed on checksum/count
mismatch.
- **Visual CI is opt-in while stabilizing:** add the `storybook-visual`
label or dispatch the workflow manually to produce downloadable
Playwright report/test-result artifacts.
- **Scheduled follow-ups, deliberately out of scope:** Tailwind palette
classes map to semantic tokens in a dedicated pass; card/pill component
consolidation; ESLint ratchet. Tracked in
`doc/design/DECISION-SHEET.md`.

## Model Used

Claude Fable 5 (Anthropic, `claude-fable-5`, Mythos-class tier) with
extended thinking, running in Claude Code with tool use; mechanical
phases delegated to Claude Sonnet subagents. Follow-up external-baseline
rework assisted by OpenAI Codex (`gpt-5` coding agent with repository,
terminal, and GitHub tool use). All bulk rewrites executed via
deterministic, idempotent scripts committed in `scripts/`; intentional
visual changes were human-reviewed on screenshot contact sheets.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run targeted local verification and documented the
intentional baseline-publication failure above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green *(pending new CI run after this
rework)*
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
*(pending review)*
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code) and
OpenAI Codex

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-07 16:22:16 -05:00

1787 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { useLocation, useNavigate, useParams } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { companiesApi } from "../api/companies";
import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents";
import { approvalsApi } from "../api/approvals";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { cn } from "../lib/utils";
import {
extractModelName,
extractProviderIdWithFallback
} from "../lib/model-utils";
import { getUIAdapter } from "../adapters";
import { listUIAdapters } from "../adapters";
import { isVisualAdapterChoice } from "../adapters/metadata";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import { composeCeoInstructions } from "../lib/ceo-instructions";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId,
selectReusableOnboardingProject,
} from "../lib/onboarding-launch";
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
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 { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { FrontDoor } from "./FrontDoor";
import { AgentCapsule } from "./AgentCapsule";
import {
Building2,
Bot,
ListTodo,
ArrowLeft,
ArrowRight,
Sparkles,
Check,
Loader2,
ChevronDown,
X
} from "lucide-react";
type Step = 0 | 1 | 2 | 3 | 4 | 5;
// Plugin/external adapters use arbitrary type ids, so this mirrors the master
// wizard's registry-driven approach rather than a fixed union.
type AdapterType = string;
const MISSION_PROMPT_CHIPS = [
"Build a SaaS product",
"Scale a content business",
"Launch a marketplace"
];
function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: string): string {
const parts: string[] = [];
if (q1.trim()) parts.push(q1.trim());
if (q2.trim()) parts.push(`We serve ${q2.trim().toLowerCase()}.`);
if (q3.trim()) parts.push(`Our biggest challenge is ${q3.trim().toLowerCase()}.`);
if (q4.trim()) parts.push(`Success looks like ${q4.trim().toLowerCase()}.`);
return parts.join(" ");
}
const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
- hire a founding engineer
- write a hiring plan
- break the roadmap into concrete tasks and start delegating work`;
const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
"Onboarding state is incomplete. Please restart onboarding and try again.";
function loadSavedState(): Record<string, unknown> | null {
try {
const raw = localStorage.getItem(ONBOARDING_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function OnboardingWizard() {
const {
onboardingOpen,
onboardingOptions,
closeOnboarding,
onboardingRouteDismissed: routeDismissed,
setOnboardingRouteDismissed: setRouteDismissed,
} = useDialog();
const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
// Sync disabled adapter types from server so the adapter grid filters them out.
const disabledTypes = useDisabledAdaptersSync();
// Support opening the wizard from a route (e.g. /onboarding or an existing
// company's "add agent" entry point) in addition to the dialog context.
const routeOnboardingOptions =
companyPrefix && companiesLoading
? null
: resolveRouteOnboardingOptions({
pathname: location.pathname,
companyPrefix,
companies,
});
const effectiveOnboardingOpen =
onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
const effectiveOnboardingOptions = onboardingOpen
? onboardingOptions
: routeOnboardingOptions ?? {};
const initialStep = effectiveOnboardingOptions.initialStep ?? 0;
const existingCompanyId = effectiveOnboardingOptions.companyId;
// Restore saved state from localStorage (read once on mount)
const saved = useMemo(loadSavedState, []);
const [step, setStep] = useState<Step>((saved?.step as Step) ?? initialStep);
const [onboardingPath, setOnboardingPath] = useState<"create" | "grow" | null>((saved?.onboardingPath as "create" | "grow" | null) ?? null);
// "Grow existing" questionnaire fields
const [growWorkflows, setGrowWorkflows] = useState((saved?.growWorkflows as string) ?? "");
const [growPainPoints, setGrowPainPoints] = useState((saved?.growPainPoints as string) ?? "");
const [growAutomate, setGrowAutomate] = useState((saved?.growAutomate as string) ?? "");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modelOpen, setModelOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
// Step 1
const [companyName, setCompanyName] = useState((saved?.companyName as string) ?? "");
const [companyGoal, setCompanyGoal] = useState((saved?.companyGoal as string) ?? "");
const [missionPath, setMissionPath] = useState<"direct" | "questionnaire" | null>((saved?.missionPath as "direct" | "questionnaire" | null) ?? null);
const [missionConfirmed, setMissionConfirmed] = useState((saved?.missionConfirmed as boolean) ?? false);
// Questionnaire answers
const [q1, setQ1] = useState((saved?.q1 as string) ?? ""); // What do you do?
const [q2, setQ2] = useState((saved?.q2 as string) ?? ""); // Who do you serve?
const [q3, setQ3] = useState((saved?.q3 as string) ?? ""); // Biggest bottleneck?
const [q4, setQ4] = useState((saved?.q4 as string) ?? ""); // What would success look like?
// Step 2
const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "Chief of staff");
const [adapterType, setAdapterType] = useState<AdapterType>((saved?.adapterType as AdapterType) ?? "claude_local");
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
const [model, setModel] = useState((saved?.model as string) ?? "");
const [command, setCommand] = useState((saved?.command as string) ?? "");
const [args, setArgs] = useState((saved?.args as string) ?? "");
const [url, setUrl] = useState((saved?.url as string) ?? "");
const [adapterEnvResult, setAdapterEnvResult] =
useState<AdapterEnvironmentTestResult | null>(null);
const [adapterEnvError, setAdapterEnvError] = useState<string | null>(null);
const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] =
useState(false);
const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
// Created entity IDs — pre-populate from existing company when skipping step 1
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
existingCompanyId ?? (saved?.createdCompanyId as string) ?? null
);
const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
string | null
>((saved?.createdCompanyPrefix as string) ?? null);
const [createdAgentId, setCreatedAgentId] = useState<string | null>((saved?.createdAgentId as string) ?? null);
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
(saved?.createdCompanyGoalId as string) ?? null
);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(
(saved?.createdProjectId as string) ?? null
);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(
(saved?.createdIssueRef as string) ?? null
);
// Reset the route-dismissed flag when navigating to a different path.
useEffect(() => {
setRouteDismissed(false);
}, [location.pathname]);
// Sync step and company when onboarding opens with explicit options.
// Only override saved state when explicit options provide values.
useEffect(() => {
if (!effectiveOnboardingOpen) return;
// If explicit options are provided, they take precedence over saved state
if (effectiveOnboardingOptions.initialStep) {
setStep(effectiveOnboardingOptions.initialStep);
}
if (effectiveOnboardingOptions.companyId) {
setCreatedCompanyId(effectiveOnboardingOptions.companyId);
setCreatedCompanyPrefix(null);
}
}, [
effectiveOnboardingOpen,
effectiveOnboardingOptions.companyId,
effectiveOnboardingOptions.initialStep
]);
// Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => {
if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix);
}, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Persist wizard state to localStorage on every change
useEffect(() => {
if (!effectiveOnboardingOpen) return;
const state = {
step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
};
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(state));
}, [
effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
]);
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching
} = useQuery({
// The wizard doesn't expose an environment selector, so models always
// resolve against the local Paperclip host (environmentId = null).
queryKey: createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null)
: ["agents", "none", "adapter-models", adapterType, null],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
// Models are picked on step 4 (Connect a model).
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
});
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
const isLocalAdapterCaps =
adapterCaps.supportsInstructionsBundle ||
adapterCaps.supportsSkills ||
adapterCaps.supportsLocalAgentJwt;
const isLocalAdapter =
isLocalAdapterCaps ||
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
// Build adapter grids dynamically from the UI registry + display metadata.
// External/plugin adapters automatically appear with generic defaults, and
// server-disabled types are filtered out.
const { recommendedAdapters, moreAdapters } = useMemo(() => {
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
const all = listUIAdapters()
.filter((a) =>
!SYSTEM_ADAPTER_TYPES.has(a.type) &&
!disabledTypes.has(a.type) &&
isVisualAdapterChoice(a.type)
)
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
return {
recommendedAdapters: all.filter((a) => a.recommended),
moreAdapters: all.filter((a) => !a.recommended),
};
}, [disabledTypes]);
const COMMAND_PLACEHOLDERS: Record<string, string> = {
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
};
const effectiveAdapterCommand =
command.trim() ||
(COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
useEffect(() => {
if (step !== 4) return;
setAdapterEnvResult(null);
setAdapterEnvError(null);
}, [step, adapterType, model, command, args, url]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
adapterEnvResult?.checks.some(
(check) =>
check.code === "claude_anthropic_api_key_overrides_subscription"
) ?? false;
const shouldSuggestUnsetAnthropicApiKey =
adapterType === "claude_local" &&
adapterEnvResult?.status === "fail" &&
hasAnthropicApiKeyOverrideCheck;
const filteredModels = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
return (adapterModels ?? []).filter((entry) => {
if (!query) return true;
const provider = extractProviderIdWithFallback(entry.id, "");
return (
entry.id.toLowerCase().includes(query) ||
entry.label.toLowerCase().includes(query) ||
provider.toLowerCase().includes(query)
);
});
}, [adapterModels, modelSearch]);
const groupedModels = useMemo(() => {
if (adapterType !== "opencode_local") {
return [
{
provider: "models",
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id))
}
];
}
const groups = new Map<string, Array<{ id: string; label: string }>>();
for (const entry of filteredModels) {
const provider = extractProviderIdWithFallback(entry.id);
const bucket = groups.get(provider) ?? [];
bucket.push(entry);
groups.set(provider, bucket);
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, entries]) => ({
provider,
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id))
}));
}, [filteredModels, adapterType]);
function reset() {
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
setStep(0);
setOnboardingPath(null);
setGrowWorkflows("");
setGrowPainPoints("");
setGrowAutomate("");
setLoading(false);
setError(null);
setCompanyName("");
setCompanyGoal("");
setMissionPath(null);
setMissionConfirmed(false);
setQ1("");
setQ2("");
setQ3("");
setQ4("");
setAgentName("Chief of staff");
setAdapterType("claude_local");
setModel("");
setCommand("");
setArgs("");
setUrl("");
setAdapterEnvResult(null);
setAdapterEnvError(null);
setAdapterEnvLoading(false);
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
}
function handleClose() {
reset();
closeOnboarding();
// On the /onboarding route the wizard is also kept open by the route
// itself, so closing the dialog must mark the route dismissed — otherwise
// effectiveOnboardingOpen stays true and the wizard re-renders instead of
// handing off to the launcher card (PAP-52).
setRouteDismissed(true);
}
async function handleLaunchToDashboard() {
if (!createdCompanyId || !createdAgentId) {
setError(INCOMPLETE_ONBOARDING_STATE_MESSAGE);
return;
}
setLoading(true);
setError(null);
try {
let goalId = createdCompanyGoalId;
if (!goalId) {
const goals = await goalsApi.list(createdCompanyId);
goalId = selectDefaultCompanyGoalId(goals);
setCreatedCompanyGoalId(goalId);
}
let projectId = createdProjectId;
if (!projectId) {
const projects = await projectsApi.list(createdCompanyId);
const existingOnboardingProject = selectReusableOnboardingProject(projects);
if (existingOnboardingProject) {
projectId = existingOnboardingProject.id;
} else {
const project = await projectsApi.create(
createdCompanyId,
buildOnboardingProjectPayload(goalId)
);
projectId = project.id;
queryClient.invalidateQueries({
queryKey: queryKeys.projects.list(createdCompanyId)
});
}
setCreatedProjectId(projectId);
}
if (!createdIssueRef) {
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
title: DEFAULT_TASK_TITLE,
description: DEFAULT_TASK_DESCRIPTION,
assigneeAgentId: createdAgentId,
projectId,
goalId
})
);
setCreatedIssueRef(issue.identifier ?? issue.id);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId)
});
}
const prefix = createdCompanyPrefix;
setSelectedCompanyId(createdCompanyId);
reset();
closeOnboarding();
navigate(prefix ? `/${prefix}/dashboard` : "/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to launch first task");
} finally {
setLoading(false);
}
}
function buildAdapterConfig(): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
model:
adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor"
? model || DEFAULT_CURSOR_LOCAL_MODEL
: adapterType === "opencode_local"
? model || DEFAULT_OPENCODE_LOCAL_MODEL
: model,
command,
args,
url,
dangerouslySkipPermissions:
adapterType === "claude_local" || adapterType === "opencode_local",
dangerouslyBypassSandbox:
adapterType === "codex_local"
? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
: defaultCreateValues.dangerouslyBypassSandbox
});
if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
const env =
typeof config.env === "object" &&
config.env !== null &&
!Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
}
return config;
}
async function runAdapterEnvironmentTest(
adapterConfigOverride?: Record<string, unknown>
): Promise<AdapterEnvironmentTestResult | null> {
if (!createdCompanyId) {
setAdapterEnvError(
"Create or select a company before testing adapter environment."
);
return null;
}
setAdapterEnvLoading(true);
setAdapterEnvError(null);
try {
const result = await agentsApi.testEnvironment(
createdCompanyId,
adapterType,
{
adapterConfig: adapterConfigOverride ?? buildAdapterConfig()
}
);
setAdapterEnvResult(result);
return result;
} catch (err) {
setAdapterEnvError(
err instanceof Error ? err.message : "Adapter environment test failed"
);
return null;
} finally {
setAdapterEnvLoading(false);
}
}
// Step 2 → 3 ("Confirm mission"): create the company + its company-level
// goal, then advance to naming the team lead. Guarded so revisiting the
// mission step (e.g. via Back) doesn't create a duplicate company.
async function handleConfirmMission() {
if (createdCompanyId) {
setStep(3);
return;
}
setLoading(true);
setError(null);
try {
const company = await companiesApi.create({ name: companyName.trim() });
setCreatedCompanyId(company.id);
setCreatedCompanyPrefix(company.issuePrefix);
setSelectedCompanyId(company.id);
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const parsedGoal = parseOnboardingGoalInput(companyGoal);
const goal = await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
: {}),
level: "company",
status: "active"
});
setCreatedCompanyGoalId(goal.id);
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
setStep(3); // → Create your team lead
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create company");
} finally {
setLoading(false);
}
}
// Step 4 → 5 ("Give it a heartbeat"): hire the lead agent + seed its
// instructions, then advance to Review. Guarded so revisiting step 4
// doesn't hire a second agent.
async function handleGiveHeartbeat() {
if (!createdCompanyId) return;
if (createdAgentId) {
setStep(5);
return;
}
setLoading(true);
setError(null);
try {
if (adapterType === "opencode_local") {
const selectedModelId = model.trim();
if (!isValidOpenCodeModelId(selectedModelId)) {
setError(
"OpenCode requires an explicit model in provider/model format."
);
return;
}
if (adapterModelsError) {
setError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models."
);
return;
}
if (adapterModelsLoading || adapterModelsFetching) {
setError(
"OpenCode models are still loading. Please wait and try again."
);
return;
}
const discoveredModels = adapterModels ?? [];
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
setError(
discoveredModels.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModelId}`
);
return;
}
}
if (isLocalAdapter) {
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
if (!result) return;
}
const hire = await agentsApi.hire(createdCompanyId, {
name: agentName.trim(),
role: "ceo",
adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: buildNewAgentRuntimeConfig()
});
if (hire.approval) {
await approvalsApi.approve(
hire.approval.id,
"Approved during onboarding first-agent setup."
);
queryClient.invalidateQueries({
queryKey: queryKeys.approvals.list(createdCompanyId)
});
}
const agent = hire.agent;
setCreatedAgentId(agent.id);
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
// Seed the CEO's agent instructions file so the agent always has
// company context + a hiring-plan output format rule. Non-fatal on
// failure — the agent can still function with adapter defaults.
try {
const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
await agentsApi.saveInstructionsFile(
agent.id,
{
path: bundle.entryFile,
content: composeCeoInstructions({
companyName,
companyGoal,
growPath: onboardingPath === "grow",
growWorkflows,
growPainPoints,
growAutomate,
q1, q2, q3, q4,
}),
},
createdCompanyId,
);
} catch (err) {
console.warn("Failed to seed CEO instructions:", err);
}
// Advance to the Review step — the lead is now online. The user drives
// strategy + hiring from the planning chat after "Get started".
setStep(5);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create agent");
} finally {
setLoading(false);
}
}
async function handleUnsetAnthropicApiKey() {
if (!createdCompanyId || unsetAnthropicLoading) return;
setUnsetAnthropicLoading(true);
setError(null);
setAdapterEnvError(null);
setForceUnsetAnthropicApiKey(true);
const configWithUnset = (() => {
const config = buildAdapterConfig();
const env =
typeof config.env === "object" &&
config.env !== null &&
!Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {};
env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
config.env = env;
return config;
})();
try {
if (createdAgentId) {
await agentsApi.update(
createdAgentId,
{ adapterConfig: configWithUnset },
createdCompanyId
);
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
}
const result = await runAdapterEnvironmentTest(configWithUnset);
if (result?.status === "fail") {
setError(
"Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing."
);
}
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to unset ANTHROPIC_API_KEY and retry."
);
} finally {
setUnsetAnthropicLoading(false);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (step === 0) return; // front door requires click
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 === 5) handleLaunchToDashboard();
}
}
if (!effectiveOnboardingOpen) return null;
const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);
return (
<Dialog
open={effectiveOnboardingOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<DialogPortal>
{/* Plain div instead of DialogOverlay — Radix's overlay wraps in
RemoveScroll which blocks wheel events on our custom (non-DialogContent)
scroll container. A plain div preserves the background without scroll-locking. */}
<div className="fixed inset-0 z-50 bg-background" />
<div className="fixed inset-0 z-50 flex" onKeyDown={handleKeyDown}>
{/* Close button */}
<button
onClick={handleClose}
className="absolute top-4 left-4 z-10 rounded-sm p-1.5 text-muted-foreground/60 hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</button>
{/* Step 0: Front Door — full-screen choice */}
{step === 0 && (
<div className="w-full flex flex-col overflow-y-auto">
<FrontDoor onChoose={(path) => {
setOnboardingPath(path);
setStep(1);
}} />
</div>
)}
{/* Left half — form (steps 1+) */}
{step !== 0 && (
<div
className={cn(
"w-full flex flex-col overflow-y-auto transition-(--tp-width) duration-500 ease-in-out",
step === 1 || step === 2 ? "md:w-1/2" : "md:w-full"
)}
>
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
{/* 5-segment progress bar (brand .wsteps/.wstep) — segment N
filled once step ≥ N. Completed segments jump back. */}
<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;
return (
<button
key={s}
type="button"
aria-label={`Step ${s}`}
aria-current={s === step ? "step" : undefined}
disabled={!canJump}
onClick={() => canJump && setStep(s as Step)}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
filled ? "bg-foreground" : "bg-muted",
canJump ? "cursor-pointer" : "cursor-default"
)}
/>
);
})}
</div>
{/* Persistent evolving capsule (steps 3–5): a single AgentCapsule
held in the same tree slot so React reuses the DOM node and the
morph reads as one capsule coming to life — dashed slot →
solid (configured) → liquid fill + blue glow (online). */}
{step >= 3 && step <= 5 && (
<div className="space-y-4 mb-6">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
{step === 5 ? (
<Check className="h-5 w-5 text-muted-foreground" />
) : (
<Bot className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<h3 className="font-medium">
{step === 3
? "Create your team lead"
: step === 4
? "Connect a model"
: "Review"}
</h3>
<p className="text-xs text-muted-foreground">
{step === 3 ? (
<>
Name your lead. They'll help drive{" "}
<span className="font-medium text-foreground">{companyName}</span>{" "}
toward its mission. We default to{" "}
<span className="font-medium text-foreground">Chief of staff</span> —
rename it to anything you like.
</>
) : step === 4 ? (
<>Pick the adapter and model your lead will run on, then check the environment.</>
) : (
<>Everything's set up — your team lead is online and ready to work.</>
)}
</p>
</div>
</div>
<div className="flex flex-col items-center gap-1.5 py-1 text-center">
<AgentCapsule
state={step === 3 ? "slot" : step === 4 ? "configured" : "online"}
gradient={5}
glow="blue"
size="md"
/>
<p className="text-(length:--text-micro) text-muted-foreground">
{step === 3 ? (
"an empty slot for an agent"
) : step === 4 ? (
"your team lead, taking shape"
) : (
<>
<span className="font-medium text-foreground">{agentName}</span>{" "}
is online and ready to work!
</>
)}
</p>
</div>
</div>
)}
{/* Step content */}
{step === 2 && onboardingPath === "grow" && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Tell us about your team</h3>
<p className="text-xs text-muted-foreground">
We'll use this to set up your lead agent and plan which agents to add.
</p>
</div>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">What does your team work on?</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. We create educational YouTube content about AI"
value={q1}
onChange={(e) => setQ1(e.target.value)}
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">What are your current workflows?</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-(--sz-60px)"
placeholder="e.g. Manual content creation, spreadsheet tracking, email outreach"
value={growWorkflows}
onChange={(e) => setGrowWorkflows(e.target.value)}
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">What pain points would you solve with AI?</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-(--sz-60px)"
placeholder="e.g. Can't produce content fast enough, no time for social media"
value={growPainPoints}
onChange={(e) => setGrowPainPoints(e.target.value)}
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">What would you automate first?</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Social media scheduling and content repurposing"
value={growAutomate}
onChange={(e) => setGrowAutomate(e.target.value)}
/>
</div>
{companyName.trim() && q1.trim() && (
<>
{!companyGoal.trim() && (
<Button
size="sm"
variant="outline"
onClick={() => {
const parts = [q1.trim()];
if (growPainPoints.trim()) parts.push(`Key challenge: ${growPainPoints.trim()}`);
if (growAutomate.trim()) parts.push(`First priority: automate ${growAutomate.trim().toLowerCase()}`);
setCompanyGoal(parts.join(". "));
}}
>
Generate mission from answers
</Button>
)}
{companyGoal.trim() && (
<div className="group">
<label className="text-xs text-foreground mb-1 block">Generated mission — edit however you like:</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-(--sz-60px)"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
/>
</div>
)}
</>
)}
<button
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setOnboardingPath(null); setStep(0); }}
>
← Back to start
</button>
</div>
)}
{/* Step 1: Name your company (both paths) */}
{step === 1 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Name your company</h3>
<p className="text-xs text-muted-foreground">
What should we call your company?
</p>
</div>
</div>
<div className="mt-3 group">
<label
className={cn(
"text-xs mb-1 block transition-colors",
companyName.trim()
? "text-foreground"
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Company name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Acme Corp"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && companyName.trim()) {
e.preventDefault();
if (onboardingPath !== "grow" && !missionPath) setMissionPath("direct");
setStep(2);
}
}}
autoFocus
/>
</div>
<button
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setOnboardingPath(null); setStep(0); }}
>
← Back to start
</button>
</div>
)}
{/* Step 2: Define your mission */}
{step === 2 && onboardingPath !== "grow" && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Define your mission</h3>
<p className="text-xs text-muted-foreground">
Your mission guides everything — your lead agent, who you bring on, and the work <strong>{companyName}</strong> takes on.
</p>
</div>
</div>
{/* Mission path selector */}
<div className="space-y-3">
<label className="text-xs text-foreground block">
How would you like to define your mission?
</label>
<div className="grid grid-cols-2 gap-2">
<button
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
missionPath === "direct"
? "border-foreground bg-accent/50"
: "border-border hover:bg-accent/50"
)}
onClick={() => setMissionPath("direct")}
>
<Sparkles className="h-4 w-4" />
<span className="font-medium">I know my mission</span>
<span className="text-muted-foreground text-(length:--text-nano)">
Type it directly
</span>
</button>
<button
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
missionPath === "questionnaire"
? "border-foreground bg-accent/50"
: "border-border hover:bg-accent/50"
)}
onClick={() => setMissionPath("questionnaire")}
>
<ListTodo className="h-4 w-4" />
<span className="font-medium">Help me figure it out</span>
<span className="text-muted-foreground text-(length:--text-nano)">
Answer a few questions
</span>
</button>
</div>
</div>
{/* Direct mission input */}
{missionPath === "direct" && (
<div className="space-y-3 animate-in fade-in duration-200">
<div className="group">
<label
className={cn(
"text-xs mb-1 block transition-colors",
companyGoal.trim()
? "text-foreground"
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Mission
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-(--sz-60px)"
placeholder="What is your team trying to achieve?"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
autoFocus
/>
</div>
{/* Prompt chips for inspiration */}
<div className="flex flex-wrap gap-1.5">
{MISSION_PROMPT_CHIPS.map((chip) => (
<button
key={chip}
className={cn(
"rounded-full border px-2.5 py-1 text-(length:--text-micro) transition-colors",
companyGoal === chip
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50"
)}
onClick={() => setCompanyGoal(chip)}
>
{chip}
</button>
))}
</div>
</div>
)}
{/* Questionnaire path */}
{missionPath === "questionnaire" && !missionConfirmed && (
<div className="space-y-3 animate-in fade-in duration-200">
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
What does your team work on?
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. We create educational YouTube content about AI"
value={q1}
onChange={(e) => setQ1(e.target.value)}
autoFocus
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
Who do you serve?
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Non-technical professionals curious about AI tools"
value={q2}
onChange={(e) => setQ2(e.target.value)}
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
What's your biggest bottleneck right now?
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Can't produce content fast enough across multiple channels"
value={q3}
onChange={(e) => setQ3(e.target.value)}
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
What would success look like in 6 months?
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. Publishing daily content across 4 platforms with a team of AI agents"
value={q4}
onChange={(e) => setQ4(e.target.value)}
/>
</div>
{q1.trim() && (
<Button
size="sm"
variant="outline"
onClick={() => {
setCompanyGoal(buildMissionFromQuestionnaire(q1, q2, q3, q4));
setMissionConfirmed(true);
}}
>
Generate my mission
</Button>
)}
</div>
)}
{/* Questionnaire result — editable mission */}
{missionPath === "questionnaire" && missionConfirmed && (
<div className="space-y-3 animate-in fade-in duration-200">
<div className="group">
<label className="text-xs text-foreground mb-1 block">
Here's your draft mission — edit it however you like:
</label>
<textarea
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-(--sz-80px)"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
autoFocus
/>
</div>
<button
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setMissionConfirmed(false); setCompanyGoal(""); }}
>
← Back to questions
</button>
</div>
)}
{/* Confirm mission note */}
{companyGoal.trim() && (
<p className="text-(length:--text-micro) text-muted-foreground italic">
You can always change your mission later in settings.
</p>
)}
<button
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setStep(1)}
>
← Change company name
</button>
</div>
)}
{/* Step 3: Create your team lead — name only (capsule above) */}
{step === 3 && (
<div className="space-y-5">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="Chief of staff"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && agentName.trim()) {
e.preventDefault();
setStep(4);
}
}}
autoFocus
/>
</div>
</div>
)}
{/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
{step === 4 && (
<div className="space-y-5">
{/* Adapter type radio cards */}
<div>
<label className="text-xs text-muted-foreground mb-2 block">
Adapter type
</label>
<div className="grid grid-cols-2 gap-2">
{recommendedAdapters.map((opt) => (
<button
key={opt.type}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
adapterType === opt.type
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
const nextType = opt.type;
setAdapterType(nextType);
if (nextType === "codex_local") {
return;
}
if (nextType === "opencode_local") {
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
}}
>
{opt.recommended && (
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-(length:--text-nano) font-semibold px-1.5 py-0.5 rounded-full leading-none">
Recommended
</span>
)}
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-(length:--text-nano)">
{opt.description}
</span>
</button>
))}
</div>
<button
className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowMoreAdapters((v) => !v)}
>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
showMoreAdapters ? "rotate-0" : "-rotate-90"
)}
/>
More Agent Adapter Types
</button>
{showMoreAdapters && (
<div className="grid grid-cols-2 gap-2 mt-2">
{moreAdapters.map((opt) => (
<button
key={opt.type}
disabled={!!opt.comingSoon}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
opt.comingSoon
? "border-border opacity-40 cursor-not-allowed"
: adapterType === opt.type
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
const nextType = opt.type;
setAdapterType(nextType);
if (nextType === "gemini_local" && !model) {
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
return;
}
if (nextType === "cursor" && !model) {
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
return;
}
if (nextType === "opencode_local") {
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
}}
>
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-(length:--text-nano)">
{opt.comingSoon
? opt.disabledLabel ?? "Coming soon"
: opt.description}
</span>
</button>
))}
</div>
)}
</div>
{/* Conditional adapter fields */}
{isLocalAdapter && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
</label>
<Popover
open={modelOpen}
onOpenChange={(next) => {
setModelOpen(next);
if (!next) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span
className={cn(
!model && "text-muted-foreground"
)}
>
{selectedModel
? selectedModel.label
: model ||
(adapterType === "opencode_local"
? "Select model (required)"
: "Default")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-(--radix-popover-trigger-width) p-1"
align="start"
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{adapterType !== "opencode_local" && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!model && "bg-accent"
)}
onClick={() => {
setModel("");
setModelOpen(false);
}}
>
Default
</button>
)}
<div className="max-h-(--sz-240px) overflow-y-auto">
{groupedModels.map((group) => (
<div
key={group.provider}
className="mb-1 last:mb-0"
>
{adapterType === "opencode_local" && (
<div className="px-2 py-1 text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === model && "bg-accent"
)}
onClick={() => {
setModel(m.id);
setModelOpen(false);
}}
>
<span
className="block w-full text-left truncate"
title={m.id}
>
{adapterType === "opencode_local"
? extractModelName(m.id)
: m.label}
</span>
</button>
))}
</div>
))}
</div>
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">
No models discovered.
</p>
)}
</PopoverContent>
</Popover>
</div>
</div>
)}
{isLocalAdapter && (
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium">
Adapter environment check
</p>
<p className="text-(length:--text-micro) text-muted-foreground">
Runs a live probe that asks the adapter CLI to
respond with hello.
</p>
</div>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={adapterEnvLoading}
onClick={() => void runAdapterEnvironmentTest()}
>
{adapterEnvLoading ? "Testing..." : "Test now"}
</Button>
</div>
{adapterEnvError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-(length:--text-micro) text-destructive">
{adapterEnvError}
</div>
)}
{adapterEnvResult &&
adapterEnvResult.status === "pass" ? (
<div className="flex items-center gap-2 rounded-md border border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10 px-3 py-2 text-xs text-green-700 dark:text-green-300 animate-in fade-in slide-in-from-bottom-1 duration-300">
<Check className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium">Passed</span>
</div>
) : adapterEnvResult ? (
<AdapterEnvironmentResult result={adapterEnvResult} />
) : null}
{shouldSuggestUnsetAnthropicApiKey && (
<div className="rounded-md border border-amber-300/60 bg-amber-50/40 px-2.5 py-2 space-y-2">
<p className="text-(length:--text-micro) text-amber-900/90 leading-relaxed">
Claude failed while{" "}
<span className="font-mono">ANTHROPIC_API_KEY</span>{" "}
is set. You can clear it in this adapter config
and retry the probe.
</p>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={
adapterEnvLoading || unsetAnthropicLoading
}
onClick={() => void handleUnsetAnthropicApiKey()}
>
{unsetAnthropicLoading
? "Retrying..."
: "Unset ANTHROPIC_API_KEY"}
</Button>
</div>
)}
{adapterEnvResult && adapterEnvResult.status === "fail" && (
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-2 text-(length:--text-micro) space-y-1.5">
<p className="font-medium">Manual debug</p>
<p className="text-muted-foreground font-mono break-all">
{adapterType === "cursor"
? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
: adapterType === "codex_local"
? `${effectiveAdapterCommand} exec --json -`
: adapterType === "gemini_local"
? `${effectiveAdapterCommand} --output-format json "Respond with hello."`
: adapterType === "opencode_local"
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
</p>
<p className="text-muted-foreground">
Prompt:{" "}
<span className="font-mono">Respond with hello.</span>
</p>
{adapterType === "cursor" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ? (
<p className="text-muted-foreground">
If auth fails, set{" "}
<span className="font-mono">
{adapterType === "cursor"
? "CURSOR_API_KEY"
: adapterType === "gemini_local"
? "GEMINI_API_KEY"
: "OPENAI_API_KEY"}
</span>{" "}
in env or run{" "}
<span className="font-mono">
{adapterType === "cursor"
? "agent login"
: adapterType === "codex_local"
? "codex login"
: adapterType === "gemini_local"
? "gemini auth"
: "opencode auth login"}
</span>
.
</p>
) : (
<p className="text-muted-foreground">
If login is required, run{" "}
<span className="font-mono">claude login</span>{" "}
and retry.
</p>
)}
</div>
)}
</div>
)}
{(adapterType === "http" ||
adapterType === "openclaw_gateway") && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{adapterType === "openclaw_gateway"
? "Gateway URL"
: "Webhook URL"}
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder={
adapterType === "openclaw_gateway"
? "ws://127.0.0.1:18789"
: "https://..."
}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
)}
</div>
)}
{/* Step 5: Review — lead is online (shared capsule above) */}
{step === 5 && (
<div className="space-y-5 py-1">
{/* Review checklist — everything that's now set up */}
<div className="space-y-1.5">
{[
{ label: "Company name", done: Boolean(companyName.trim()) },
{ label: "Mission", done: Boolean(companyGoal.trim()) },
{ label: "Agent created", done: Boolean(createdAgentId) },
{ label: "Model connected", done: Boolean(createdAgentId) },
].map(({ label, done }) => (
<div key={label} className="flex items-center gap-2 text-sm">
<span
className={cn(
"flex h-4 w-4 items-center justify-center rounded-full shrink-0",
done
? "bg-green-500/15 text-green-600 dark:text-green-400"
: "bg-muted text-muted-foreground"
)}
>
<Check className="h-2.5 w-2.5" />
</span>
<span className={done ? "text-foreground" : "text-muted-foreground"}>
{label}
</span>
</div>
))}
</div>
{companyGoal.trim() && (
<p className="text-sm text-muted-foreground italic text-center">
"{companyGoal}"
</p>
)}
<p className="text-xs text-muted-foreground text-center">
We'll create the first task for {agentName} and take you to the dashboard.
</p>
</div>
)}
{/* Error */}
{visibleError && (
<div className="mt-3">
<p className="text-xs text-destructive">{visibleError}</p>
</div>
)}
{/* Footer navigation */}
<div className="flex items-center justify-between mt-8">
<div>
{step > 1 && step > (effectiveOnboardingOptions.initialStep ?? 0) && (
<Button
variant="ghost"
size="sm"
onClick={() => setStep((step - 1) as Step)}
disabled={loading}
>
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back
</Button>
)}
</div>
<div className="flex items-center gap-2">
{step === 1 && (
<Button
size="sm"
disabled={!companyName.trim()}
onClick={() => {
if (onboardingPath !== "grow" && !missionPath) setMissionPath("direct");
setStep(2);
}}
>
Next
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
)}
{step === 2 && (
<Button
size="sm"
disabled={!companyName.trim() || !companyGoal.trim() || loading}
onClick={handleConfirmMission}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating..." : "Confirm mission"}
</Button>
)}
{step === 3 && (
<Button
size="sm"
disabled={!agentName.trim()}
onClick={() => setStep(4)}
>
Next
<ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
)}
{step === 4 && (
<Button
size="sm"
disabled={!agentName.trim() || loading || adapterEnvLoading}
onClick={handleGiveHeartbeat}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Bringing to life..." : "Give it a heartbeat"}
</Button>
)}
{step === 5 && (
<Button
size="sm"
onClick={handleLaunchToDashboard}
disabled={loading || launchStateIncomplete}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Launching..." : "Get started"}
</Button>
)}
</div>
</div>
</div>
</div>
)}
{/* Right half — ASCII art (hidden on mobile, only for the team
name + mission steps) */}
<div
className={cn(
"hidden md:block overflow-hidden bg-(--hex-1d1d1d) transition-(--tp-width-opacity) duration-500 ease-in-out",
step === 1 || step === 2 ? "w-1/2 opacity-100" : "w-0 opacity-0"
)}
>
<AsciiArtAnimation />
</div>
</div>
</DialogPortal>
</Dialog>
);
}
function AdapterEnvironmentResult({
result
}: {
result: AdapterEnvironmentTestResult;
}) {
const statusLabel =
result.status === "pass"
? "Passed"
: result.status === "warn"
? "Warnings"
: "Failed";
const statusClass =
result.status === "pass"
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
: result.status === "warn"
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
return (
<div className={`rounded-md border px-2.5 py-2 text-(length:--text-micro) ${statusClass}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{statusLabel}</span>
<span className="opacity-80">
{new Date(result.testedAt).toLocaleTimeString()}
</span>
</div>
<div className="mt-1.5 space-y-1">
{result.checks.map((check, idx) => (
<div
key={`${check.code}-${idx}`}
className="leading-relaxed break-words"
>
<span className="font-medium uppercase tracking-wide opacity-80">
{check.level}
</span>
<span className="mx-1 opacity-60">·</span>
<span>{check.message}</span>
{check.detail && (
<span className="block opacity-75 break-all">
({check.detail})
</span>
)}
{check.hint && (
<span className="block opacity-90 break-words">
Hint: {check.hint}
</span>
)}
</div>
))}
</div>
</div>
);
}