Loading files
"use client";
import { useCallback, useEffect, useState } from "react";
import { Check, ChevronDown, Cpu, Loader2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/ui/utils";
import { transport } from "@/lib/transport.ts";
import {
choiceIdForSettings,
CODEX_CHOICES,
DEFAULT_MODEL,
labelForModel,
MODEL_CHOICES,
} from "./modelChoices.js";
// The switcher stays local-only: Claude Code remains the default, while the
// Codex CLI is an opt-in provider using the same board workspace.
const LOCAL_CHOICES = [
...CODEX_CHOICES,
...MODEL_CHOICES.filter((choice) => !choice.requiresPandaSignIn),
];
/**
* Compact pill in the chat composer footer showing which Claude model the next
* turn will use, with a dropdown to switch between the offered models. The
* choice is persisted in AppSettings (`app_set_model`); the driver reads it
* fresh at each turn spawn, so a switch takes effect on the next turn — no need
* to block switching mid-turn.
*/
export default function ModelControl({ className }) {
// Active model value; null until the first settings read resolves. Falls back
// to the default for display when unset/unrecognized.
const [model, setModel] = useState(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const settings = await transport.app_settings_read();
setModel(choiceIdForSettings(settings));
} catch {
// Leave the current display in place; the driver still resolves its own
// default when unset.
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// `model` is the persisted selection id; unknown/legacy ids display as the
// default.
const active = LOCAL_CHOICES.some((choice) => choice.id === model)
? model
: DEFAULT_MODEL;
const pick = useCallback(
async (id) => {
if (busy || id === active) return;
setBusy(true);
try {
const choice = LOCAL_CHOICES.find((one) => one.id === id);
const provider = choice?.provider === "codex" ? "codex" : "claude";
// Provider first: a failed second call leaves the pill on the prior
// selection, and the next refresh re-reads both fields together.
await transport.app_set_provider(provider);
const next = await transport.app_set_model(choice?.value ?? id);
setModel(choiceIdForSettings({ provider, model: next?.model ?? choice?.value ?? "" }));
} catch {
// Leave the prior selection in place on failure.
} finally {
setBusy(false);
}
},
[busy, active],
);
return (
<DropdownMenu
onOpenChange={(open) => {
if (open) {
void refresh();
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-background/55 hover:text-foreground",
className,
)}
data-testid="model-trigger"
title="Model"
>
{busy ? (
<Loader2 className="size-3 animate-spin" aria-hidden />
) : (
<Cpu className="size-3" aria-hidden />
)}
{labelForModel(active)}
<ChevronDown className="size-3 opacity-60" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="cad-solid-popover min-w-40">
<DropdownMenuLabel className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
AI provider
</DropdownMenuLabel>
{LOCAL_CHOICES.map((choice) => (
<DropdownMenuItem
key={choice.id}
onSelect={() => void pick(choice.id)}
data-testid={`model-option-${choice.id}`}
className="justify-between gap-3"
>
<span>{choice.label}</span>
{choice.id === active ? <Check className="size-3.5" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
e4d010b16This file is pinned to the selected release. Download the original to open it in your design tool.