Files
kandev/apps/web/components/onboarding-dialog.tsx
T

535 lines
17 KiB
TypeScript

"use client";
import { Fragment, useCallback, useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import {
IconArrowRight,
IconArrowLeft,
IconCheck,
IconFolder,
IconFolders,
IconBrandDocker,
IconX,
IconLoader2,
IconCommand,
IconSearch,
IconHome,
IconGitCommit,
IconTerminal2,
IconArrowDown,
IconCloud,
} from "@tabler/icons-react";
import { Kbd } from "@kandev/ui/kbd";
import { type ProfileFormData } from "@/components/settings/profile-form-fields";
import { profileToPermissionsMap, permissionsToProfilePatch } from "@/lib/agent-permissions";
import { listAvailableAgents, listWorkflowTemplates } from "@/lib/api";
import { listAgentsAction, updateAgentProfileAction } from "@/app/actions/agents";
import { StepAgents, type AgentSetting } from "@/components/onboarding/step-agents";
import type { AvailableAgent, ToolStatus, WorkflowTemplate, AgentProfile } from "@/lib/types/http";
interface OnboardingDialogProps {
open: boolean;
onComplete: () => void;
}
const TOTAL_STEPS = 4;
const RUNTIMES = [
{
name: "Local",
description: "Run agents directly on your machine with full access to your local filesystem.",
icon: IconFolder,
},
{
name: "Git Worktree",
description: "Isolated branch environment under a worktree root for parallel work.",
icon: IconFolders,
},
{
name: "Docker",
description: "Containerized execution for full isolation and reproducibility.",
icon: IconBrandDocker,
},
{
name: "Sprites (Remote sprites.dev)",
description: "Hardware-isolated execution environment for arbitrary code.",
icon: IconCloud,
href: "https://sprites.dev",
},
];
const STEP_TITLES = ["AI Agents", "Executors", "Agentic Workflows", "Command Panel"];
const STEP_DESCRIPTIONS = [
"Manage discovered agents and install new ones.",
"Agents can run in different executor environments — local, containerized, or remote.",
"Workflows define the steps and automation for your tasks.",
"Quick access to actions from anywhere with a keyboard shortcut.",
];
function buildAgentSettings(
avail: AvailableAgent[],
saved: {
name: string;
profiles?: {
id: string;
name: string;
model?: string | null;
cli_passthrough?: boolean | null;
cli_flags?: AgentProfile["cli_flags"] | null;
}[];
}[],
): Record<string, AgentSetting> {
const settings: Record<string, AgentSetting> = {};
for (const aa of avail) {
const dbAgent = saved.find((a) => a.name === aa.name);
const profile = dbAgent?.profiles?.[0];
if (profile) {
const perms = profileToPermissionsMap(
profile as Partial<Pick<AgentProfile, "allow_indexing">>,
aa.permission_settings ?? {},
);
settings[aa.name] = {
profileId: profile.id,
formData: {
name: profile.name,
model: profile.model || aa.model_config.default_model,
mode: (profile as { mode?: string }).mode ?? aa.model_config.current_mode_id ?? "",
cli_passthrough: profile.cli_passthrough ?? false,
cli_flags: profile.cli_flags ?? [],
...perms,
},
dirty: false,
};
}
}
return settings;
}
type OnboardingFooterProps = {
step: number;
onSkip: () => void;
onBack: () => void;
onNext: () => void;
onGetStarted: () => void;
};
function OnboardingStepDots({ step }: { step: number }) {
return (
<div className="flex justify-center gap-1.5 pb-2">
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? "w-6 bg-primary" : "w-1.5 bg-muted-foreground/30"}`}
/>
))}
</div>
);
}
function useOnboardingResources(open: boolean) {
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([]);
const [tools, setTools] = useState<ToolStatus[]>([]);
const [agentSettings, setAgentSettings] = useState<Record<string, AgentSetting>>({});
const [templates, setTemplates] = useState<WorkflowTemplate[]>([]);
const [loadingAgents, setLoadingAgents] = useState(true);
const [loadingTemplates, setLoadingTemplates] = useState(true);
useEffect(() => {
if (!open) return;
queueMicrotask(() => {
setLoadingAgents(true);
setLoadingTemplates(true);
});
}, [open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
// Poll while any agent is still in the "probing" state — the host-utility
// probes async at boot and the dialog can open before they all resolve.
// Without re-polling, agents that flip status mid-session stay stuck on
// the initial badge in the UI. Re-poll on transient fetch errors too:
// a single 500 shouldn't strand the dialog on stale probing status.
let lastSawProbing = true;
const pollOnce = (firstRun: boolean) => {
Promise.all([
listAvailableAgents({ cache: "no-store" }),
firstRun ? listAgentsAction() : Promise.resolve(null),
])
.then(([availRes, savedRes]) => {
if (cancelled) return;
const agents = availRes.agents ?? [];
setAvailableAgents(agents);
setTools(availRes.tools ?? []);
if (savedRes) {
setAgentSettings(buildAgentSettings(agents, savedRes.agents ?? []));
}
lastSawProbing = agents.some((a) => a.model_config.status === "probing");
})
.catch(() => {
// Keep polling on transient errors — backend may be momentarily
// unreachable while still resolving probes.
})
.finally(() => {
if (cancelled) return;
if (firstRun) setLoadingAgents(false);
if (lastSawProbing) {
timeoutId = setTimeout(() => pollOnce(false), 2000);
}
});
};
pollOnce(true);
listWorkflowTemplates()
.then((res) => {
if (!cancelled) setTemplates(res.templates ?? []);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoadingTemplates(false);
});
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [open]);
return {
availableAgents,
tools,
agentSettings,
setAgentSettings,
templates,
loadingAgents,
loadingTemplates,
};
}
function OnboardingFooter({ step, onSkip, onBack, onNext, onGetStarted }: OnboardingFooterProps) {
return (
<DialogFooter>
<div className="flex w-full items-center justify-between">
<Button variant="ghost" size="sm" onClick={onSkip} className="cursor-pointer">
<IconX className="mr-1.5 h-3.5 w-3.5" />
Skip
</Button>
<div className="flex gap-2">
{step > 0 && (
<Button variant="outline" onClick={onBack} className="cursor-pointer">
<IconArrowLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
)}
{step < TOTAL_STEPS - 1 ? (
<Button onClick={onNext} className="cursor-pointer">
Next
<IconArrowRight className="ml-1.5 h-4 w-4" />
</Button>
) : (
<Button onClick={onGetStarted} className="cursor-pointer">
<IconCheck className="mr-1.5 h-4 w-4" />
Get Started
</Button>
)}
</div>
</div>
</DialogFooter>
);
}
export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
const [step, setStep] = useState(0);
const {
availableAgents,
tools,
agentSettings,
setAgentSettings,
templates,
loadingAgents,
loadingTemplates,
} = useOnboardingResources(open);
const saveAgentSettings = useCallback(async () => {
await Promise.all(
Object.values(agentSettings)
.filter((s) => s.dirty)
.map((s) =>
updateAgentProfileAction(s.profileId, {
model: s.formData.model,
...permissionsToProfilePatch(s.formData),
cli_passthrough: s.formData.cli_passthrough,
cli_flags: s.formData.cli_flags,
}),
),
);
}, [agentSettings]);
const handleSkip = () => {
onComplete();
setStep(0);
};
const handleNext = async () => {
if (step === 0) await saveAgentSettings();
if (step < TOTAL_STEPS - 1) setStep(step + 1);
};
const handleBack = () => {
if (step > 0) setStep(step - 1);
};
const handleGetStarted = async () => {
await saveAgentSettings();
onComplete();
setStep(0);
};
const updateSetting = (agentName: string, formPatch: Partial<ProfileFormData>) => {
setAgentSettings((prev) => ({
...prev,
[agentName]: {
...prev[agentName],
formData: { ...prev[agentName].formData, ...formPatch },
dirty: true,
},
}));
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-3xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-center text-2xl">{STEP_TITLES[step]}</DialogTitle>
<DialogDescription className="text-center">{STEP_DESCRIPTIONS[step]}</DialogDescription>
</DialogHeader>
<div className="py-4 min-h-[220px]">
{step === 0 && (
<StepAgents
availableAgents={availableAgents}
tools={tools}
agentSettings={agentSettings}
loading={loadingAgents}
onUpdateSetting={updateSetting}
/>
)}
{step === 1 && <StepEnvironments />}
{step === 2 && <StepWorkflows templates={templates} loading={loadingTemplates} />}
{step === 3 && <StepCommandPanel />}
</div>
<OnboardingStepDots step={step} />
<OnboardingFooter
step={step}
onSkip={handleSkip}
onBack={handleBack}
onNext={handleNext}
onGetStarted={handleGetStarted}
/>
</DialogContent>
</Dialog>
);
}
function StepEnvironments() {
return (
<div className="space-y-3">
<div className="grid gap-2">
{RUNTIMES.map((runtime) => {
const Icon = runtime.icon;
const nameEl = runtime.href ? (
<a
href={runtime.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium hover:underline cursor-pointer"
>
{runtime.name}
</a>
) : (
<p className="text-sm font-medium">{runtime.name}</p>
);
return (
<div key={runtime.name} className="flex items-start gap-3 rounded-lg border p-3">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
<Icon className="h-4.5 w-4.5 text-muted-foreground" />
</div>
<div className="min-w-0">
{nameEl}
<p className="text-xs text-muted-foreground">{runtime.description}</p>
</div>
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Configure executors in Settings to control where agents execute.
</p>
</div>
);
}
function StepWorkflows({
templates,
loading,
}: {
templates: WorkflowTemplate[];
loading: boolean;
}) {
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-sm text-muted-foreground">
<IconLoader2 className="h-6 w-6 animate-spin" />
Loading workflow templates...
</div>
);
}
const defaultTemplate = templates.find((t) => t.id === "simple");
const otherTemplates = templates.filter((t) => t.id !== "simple");
return (
<div className="space-y-3">
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
{defaultTemplate && <TemplateCard template={defaultTemplate} isDefault />}
{otherTemplates.length > 0 && (
<>
<p className="text-xs text-muted-foreground mt-1">Available templates</p>
{otherTemplates.map((template) => (
<TemplateCard key={template.id} template={template} />
))}
</>
)}
</div>
<p className="text-xs text-muted-foreground">
Workflows control the steps, automation, and agent behavior for your tasks. You can add more
workflows from Settings.
</p>
</div>
);
}
const COMMAND_PANEL_PREVIEW_ITEMS = [
{ icon: IconSearch, label: "Search Tasks", trailing: "→" },
{ icon: IconHome, label: "Go to Home" },
{ icon: IconGitCommit, label: "Commit Changes" },
{ icon: IconArrowDown, label: "Pull" },
{ icon: IconTerminal2, label: "Add Terminal Panel" },
];
function StepCommandPanel() {
return (
<div className="space-y-4">
{/* Mock command panel preview */}
<div className="rounded-lg border bg-card overflow-hidden">
{/* Search input */}
<div className="flex items-center gap-2 px-3 py-2 border-b">
<IconSearch className="h-3.5 w-3.5 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground/50">Type a command...</span>
</div>
{/* Sample commands */}
<div className="py-1">
{COMMAND_PANEL_PREVIEW_ITEMS.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="flex items-center gap-3 px-3 py-1.5 text-sm first:bg-muted/50"
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 text-xs">{item.label}</span>
{item.trailing && (
<span className="text-xs text-muted-foreground">{item.trailing}</span>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="flex items-center gap-3 px-3 py-1.5 border-t text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
<span className="text-[0.6rem]">Navigate</span>
</span>
<span className="inline-flex items-center gap-1">
<Kbd>↵</Kbd>
<span className="text-[0.6rem]">Select</span>
</span>
<span className="inline-flex items-center gap-1">
<Kbd>esc</Kbd>
<span className="text-[0.6rem]">Close</span>
</span>
</div>
</div>
{/* Shortcut hint */}
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Press</span>
<span className="inline-flex items-center gap-0.5">
<Kbd>
<IconCommand className="size-3" />
</Kbd>
<Kbd>K</Kbd>
</span>
<span>to open it anytime</span>
</div>
<p className="text-xs text-muted-foreground">
Navigate between pages, search tasks, trigger git operations, and manage panels — all
without leaving the keyboard. Context-aware commands appear based on the active page.
</p>
</div>
);
}
function TemplateCard({
template,
isDefault,
}: {
template: WorkflowTemplate;
isDefault?: boolean;
}) {
const steps = (template.default_steps ?? []).slice().sort((a, b) => a.position - b.position);
return (
<div
className={`rounded-lg border p-3 ${isDefault ? "border-primary/50 bg-primary/5" : "opacity-60"}`}
>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{template.name}</p>
{isDefault && (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<IconCheck className="h-3.5 w-3.5" />
Default
</span>
)}
</div>
{template.description && (
<p className="text-xs text-muted-foreground mt-0.5">{template.description}</p>
)}
{steps.length > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap mt-2">
{steps.map((s, i) => (
<Fragment key={s.name}>
{i > 0 && <span className="text-muted-foreground/40">→</span>}
<span className="flex items-center gap-1">
<span
className="h-1.5 w-1.5 rounded-full shrink-0"
style={{ backgroundColor: s.color || "hsl(var(--muted-foreground))" }}
/>
{s.name}
</span>
</Fragment>
))}
</div>
)}
</div>
);
}