AgentComputer: clickable model picker (dropdown → PATCH /api/claws/:id/model)
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 27s
ci / rust (push) Successful in 4m20s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m45s

The '● <model>' text on the per-agent Computer slide-out was
read-only — swapping a claw's model required curl. Now it's a
button that opens a small dropdown of the curated fleet models:

- Sonnet 5 / Opus 4.8 / Haiku 4.5 / Sonnet 4.6 (Anthropic)
- GLM 4.6 / 5.2 (Z.AI)
- Kimi K2 (Moonshot)
- Gemini 2.0 Flash (Google)
- Llama 3.3 70B (Groq)

Click a model → PATCH /api/claws/{id}/model → picker closes, the
button relabels to the new selection. The endpoint persists the DB
binding + best-effort re-provisions the runtime; the swap applies
on the claw's next turn. Long-tail models still work via curl with
any string.

Adds an optional onModelChanged callback so callers (Dashboard's
runtime-config enrich cache) can refresh their state without a
full re-render.
This commit is contained in:
Omar Sobh
2026-07-17 21:18:29 -07:00
parent 6e37802f73
commit f8589471bc
@@ -3,10 +3,144 @@
// The per-agent "computer" slide-out — templated from the selected agent's own // The per-agent "computer" slide-out — templated from the selected agent's own
// model / apps / running tasks (each agent differs). // model / apps / running tasks (each agent differs).
import { Bell, BookOpen, Boxes, FolderClosed, Phone, Settings, Zap } from "lucide-react"; import { useState } from "react";
import { Bell, BookOpen, Boxes, ChevronDown, FolderClosed, Phone, Settings, Zap } from "lucide-react";
import type { DemoAgent, DemoApp } from "@/lib/dashboard-demo"; import type { DemoAgent, DemoApp } from "@/lib/dashboard-demo";
// Curated model roster the picker offers. Kept short so users pick
// from what actually runs in-fleet; long-tail models still work via
// `curl PATCH /api/claws/:id/model` with any string.
const MODEL_OPTIONS: { value: string; label: string; family: string }[] = [
{ value: "claude-sonnet-5", label: "Sonnet 5", family: "Anthropic" },
{ value: "claude-opus-4-8", label: "Opus 4.8", family: "Anthropic" },
{ value: "claude-haiku-4-5-20251001", label: "Haiku 4.5", family: "Anthropic" },
{ value: "claude-sonnet-4-6", label: "Sonnet 4.6", family: "Anthropic" },
{ value: "glm-4.6", label: "GLM 4.6", family: "Z.AI" },
{ value: "glm-5.2", label: "GLM 5.2", family: "Z.AI" },
{ value: "kimi-k2", label: "Kimi K2", family: "Moonshot" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", family: "Google" },
{ value: "llama-3.3-70b-versatile", label: "Llama 3.3 70B", family: "Groq" },
];
function ModelPicker({
agent,
onChanged,
}: {
agent: DemoAgent;
onChanged?: (model: string) => void;
}) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const current = MODEL_OPTIONS.find((o) => o.value === agent.model);
const label = current?.label ?? agent.model;
async function set(value: string) {
if (value === agent.model) {
setOpen(false);
return;
}
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/claws/${agent.id}/model`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: value }),
});
if (!r.ok) throw new Error(`PATCH /api/claws/${agent.id}/model → ${r.status}`);
onChanged?.(value);
setOpen(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "swap failed");
} finally {
setBusy(false);
}
}
return (
<div style={{ position: "relative", display: "inline-block" }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={busy}
title="Change model"
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "0 4px",
background: "transparent",
border: "none",
fontFamily: mono,
fontSize: 10,
color: "#5ec8d8",
cursor: busy ? "wait" : "pointer",
opacity: busy ? 0.6 : 1,
}}
>
<span>● {label}</span>
<ChevronDown aria-hidden size={11} />
</button>
{open && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
marginTop: 4,
minWidth: 200,
background: "#0b0b0e",
border: "1px solid rgba(255,255,255,.12)",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,.5)",
zIndex: 20,
padding: 4,
maxHeight: 320,
overflow: "auto",
}}
>
{MODEL_OPTIONS.map((o) => {
const active = o.value === agent.model;
return (
<button
key={o.value}
type="button"
onClick={() => set(o.value)}
disabled={busy || active}
style={{
display: "block",
width: "100%",
padding: "6px 10px",
border: 0,
borderRadius: 6,
background: active ? "rgba(94,200,216,.15)" : "transparent",
color: active ? "#e5f6fb" : "#cfcfd5",
cursor: active || busy ? "default" : "pointer",
fontFamily: mono,
fontSize: 11,
textAlign: "left",
}}
>
<div>{o.label}</div>
<div style={{ fontSize: 9, color: "#5a5a62" }}>
{o.family} · {o.value}
</div>
</button>
);
})}
{err && (
<div style={{ padding: "6px 10px", fontFamily: mono, fontSize: 10, color: "#ff8a7a" }}>
{err}
</div>
)}
</div>
)}
</div>
);
}
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
function appIcon(kind: DemoApp["kind"]) { function appIcon(kind: DemoApp["kind"]) {
@@ -31,7 +165,15 @@ const dockTile = (label: string, coral: boolean, icon: React.ReactNode) => (
</div> </div>
); );
export function AgentComputer({ agent, onClose }: { agent: DemoAgent; onClose: () => void }) { export function AgentComputer({
agent,
onClose,
onModelChanged,
}: {
agent: DemoAgent;
onClose: () => void;
onModelChanged?: (model: string) => void;
}) {
return ( return (
<div style={{ width: 330, flex: "none", borderLeft: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0, animation: "cm-fade .25s ease" }}> <div style={{ width: 330, flex: "none", borderLeft: "1px solid rgba(255,255,255,.06)", background: "#0b0b0e", display: "flex", flexDirection: "column", minHeight: 0, animation: "cm-fade .25s ease" }}>
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)" }}> <div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)" }}>
@@ -42,7 +184,7 @@ export function AgentComputer({ agent, onClose }: { agent: DemoAgent; onClose: (
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: "#f3f3f5" }}>{agent.name}&apos;s Computer</div> <div style={{ fontSize: 14, fontWeight: 700, color: "#f3f3f5" }}>{agent.name}&apos;s Computer</div>
<div style={{ fontFamily: mono, fontSize: 10, color: "#5ec8d8" }}>● {agent.model}</div> <ModelPicker agent={agent} onChanged={onModelChanged} />
</div> </div>
<div onClick={onClose} style={{ width: 26, height: 26, borderRadius: 7, border: "1px solid rgba(255,255,255,.1)", display: "flex", alignItems: "center", justifyContent: "center", color: "#6a6a72", fontSize: 13, cursor: "pointer" }}>⤢</div> <div onClick={onClose} style={{ width: 26, height: 26, borderRadius: 7, border: "1px solid rgba(255,255,255,.1)", display: "flex", alignItems: "center", justifyContent: "center", color: "#6a6a72", fontSize: 13, cursor: "pointer" }}>⤢</div>
</div> </div>