Files
kandev/apps/web/app/office/setup/step-agent.tsx
T
2026-05-16 20:07:57 +01:00

344 lines
11 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { useAppStore } from "@/components/state-provider";
import { AgentSelector } from "@/components/task-create-dialog-selectors";
import { useAgentProfileOptions } from "@/components/task-create-dialog-options";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
import { getCapabilityWarning } from "@/lib/capability-warning";
import { CliProfileEditor } from "@/components/agent/cli-profile-editor";
import { Combobox, type ComboboxOption } from "@/components/combobox";
import { getExecutorIcon } from "@/lib/executor-icons";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import type { Tier } from "@/lib/state/slices/office/types";
import { seedTier } from "./seed-tier-mapping";
type StepAgentProps = {
agentName: string;
agentProfileId: string;
executorPreference: string;
defaultTier?: Tier;
agentProfiles: AgentProfileOption[];
onChange: (patch: {
agentName?: string;
agentProfileId?: string;
executorPreference?: string;
defaultTier?: Tier;
}) => void;
onAgentProfilesChange?: (profiles: AgentProfileOption[]) => void;
};
// Fallback used only when meta has not been hydrated yet (graceful degradation).
const FALLBACK_EXECUTOR_OPTIONS = [
{ id: "local_pc", label: "Local (standalone)", description: "Run on host machine" },
{ id: "local_docker", label: "Local Docker", description: "Run in a local Docker container" },
{
id: "sprites",
label: "Sprites (remote sandbox)",
description: "Run in a Sprites cloud environment",
},
];
function sortProfiles(profiles: AgentProfileOption[]): AgentProfileOption[] {
return [...profiles].sort((a, b) => {
const aDisabled =
a.cli_passthrough || !!getCapabilityWarning(a.capability_status, a.capability_error);
const bDisabled =
b.cli_passthrough || !!getCapabilityWarning(b.capability_status, b.capability_error);
if (aDisabled === bDisabled) return 0;
return aDisabled ? 1 : -1;
});
}
export function StepAgent({
agentName,
agentProfileId,
executorPreference,
defaultTier,
agentProfiles,
onChange,
onAgentProfilesChange,
}: StepAgentProps) {
const meta = useAppStore((s) => s.office.meta);
const executorOptions = meta?.executorTypes ?? FALLBACK_EXECUTOR_OPTIONS;
const settingsAgents = useAppStore((s) => s.settingsAgents.items);
const setAgentProfiles = useAppStore((s) => s.setAgentProfiles);
const agentProfilesState = useAppStore((s) => s.agentProfiles.items);
const sortedProfiles = useMemo(() => sortProfiles(agentProfiles), [agentProfiles]);
const baseOptions = useAgentProfileOptions(sortedProfiles);
const profileOptions = useMemo(
() =>
baseOptions.map((opt, i) => ({
...opt,
disabled:
sortedProfiles[i]?.cli_passthrough ||
!!getCapabilityWarning(
sortedProfiles[i]?.capability_status,
sortedProfiles[i]?.capability_error,
),
})),
[baseOptions, sortedProfiles],
);
const selectedProfile = sortedProfiles.find((p) => p.id === agentProfileId);
const [showCreate, setShowCreate] = useState(profileOptions.length === 0);
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold">Create your coordinator agent</h2>
<p className="text-sm text-muted-foreground mt-1">
The coordinator manages other agents, delegates tasks, and monitors progress.
</p>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="agent-name">Agent name</Label>
<Input
id="agent-name"
value={agentName}
onChange={(e) => onChange({ agentName: e.target.value })}
placeholder="CEO"
className="mt-1"
autoFocus
/>
</div>
<div>
<Label>CLI agent profile</Label>
{!showCreate && (
<AgentSelector
options={profileOptions}
value={agentProfileId}
onValueChange={(v) => onChange({ agentProfileId: v })}
disabled={profileOptions.length === 0}
placeholder="Select an agent profile..."
triggerClassName="mt-1 border border-input rounded-md px-3 h-9"
/>
)}
{showCreate ? (
<CreateProfilePanel
settingsAgents={settingsAgents}
storeProfiles={agentProfilesState}
wizardProfiles={agentProfiles}
canCancel={profileOptions.length > 0}
setAgentProfiles={setAgentProfiles}
onAgentProfilesChange={onAgentProfilesChange}
onChange={onChange}
onClose={() => setShowCreate(false)}
/>
) : (
<ProfilePickerHint
hasProfiles={profileOptions.length > 0}
selected={selectedProfile}
onCreateClick={() => setShowCreate(true)}
/>
)}
</div>
<ExecutorSelector
value={executorPreference}
options={executorOptions}
onChange={(v) => onChange({ executorPreference: v })}
/>
<TierIndicator
selectedProfile={selectedProfile}
defaultTier={defaultTier}
onChange={(t) => onChange({ defaultTier: t })}
/>
</div>
</div>
);
}
function TierIndicator({
selectedProfile,
defaultTier,
onChange,
}: {
selectedProfile: AgentProfileOption | undefined;
defaultTier?: Tier;
onChange: (t: Tier) => void;
}) {
// The label string in AgentProfileOption is "<agent display> • <profile name>"
// — fall back to the raw label when we cannot extract a model id, since the
// seed mapping only matters for the "we'll treat X as the Y tier" hint.
const modelHint = selectedProfile?.label;
const seeded = seedTier(selectedProfile?.agent_id, modelHint);
const value: Tier = defaultTier ?? seeded;
return (
<div>
<Label>Workspace default tier</Label>
<p className="text-xs text-muted-foreground mb-2">
We&apos;ll treat <span className="font-mono">{modelHint || "your model"}</span> as the{" "}
{value} tier for {selectedProfile?.agent_name || "this provider"}. Change it later in
Workspace → Provider routing.
</p>
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => v && onChange(v as Tier)}
className="justify-start"
>
<ToggleGroupItem value="frontier" className="cursor-pointer capitalize">
Frontier
</ToggleGroupItem>
<ToggleGroupItem value="balanced" className="cursor-pointer capitalize">
Balanced
</ToggleGroupItem>
<ToggleGroupItem value="economy" className="cursor-pointer capitalize">
Economy
</ToggleGroupItem>
</ToggleGroup>
</div>
);
}
function CreateProfilePanel({
settingsAgents,
storeProfiles,
wizardProfiles,
canCancel,
setAgentProfiles,
onAgentProfilesChange,
onChange,
onClose,
}: {
settingsAgents: { id: string; name: string }[];
storeProfiles: AgentProfileOption[];
wizardProfiles: AgentProfileOption[];
canCancel: boolean;
setAgentProfiles: (profiles: AgentProfileOption[]) => void;
onAgentProfilesChange?: (profiles: AgentProfileOption[]) => void;
onChange: StepAgentProps["onChange"];
onClose: () => void;
}) {
return (
<div className="mt-2 rounded-md border bg-muted/30 p-3">
<CliProfileEditor
mode="create"
defaultProfileName="default"
showAdvanced
allowCliPassthrough={false}
onSaved={(saved) => {
const agentForProfile = settingsAgents.find((a) => a.id === saved.agentId) ?? {
id: saved.agentId ?? "",
name: saved.agentId ?? "",
};
const option = toAgentProfileOption(agentForProfile, saved);
setAgentProfiles([...storeProfiles.filter((p) => p.id !== option.id), option]);
onAgentProfilesChange?.([...wizardProfiles.filter((p) => p.id !== option.id), option]);
onChange({ agentProfileId: saved.id });
onClose();
}}
onCancel={canCancel ? onClose : undefined}
/>
</div>
);
}
function ProfilePickerHint({
hasProfiles,
selected,
onCreateClick,
}: {
hasProfiles: boolean;
selected: AgentProfileOption | undefined;
onCreateClick: () => void;
}) {
if (!hasProfiles) {
return (
<div className="mt-2 text-xs text-muted-foreground space-y-1">
<p>No CLI agent profiles available yet.</p>
<Button
type="button"
variant="link"
onClick={onCreateClick}
className="h-auto p-0 cursor-pointer text-primary"
>
Create one inline
</Button>
</div>
);
}
return (
<div className="mt-2 space-y-2 text-xs text-muted-foreground">
{selected ? (
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary">{selected.agent_name}</Badge>
{selected.cli_passthrough ? <Badge variant="outline">CLI passthrough</Badge> : null}
</div>
) : (
<p>Picks the CLI client, model, mode, and flags this agent will use.</p>
)}
<Button
type="button"
variant="link"
onClick={onCreateClick}
className="h-auto p-0 cursor-pointer text-primary"
>
+ Create a new CLI profile
</Button>
</div>
);
}
// Maps onboarding executor-preference IDs to the icon catalog keys in
// `lib/executor-icons.ts` (which uses runtime executor type names).
const EXECUTOR_ICON_TYPE: Record<string, string> = {
local_pc: "local",
local_docker: "local_docker",
remote_docker: "remote_docker",
sprites: "sprites",
};
function ExecutorSelector({
value,
options,
onChange,
}: {
value: string;
options: { id: string; label: string; description: string }[];
onChange: (v: string) => void;
}) {
const current = value || "local_pc";
const selected = options.find((o) => o.id === current);
const comboOptions: ComboboxOption[] = options.map((opt) => {
const Icon = getExecutorIcon(EXECUTOR_ICON_TYPE[opt.id] ?? "local");
const disabled = opt.id !== "local_pc";
return {
value: opt.id,
label: opt.label,
description: opt.description,
disabled,
disabledReason: disabled ? "Coming soon — only Local is supported right now." : undefined,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{opt.label}</span>
</span>
),
};
});
return (
<div>
<Label>Executor preference</Label>
<Combobox
options={comboOptions}
value={current}
onValueChange={onChange}
placeholder="Select executor..."
showSearch={false}
triggerClassName="mt-1 border border-input rounded-md px-3 h-9"
/>
{selected ? (
<p className="text-xs text-muted-foreground mt-1">{selected.description}</p>
) : null}
</div>
);
}