nexus/ui/src/components/OnboardingWizard.tsx
scotttong f312f22f27 experiment: unify board chat — Board Room, legacy redirect, split pane
- Remove legacy Chat page and CEOChatPanel; board concierge lives at /board-chat only
- Redirect /chat to /board-chat (preserve search and hash)
- Sidebar: single 'Board Room' nav item; drop duplicate Chat/Board Chat entries
- Breadcrumbs: label board-chat as 'Board Room' when a single crumb
- BoardChat: resizable chat + Agent Feed column, feed filter menu, starter
  prompts, bubble/input/status polish
- Onboarding: post-wizard launch targets board-chat where applicable
- Layout/index.css and dev-fresh-chat.sh: small spacing/script alignment

Made-with: Cursor
2026-03-22 02:09:34 -07:00

2348 lines
99 KiB
TypeScript

import { useEffect, useState, useRef, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
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 { issuesApi } from "../api/issues";
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 { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL
} 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 { OnboardingChat } from "./OnboardingChat";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { FrontDoor } from "./FrontDoor";
import {
Building2,
Bot,
Code,
Gem,
ListTodo,
Rocket,
ArrowLeft,
ArrowRight,
Terminal,
Sparkles,
MousePointer2,
Check,
Loader2,
FolderOpen,
ChevronDown,
X,
Plus,
Pencil,
Trash2,
MessageSquare
} from "lucide-react";
type Step = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type AdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "process"
| "http"
| "openclaw_gateway";
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(" ");
}
interface HiringRole {
id: string;
name: string;
summary: string;
expertise: string;
priorities: string;
boundaries: string;
tools: string;
communication: string;
collaboration: string;
enabled: boolean;
editing: boolean;
}
function nextRoleId(): string {
return crypto.randomUUID();
}
const EMPTY_ROLE: Omit<HiringRole, "id"> = {
name: "", summary: "", expertise: "", priorities: "",
boundaries: "", tools: "", communication: "", collaboration: "",
enabled: true, editing: true,
};
function cleanMd(s: string): string {
return s
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/^\s*[-*]\s+/, "")
.trim();
}
/**
* Map a bullet label (e.g. "Why:", "Responsibilities:") to a structured field.
*/
function classifyBullet(label: string): keyof HiringRole | null {
const l = label.toLowerCase();
if (/^why|^purpose|^overview/.test(l)) return "summary";
if (/^responsibilit|^expertise|^duties|^scope|^what they do/.test(l)) return "expertise";
if (/^priorit|^focus|^goals|^kpi|^metric/.test(l)) return "priorities";
if (/^boundar|^limit|^should not|^don.?t|^avoid|^out of scope/.test(l)) return "boundaries";
if (/^tool|^permission|^access|^tech|^stack/.test(l)) return "tools";
if (/^communic|^tone|^style|^voice/.test(l)) return "communication";
if (/^collaborat|^escalat|^report|^works with|^interact|^coordinat/.test(l)) return "collaboration";
if (/^recommend|^profile|^ideal|^skills|^qualif/.test(l)) return "expertise";
return null;
}
/**
* Parse a markdown hiring plan into structured roles.
* Handles two document formats:
* Format A: "## Role N: Name" with ### sub-sections (Priorities, Boundaries, etc.)
* Format B: "### N. Name" with **Label:** bullets
* Fallback: comment-style bullet/table patterns.
*/
function parseHiringPlan(markdown: string): HiringRole[] {
const roles: HiringRole[] = [];
const seen = new Set<string>();
// Find role headings: any ## or ### heading with a numbered prefix
// like "### 1. Content Marketing Officer" or "## Role 2: CTO"
const rolePattern = /^(?:role\s*\d+[:.]\s*|\d+[.)]\s*)/i;
const roleHeadingRegex = /^#{2,3}\s+(.+)$/gm;
let match: RegExpExecArray | null;
// First pass: find all role heading positions (start of line, end of heading)
const rolePositions: Array<{ title: string; lineStart: number; contentStart: number }> = [];
while ((match = roleHeadingRegex.exec(markdown)) !== null) {
if (rolePattern.test(match[1].trim())) {
rolePositions.push({
title: match[1].trim(),
lineStart: match.index,
contentStart: match.index + match[0].length,
});
}
}
// Extract body for each role (from heading end to the next role heading start)
const sections: Array<{ title: string; body: string }> = [];
for (let i = 0; i < rolePositions.length; i++) {
const end = i + 1 < rolePositions.length
? rolePositions[i + 1].lineStart
: markdown.length;
sections.push({
title: rolePositions[i].title,
body: markdown.slice(rolePositions[i].contentStart, end),
});
}
for (const section of sections) {
if (!rolePattern.test(section.title)) continue;
let name = section.title
.replace(/^role\s*\d*[:.]\s*/i, "")
.replace(/^\d+[.)]\s*/, "")
.replace(/\*\*/g, "")
.trim();
if (name.length < 3) continue;
if (seen.has(name.toLowerCase())) continue;
// Parse content: **Label:** bullets and ### sub-sections
const fields: Record<string, string[]> = {};
let currentField: string | null = null;
const bodyLines = section.body.split("\n");
for (let i = 0; i < bodyLines.length; i++) {
const raw = bodyLines[i];
const trimmed = raw.trim();
if (!trimmed) continue;
// ### sub-section heading (e.g. "### Priorities")
const subHeadingMatch = trimmed.match(/^###\s+(.+)/);
if (subHeadingMatch) {
const label = subHeadingMatch[1].trim();
const field = classifyBullet(label);
currentField = (field && field !== "id" && field !== "name" && field !== "enabled" && field !== "editing")
? field : "expertise";
continue;
}
// **Label:** inline (e.g. "**Why:** text")
const boldLabelMatch = trimmed.match(/^\*\*([^*:]+)[*:]*\*\*[:\s]*(.*)/);
const bulletLabelMatch = !boldLabelMatch && trimmed.match(/^\s*[-*]\s+\*\*([^*:]+)[*:]*\*\*[:\s]*(.*)/);
const labelMatch = boldLabelMatch ?? bulletLabelMatch;
if (labelMatch) {
const label = labelMatch[1]!.trim();
const value = cleanMd(labelMatch[2] ?? "");
const field = classifyBullet(label);
currentField = (field && field !== "id" && field !== "name" && field !== "enabled" && field !== "editing")
? field : "expertise";
if (!fields[currentField]) fields[currentField] = [];
if (value) fields[currentField].push(value);
continue;
}
// Regular content line under current field
if (currentField) {
const cleaned = cleanMd(trimmed);
if (cleaned) {
if (!fields[currentField]) fields[currentField] = [];
fields[currentField].push(cleaned);
}
}
}
const join = (arr?: string[]) => (arr ?? []).join("\n");
// If no summary, use first line of expertise
let summary = join(fields.summary);
let expertise = join(fields.expertise);
if (!summary && expertise) {
const lines = expertise.split("\n");
summary = lines[0];
expertise = lines.slice(1).join("\n");
}
seen.add(name.toLowerCase());
roles.push({
id: nextRoleId(),
name,
summary,
expertise,
priorities: join(fields.priorities),
boundaries: join(fields.boundaries),
tools: join(fields.tools),
communication: join(fields.communication),
collaboration: join(fields.collaboration),
enabled: true,
editing: false,
});
}
// Fallback: parse "N. **Role Name**" with indented bullets
if (roles.length === 0) {
const lines = markdown.split("\n");
let currentRole: HiringRole | null = null;
for (const line of lines) {
// Match numbered bold role: "1. **Content Strategist / CMO**"
const roleMatch = line.match(/^\s*(\d+)[.)]\s+\*\*([^*]+)\*\*/);
if (roleMatch) {
const name = roleMatch[2].trim();
const skip = /^(phase|month|step|update|note|question|summary|timeline|priority|plan|total|budget|immediate|hire)/i;
if (skip.test(name) || name.length < 3) continue;
if (seen.has(name.toLowerCase())) continue;
if (currentRole) roles.push(currentRole);
seen.add(name.toLowerCase());
currentRole = {
id: nextRoleId(), name, summary: "", expertise: "",
priorities: "", boundaries: "", tools: "",
communication: "", collaboration: "",
enabled: true, editing: false,
};
continue;
}
// Indented bullets under the current role
if (currentRole && /^\s{2,}[-*]/.test(line)) {
const cleaned = cleanMd(line);
if (!cleaned) continue;
// Check for labeled bullet: "*Why first:*", "**Tools:**", etc.
const labelMatch = cleaned.match(/^\*?([^:*]+)\*?:\s*(.*)/);
if (labelMatch) {
const field = classifyBullet(labelMatch[1].trim());
if (field && typeof currentRole[field] === "string") {
const val = labelMatch[2].trim();
const prev = currentRole[field] as string;
(currentRole as unknown as Record<string, unknown>)[field] = prev
? `${prev}\n${val}` : val;
continue;
}
}
// Default: add to expertise
currentRole.expertise = currentRole.expertise
? `${currentRole.expertise}\n${cleaned}` : cleaned;
}
}
if (currentRole) roles.push(currentRole);
// If summary is empty, use the first line of expertise as the summary
for (const role of roles) {
if (!role.summary && role.expertise) {
const firstLine = role.expertise.split("\n")[0];
if (firstLine) {
role.summary = firstLine;
role.expertise = role.expertise.split("\n").slice(1).join("\n");
}
}
}
}
return roles;
}
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here:
https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md
Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`;
const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
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 } = useDialog();
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const navigate = useNavigate();
const initialStep = onboardingOptions.initialStep ?? 0;
const existingCompanyId = onboardingOptions.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) ?? "CEO");
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);
// Step 3
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
const [taskDescription, setTaskDescription] = useState(
DEFAULT_TASK_DESCRIPTION
);
// Auto-grow textarea for task description
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autoResizeTextarea = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, []);
// Planning task + hiring plan
const [planningTaskId, setPlanningTaskId] = useState<string | null>((saved?.planningTaskId as string) ?? null);
const [planContent, setPlanContent] = useState<string | null>((saved?.planContent as string) ?? null);
const [hiringRoles, setHiringRoles] = useState<HiringRole[]>((saved?.hiringRoles as HiringRole[]) ?? []);
const [showRawPlan, setShowRawPlan] = 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 [createdIssueRef, setCreatedIssueRef] = useState<string | null>(null);
// Sync step and company when onboarding opens with explicit options.
// Only override saved state when onboardingOptions explicitly provides values.
useEffect(() => {
if (!onboardingOpen) return;
// If explicit options are provided, they take precedence over saved state
if (onboardingOptions.initialStep) {
setStep(onboardingOptions.initialStep);
}
if (onboardingOptions.companyId) {
setCreatedCompanyId(onboardingOptions.companyId);
setCreatedCompanyPrefix(null);
}
}, [
onboardingOpen,
onboardingOptions.companyId,
onboardingOptions.initialStep
]);
// Backfill issue prefix for an existing company once companies are loaded.
useEffect(() => {
if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
const company = companies.find((c) => c.id === createdCompanyId);
if (company) setCreatedCompanyPrefix(company.issuePrefix);
}, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
// Persist wizard state to localStorage on every change
useEffect(() => {
if (!onboardingOpen) return;
const state = {
step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
planningTaskId, planContent, hiringRoles,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
};
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(state));
}, [
onboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
planningTaskId, planContent, hiringRoles,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
]);
// Resize textarea when step 3 is shown or description changes
useEffect(() => {
// Auto-resize removed — task description textarea no longer used in onboarding
}, [step, autoResizeTextarea]);
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching
} = useQuery({
queryKey: createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 3
});
const isLocalAdapter =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "cursor";
const effectiveAdapterCommand =
command.trim() ||
(adapterType === "codex_local"
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude");
useEffect(() => {
if (step !== 3) return;
setAdapterEnvResult(null);
setAdapterEnvError(null);
}, [step, adapterType, cwd, 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("");
setPlanningTaskId(null);
setPlanContent(null);
setHiringRoles([]);
setShowRawPlan(false);
setAgentName("CEO");
setAdapterType("claude_local");
setCwd("");
setModel("");
setCommand("");
setArgs("");
setUrl("");
setAdapterEnvResult(null);
setAdapterEnvError(null);
setAdapterEnvLoading(false);
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setTaskTitle("Create your CEO HEARTBEAT.md");
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
setCreatedIssueRef(null);
}
function handleClose() {
reset();
closeOnboarding();
}
function handleLaunchToChat() {
const prefix = createdCompanyPrefix;
reset();
closeOnboarding();
navigate(prefix ? `/${prefix}/board-chat` : "/dashboard");
}
function buildAdapterConfig(): Record<string, unknown> {
const adapter = getUIAdapter(adapterType);
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
cwd,
model:
adapterType === "codex_local"
? model || DEFAULT_CODEX_LOCAL_MODEL
: adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor"
? model || DEFAULT_CURSOR_LOCAL_MODEL
: model,
command,
args,
url,
dangerouslySkipPermissions: adapterType === "claude_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);
}
}
async function handleStep1Next() {
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);
await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
: {}),
level: "company",
status: "active"
});
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
setStep(2); // → CEO config (was celebration, now swapped)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create company");
} finally {
setLoading(false);
}
}
async function handleStep2Next() {
if (!createdCompanyId) return;
setLoading(true);
setError(null);
try {
if (adapterType === "opencode_local") {
const selectedModelId = model.trim();
if (!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 agent = await agentsApi.create(createdCompanyId, {
name: agentName.trim(),
role: "ceo",
adapterType,
adapterConfig: buildAdapterConfig(),
runtimeConfig: {
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1
}
}
});
setCreatedAgentId(agent.id);
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
// Create the planning task unassigned — the CEO only gets assigned
// when the user sends their first message (user controls initiation)
const isGrowPath = onboardingPath === "grow";
const growContext = isGrowPath
? `\n\nExisting workflows: ${growWorkflows}\nPain points: ${growPainPoints}\nFirst automation priority: ${growAutomate}`
: "";
const planningIssue = await issuesApi.create(createdCompanyId, {
title: isGrowPath ? "Plan AI agents for existing company" : "Strategy & hiring plan with CEO",
description: `Company mission: ${companyGoal}${growContext}
You are the CEO of this company. The board (the user) has just appointed you. Your first conversation should focus on STRATEGY — ask the board about their vision, priorities, and constraints. DO NOT immediately create a hiring plan. Instead:
1. FIRST: Greet the board briefly, then ask strategic questions to understand their priorities. Have a real conversation.
2. ONLY WHEN the board says they're ready (e.g. "let's build the plan", "get started", "hire the team"), THEN create the hiring plan document.
3. When you do create the plan, save it as a document using the plan key.${isGrowPath ? "\n4. Focus on agents that address the existing pain points and automate current workflows." : ""}
When writing the hiring plan document, use this exact format for EACH role. Use ## headings for each role (e.g. "## 1. Role Name") and ### sub-headings for each section within the role:
## 1. Role Name
### Summary
One-line description of this role.
### Expertise & Responsibilities
What this agent does, detailed responsibilities.
### Priorities
Ordered list of what matters most.
### Boundaries
What this role should NOT do.
### Tools & Permissions
What tools and access this role needs.
### Communication
Tone, style, and interaction guidelines.
### Collaboration & Escalation
Who this role works with, escalation paths.
Follow this structure for every role in the plan.`,
status: "backlog"
});
setPlanningTaskId(planningIssue.id);
// Go to launch celebration step (step 3)
setStep(3);
} 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);
}
}
async function handleStep3Next() {
if (!createdCompanyId || !createdAgentId) return;
setError(null);
setStep(4);
}
async function handleLaunch() {
if (!createdCompanyId || !createdAgentId) return;
setLoading(true);
setError(null);
try {
// Create a hire task for each approved role
const approvedRoles = hiringRoles.filter(
(r) => r.enabled && r.name.trim()
);
for (const role of approvedRoles) {
const roleSpec = [
role.summary && `**Summary:** ${role.summary}`,
role.expertise && `**Expertise & Responsibilities:**\n${role.expertise}`,
role.priorities && `**Priorities:**\n${role.priorities}`,
role.boundaries && `**Boundaries:**\n${role.boundaries}`,
role.tools && `**Tools & Permissions:**\n${role.tools}`,
role.communication && `**Communication:**\n${role.communication}`,
role.collaboration && `**Collaboration:**\n${role.collaboration}`,
].filter(Boolean).join("\n\n");
await issuesApi.create(createdCompanyId, {
title: `Hire: ${role.name}`,
description: `Hire a ${role.name} for the company.\n\n${roleSpec}`,
assigneeAgentId: createdAgentId,
status: "todo"
});
}
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId)
});
setSelectedCompanyId(createdCompanyId);
setStep(6); // → orientation screen
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create hire tasks");
} finally {
setLoading(false);
}
}
function handleFinishOnboarding() {
const prefix = createdCompanyPrefix;
reset(); // clears localStorage
closeOnboarding();
navigate(prefix ? `/${prefix}/dashboard` : `/dashboard`);
}
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() && companyGoal.trim()) handleStep1Next();
else if (step === 2 && agentName.trim()) handleStep2Next();
else if (step === 3) handleLaunchToChat();
else if (step === 4) setStep(5);
else if (step === 5) setStep(6);
else if (step === 6) handleLaunch();
}
}
if (!onboardingOpen) return null;
return (
<Dialog
open={onboardingOpen}
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-[width] duration-500 ease-in-out",
step === 1 ? "md:w-1/2" : "md:w-full"
)}
>
<div className="w-full max-w-md mx-auto my-auto px-8 py-12 shrink-0">
{/* Progress tabs */}
<div className="flex items-center gap-0 mb-8 border-b border-border">
{(
[
{ step: 1 as Step, label: "Mission", icon: Building2 },
{ step: 2 as Step, label: "CEO", icon: Bot },
{ step: 3 as Step, label: "Launch", icon: Rocket },
] as const
).map(({ step: s, label, icon: Icon }) => (
<button
key={s}
type="button"
onClick={() => setStep(s)}
className={cn(
"flex items-center gap-1.5 px-2 py-2 text-xs font-medium border-b-2 -mb-px transition-colors cursor-pointer",
s === step
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground/70 hover:border-border"
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
{/* Step content */}
{step === 1 && 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 company</h3>
<p className="text-xs text-muted-foreground">
We'll use this to configure your CEO and plan which agents to add.
</p>
</div>
</div>
<div className="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)}
autoFocus
/>
</div>
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">What does your company do?</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-[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-[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-[60px]"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
/>
</div>
)}
</>
)}
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setOnboardingPath(null); setStep(0); }}
>
← Back to start
</button>
</div>
)}
{/* Step 1a: Name your company */}
{step === 1 && onboardingPath !== "grow" && !missionPath && (
<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 will your company be called?
</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();
setMissionPath("direct");
}
}}
autoFocus
/>
</div>
<div className="flex items-center justify-between">
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setOnboardingPath(null); setStep(0); }}
>
← Back to start
</button>
{companyName.trim() && (
<Button
size="sm"
onClick={() => setMissionPath("direct")}
className="gap-1.5"
>
Next
<ArrowRight className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)}
{/* Step 1b: Define your mission */}
{step === 1 && onboardingPath !== "grow" && missionPath && (
<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 drives everything — your CEO, your hires, and the work <strong>{companyName}</strong> will do.
</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-[10px]">
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-[10px]">
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-[60px]"
placeholder="What is this company 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-[11px] 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 company do?
</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-[80px]"
value={companyGoal}
onChange={(e) => setCompanyGoal(e.target.value)}
autoFocus
/>
</div>
<button
className="text-[11px] 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-[11px] text-muted-foreground italic">
You can always change your mission later in settings.
</p>
)}
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMissionPath(null)}
>
Change company name
</button>
</div>
)}
{/* Step 2: Create your CEO */}
{step === 2 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Bot className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Bring your CEO to life</h3>
<p className="text-xs text-muted-foreground">
Give your CEO a heartbeat. They'll lead{" "}
<span className="font-medium text-foreground">{companyName}</span>{" "}
toward its mission.
</p>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Agent 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="CEO"
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
autoFocus
/>
</div>
{/* 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">
{[
{
value: "claude_local" as const,
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
recommended: true
},
{
value: "codex_local" as const,
label: "Codex",
icon: Code,
desc: "Local Codex agent",
recommended: true
}
].map((opt) => (
<button
key={opt.value}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
const nextType = opt.value as AdapterType;
setAdapterType(nextType);
if (nextType === "codex_local" && !model) {
setModel(DEFAULT_CODEX_LOCAL_MODEL);
}
if (nextType !== "codex_local") {
setModel("");
}
}}
>
{opt.recommended && (
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] 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-[10px]">
{opt.desc}
</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">
{[
{
value: "gemini_local" as const,
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent"
},
{
value: "opencode_local" as const,
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent"
},
{
value: "pi_local" as const,
label: "Pi",
icon: Terminal,
desc: "Local Pi agent"
},
{
value: "cursor" as const,
label: "Cursor",
icon: MousePointer2,
desc: "Local Cursor agent"
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol",
comingSoon: true,
disabledLabel: "Configure OpenClaw within the App"
}
].map((opt) => (
<button
key={opt.value}
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.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
const nextType = opt.value as AdapterType;
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") {
if (!model.includes("/")) {
setModel("");
}
return;
}
setModel("");
}}
>
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.comingSoon
? (opt as { disabledLabel?: string })
.disabledLabel ?? "Coming soon"
: opt.desc}
</span>
</button>
))}
</div>
)}
</div>
{/* Conditional adapter fields */}
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<label className="text-xs text-muted-foreground">
Working directory
</label>
<HintIcon text="Paperclip works best if you create a new folder for your agents to keep their memories and stay organized. Create a new folder and put the path here." />
</div>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<ChoosePathButton />
</div>
</div>
<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-[var(--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-[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-[10px] 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-[11px] 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-[11px] 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-[11px] 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 CEO 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-[11px] 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 === "process" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Command
</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="e.g. node, python"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Args (comma-separated)
</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="e.g. script.js, --flag"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</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 3: Launch celebration → exits to chat */}
{step === 3 && (
<div className="space-y-6 text-center py-4">
<div className="text-5xl">🚀</div>
<div>
<h3 className="text-xl font-semibold">{companyName} is live!</h3>
<p className="text-sm text-muted-foreground mt-2">
Your company has been created. Your CEO is ready.
</p>
<p className="text-sm font-medium mt-1 italic">
"{companyGoal}"
</p>
</div>
<p className="text-xs text-muted-foreground">
Start a conversation with your CEO to discuss strategy and build your team.
</p>
</div>
)}
{/* Step 4: Chat with CEO */}
{step === 4 && (
<div className="space-y-4">
<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">Chat with your CEO</h3>
<p className="text-xs text-muted-foreground">
Work with your CEO to build a hiring plan for{" "}
<span className="font-medium text-foreground">{companyName}</span>.
</p>
</div>
</div>
{planningTaskId ? (
<OnboardingChat
taskId={planningTaskId}
agentId={createdAgentId!}
agentName={agentName}
companyName={companyName}
companyGoal={companyGoal}
onPlanDetected={(md) => setPlanContent(md)}
onReviewPlan={async () => {
// Always fetch the latest plan document for the richest content
try {
const doc = await issuesApi.getDocument(planningTaskId!, "plan");
if (doc.body) {
setPlanContent(doc.body);
setHiringRoles(parseHiringPlan(doc.body));
} else if (planContent) {
setHiringRoles(parseHiringPlan(planContent));
}
} catch {
if (planContent) {
setHiringRoles(parseHiringPlan(planContent));
}
}
setStep(5);
}}
/>
) : (
<div className="rounded-md border border-border p-4 min-h-[200px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">
No planning task found. Go back and create your CEO first.
</p>
</div>
)}
</div>
)}
{/* Step 5: Review hiring plan */}
{step === 5 && (
<div className="space-y-4">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<ListTodo className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Review your hiring plan</h3>
<p className="text-xs text-muted-foreground">
Select which roles to hire. Edit, add, or remove roles
before approving.
</p>
</div>
</div>
{hiringRoles.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-4 text-center">
<p className="text-sm text-muted-foreground mb-2">
No roles parsed from the hiring plan yet.
</p>
<Button
variant="outline"
size="sm"
onClick={() =>
setHiringRoles([{ ...EMPTY_ROLE, id: nextRoleId() }])
}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add a role manually
</Button>
</div>
) : (
<div className="space-y-3">
{hiringRoles.map((role) => (
<RoleCard
key={role.id}
role={role}
onChange={(updated) =>
setHiringRoles((prev) =>
prev.map((r) => (r.id === role.id ? updated : r))
)
}
onDelete={() =>
setHiringRoles((prev) =>
prev.filter((r) => r.id !== role.id)
)
}
/>
))}
</div>
)}
{/* Add role button */}
<Button
variant="outline"
size="sm"
onClick={() =>
setHiringRoles((prev) => [
...prev,
{ ...EMPTY_ROLE, id: nextRoleId() },
])
}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add role
</Button>
{/* Revise with CEO */}
{planningTaskId && (
<button
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setStep(4)}
>
<MessageSquare className="h-3 w-3" />
Revise with CEO
</button>
)}
{/* Collapsible raw plan */}
{planContent && (
<div>
<button
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowRawPlan((v) => !v)}
>
<ChevronDown
className={cn(
"h-3 w-3 transition-transform",
showRawPlan ? "rotate-0" : "-rotate-90"
)}
/>
View raw plan
</button>
{showRawPlan && (
<div className="mt-2 rounded-md border border-border p-3 text-xs bg-muted/30 max-h-[200px] overflow-y-auto">
<pre className="whitespace-pre-wrap">{planContent}</pre>
</div>
)}
</div>
)}
</div>
)}
{/* Step 6: Welcome & orientation */}
{step === 6 && (
<div className="space-y-6 py-2">
<div className="text-center">
<div className="text-4xl mb-3">🎉</div>
<h3 className="text-lg font-semibold">
{companyName} is ready to go!
</h3>
<p className="text-sm text-muted-foreground mt-1">
Your CEO is now hiring{" "}
{hiringRoles.filter((r) => r.enabled && r.name.trim()).length} roles.
Here's what to expect on your dashboard:
</p>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<ListTodo className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Tasks</p>
<p className="text-xs text-muted-foreground">
Your CEO has hire tasks queued up. Watch them progress from todo in progress done.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<Bot className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Agents</p>
<p className="text-xs text-muted-foreground">
New agents will appear here as your CEO completes each hire. You may need to approve them.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<Check className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Approvals</p>
<p className="text-xs text-muted-foreground">
Check your inbox for pending approvals. Your CEO may need your sign-off before agents can start working.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<Building2 className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Dashboard</p>
<p className="text-xs text-muted-foreground">
Your command center see agent activity, costs, and overall company health at a glance.
</p>
</div>
</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="mt-3">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
{/* Footer navigation */}
<div className="flex items-center justify-between mt-8">
<div>
{step > 1 && step > (onboardingOptions.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 && (onboardingPath === "grow" || (missionPath && (missionPath !== "questionnaire" || missionConfirmed))) && (
<Button
size="sm"
disabled={!companyName.trim() || !companyGoal.trim() || loading}
onClick={handleStep1Next}
>
{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 === 2 && (
<Button
size="sm"
disabled={
!agentName.trim() || loading || adapterEnvLoading
}
onClick={handleStep2Next}
>
{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 === 3 && (
<Button
size="sm"
onClick={handleLaunchToChat}
>
<Rocket className="h-3.5 w-3.5 mr-1" />
Launch company
</Button>
)}
{step === 4 && !planContent && (
<Button
variant="ghost"
size="sm"
onClick={() => setStep(5)}
>
Skip chat
</Button>
)}
{step === 5 && (
<Button
size="sm"
disabled={!hiringRoles.some((r) => r.enabled && r.name.trim()) || loading}
onClick={handleLaunch}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Check className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating hires..." : "Approve & hire"}
</Button>
)}
{step === 6 && (
<Button size="sm" onClick={handleFinishOnboarding}>
<Rocket className="h-3.5 w-3.5 mr-1" />
Go to dashboard
</Button>
)}
</div>
</div>
</div>
</div>
)}
{/* Right half — ASCII art (hidden on mobile, only for step 1) */}
<div
className={cn(
"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out",
step === 1 ? "w-1/2 opacity-100" : "w-0 opacity-0"
)}
>
<AsciiArtAnimation />
</div>
</div>
</DialogPortal>
</Dialog>
);
}
const ROLE_FIELDS: Array<{ key: keyof HiringRole; label: string; placeholder: string }> = [
{ key: "summary", label: "Summary", placeholder: "One-line description of this role" },
{ key: "expertise", label: "Expertise & Responsibilities", placeholder: "What this agent does, its skills, and detailed responsibilities" },
{ key: "priorities", label: "Priorities", placeholder: "What this role focuses on first, in order of importance" },
{ key: "boundaries", label: "Boundaries", placeholder: "What this role should NOT do, out-of-scope areas" },
{ key: "tools", label: "Tools & Permissions", placeholder: "What tools, systems, or access this role needs" },
{ key: "communication", label: "Communication", placeholder: "Tone, style, and interaction guidelines" },
{ key: "collaboration", label: "Collaboration & Escalation", placeholder: "Who this role works with, escalation paths" },
];
function RoleCard({
role,
onChange,
onDelete,
}: {
role: HiringRole;
onChange: (updated: HiringRole) => void;
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const update = (field: keyof HiringRole, value: string) =>
onChange({ ...role, [field]: value });
if (role.editing) {
return (
<div
className={cn(
"rounded-md border px-3 py-3 transition-colors space-y-3",
role.enabled ? "border-border bg-background" : "border-border/50 bg-muted/30 opacity-60"
)}
>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-sm font-medium outline-none focus:ring-1 focus:ring-ring"
placeholder="Role name"
value={role.name}
onChange={(e) => update("name", e.target.value)}
autoFocus
/>
{ROLE_FIELDS.map(({ key, label, placeholder }) => (
<div key={key}>
<label className="text-[11px] text-muted-foreground mb-0.5 block font-medium">
{label}
</label>
<textarea
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-ring resize-y min-h-[60px] max-h-[200px]"
placeholder={placeholder}
value={(role[key] as string) || ""}
onChange={(e) => update(key, e.target.value)}
/>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => onChange({ ...role, editing: false })}
>
<Check className="h-3 w-3 mr-1" />
Done
</Button>
</div>
);
}
return (
<div
className={cn(
"rounded-md border px-3 py-2.5 transition-colors",
role.enabled ? "border-border bg-background" : "border-border/50 bg-muted/30 opacity-60"
)}
>
<div className="flex items-start gap-2.5">
<input
type="checkbox"
checked={role.enabled}
onChange={(e) => onChange({ ...role, enabled: e.target.checked })}
className="mt-1 shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{role.name || "Untitled role"}</p>
{role.summary && (
<p className="text-xs text-muted-foreground mt-0.5">{role.summary}</p>
)}
{expanded && (
<div className="mt-2 space-y-1.5">
{ROLE_FIELDS.filter(({ key }) => key !== "summary" && (role[key] as string)?.trim()).map(({ key, label }) => (
<div key={key}>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">{label}</p>
<p className="text-xs text-muted-foreground whitespace-pre-line">{role[key] as string}</p>
</div>
))}
</div>
)}
<button
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors mt-1"
onClick={() => setExpanded(!expanded)}
>
{expanded ? "Show less" : "Show more"}
</button>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onChange({ ...role, editing: true })}
>
<Pencil className="h-3 w-3" />
</button>
<button
className="p-1 text-muted-foreground hover:text-destructive transition-colors"
onClick={onDelete}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
}
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-[11px] ${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>
);
}