Files
kandev/apps/web/app/office/setup/setup-wizard.tsx
T

251 lines
7.6 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
completeOnboarding,
importFromFS,
type OnboardingFSWorkspace,
} from "@/lib/api/domains/office-api";
import { StepImport } from "./step-import";
import { StepWorkspace, derivePrefix } from "./step-workspace";
import { StepAgent } from "./step-agent";
import { StepTask } from "./step-task";
import { StepReview } from "./step-review";
import { WizardFooter } from "./wizard-footer";
import { CloseButton } from "./close-button";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import type { Tier } from "@/lib/state/slices/office/types";
type SetupWizardProps = {
agentProfiles: AgentProfileOption[];
fsWorkspaces: OnboardingFSWorkspace[];
mode?: string;
/**
* Sensible default agent profile pre-selected for the coordinator. Resolved
* server-side from the user's `default_utility_agent_id` falling back to
* the first installed CLI profile. Empty when no profiles exist yet —
* the wizard then surfaces a "Set up CLI in Settings" link.
*/
defaultAgentProfileId?: string;
/**
* Pre-filled workspace name. "Default" on first onboarding; for additional
* workspaces ("Add workspace" flow) the page resolves to the first unused
* "Default N" so we never collide with an existing office workspace.
*/
suggestedWorkspaceName: string;
};
type WizardData = {
workspaceName: string;
taskPrefix: string;
agentName: string;
agentProfileId: string;
executorPreference: string;
defaultTier?: Tier;
taskTitle: string;
taskDescription: string;
};
function getInitialData(
suggestedWorkspaceName: string,
defaultAgentProfileId?: string,
): WizardData {
return {
workspaceName: suggestedWorkspaceName,
taskPrefix: derivePrefix(suggestedWorkspaceName),
agentName: "CEO",
agentProfileId: defaultAgentProfileId ?? "",
executorPreference: "local_pc",
taskTitle: "",
taskDescription: "",
};
}
const STEP_COUNT = 4;
function computeCanAdvance(step: number, data: WizardData): boolean {
if (step === 0) return data.workspaceName.trim() !== "";
if (step === 1) return data.agentName.trim() !== "" && data.agentProfileId !== "";
return true;
}
function dotColor(index: number, current: number): string {
if (index === current) return "bg-primary";
if (index < current) return "bg-primary/50";
return "bg-muted";
}
export async function submitOnboarding(data: WizardData) {
const result = await completeOnboarding({
workspaceName: data.workspaceName.trim() || "default",
taskPrefix: data.taskPrefix.trim() || "KAN",
agentName: data.agentName.trim() || "CEO",
agentProfileId: data.agentProfileId,
executorPreference: data.executorPreference || "local_pc",
taskTitle: data.taskTitle.trim() || undefined,
taskDescription: data.taskDescription.trim() || undefined,
default_tier: data.defaultTier,
});
return result;
}
function WizardStepContent({
step,
data,
agentProfiles,
patch,
onAgentProfilesChange,
}: {
step: number;
data: WizardData;
agentProfiles: AgentProfileOption[];
patch: (updates: Partial<WizardData>) => void;
onAgentProfilesChange: (profiles: AgentProfileOption[]) => void;
}) {
if (step === 0)
return (
<StepWorkspace
workspaceName={data.workspaceName}
taskPrefix={data.taskPrefix}
onChange={patch}
/>
);
if (step === 1)
return (
<StepAgent
agentName={data.agentName}
agentProfileId={data.agentProfileId}
executorPreference={data.executorPreference}
defaultTier={data.defaultTier}
agentProfiles={agentProfiles}
onChange={patch}
onAgentProfilesChange={onAgentProfilesChange}
/>
);
if (step === 2)
return (
<StepTask
agentName={data.agentName}
taskTitle={data.taskTitle}
taskDescription={data.taskDescription}
onChange={patch}
/>
);
return (
<StepReview
workspaceName={data.workspaceName}
taskPrefix={data.taskPrefix}
agentName={data.agentName}
agentProfileLabel={agentProfiles.find((p) => p.id === data.agentProfileId)?.label || ""}
executorPreference={data.executorPreference}
taskTitle={data.taskTitle}
/>
);
}
export function SetupWizard({
agentProfiles,
fsWorkspaces,
mode,
defaultAgentProfileId,
suggestedWorkspaceName,
}: SetupWizardProps) {
const router = useRouter();
// mode === "new" means the user explicitly asked for a fresh workspace
// (e.g. clicked "Add workspace" on the dashboard) — skip the FS import
// prompt even when on-disk configs exist.
const [showWizard, setShowWizard] = useState(mode === "new" || fsWorkspaces.length === 0);
const [step, setStep] = useState(0);
const [profileOptions, setProfileOptions] = useState(agentProfiles);
const [data, setData] = useState<WizardData>(() =>
getInitialData(suggestedWorkspaceName, defaultAgentProfileId),
);
const [submitting, setSubmitting] = useState(false);
const patch = useCallback(
(updates: Partial<WizardData>) => setData((prev) => ({ ...prev, ...updates })),
[],
);
const canAdvance = computeCanAdvance(step, data);
const handleSubmit = useCallback(async () => {
setSubmitting(true);
try {
const result = await submitOnboarding(data);
toast.success("Workspace created successfully");
document.cookie = `office-active-workspace=${result.workspaceId}; path=/; max-age=86400; samesite=strict; secure`;
router.push(`/office?workspaceId=${result.workspaceId}`);
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to complete setup");
} finally {
setSubmitting(false);
}
}, [data, router]);
const handleImportFS = useCallback(async () => {
setSubmitting(true);
try {
const result = await importFromFS();
toast.success(`Imported ${result.importedCount} config entries`);
router.push("/office");
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to import settings");
} finally {
setSubmitting(false);
}
}, [router]);
const closeHref = mode === "new" ? "/office" : "/";
if (!showWizard) {
return (
<StepImport
fsWorkspaces={fsWorkspaces}
submitting={submitting}
onImport={handleImportFS}
onSkip={() => setShowWizard(true)}
closeHref={closeHref}
/>
);
}
return (
<div className="fixed inset-0 z-50 bg-background flex items-center justify-center">
<div className="relative w-full max-w-2xl mx-auto px-6">
<CloseButton href={closeHref} />
<StepIndicator current={step} total={STEP_COUNT} />
<div className="mt-8">
<WizardStepContent
step={step}
data={data}
agentProfiles={profileOptions}
patch={patch}
onAgentProfilesChange={setProfileOptions}
/>
</div>
<WizardFooter
step={step}
canAdvance={canAdvance}
submitting={submitting}
onBack={() => setStep((s) => s - 1)}
onNext={() => setStep((s) => s + 1)}
onSkip={() => setStep((s) => s + 1)}
onSubmit={handleSubmit}
/>
</div>
</div>
);
}
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<div className="flex items-center justify-center gap-2">
{Array.from({ length: total }, (_, i) => (
<div key={i} className={`h-2 w-2 rounded-full transition-colors ${dotColor(i, current)}`} />
))}
</div>
);
}