feat(app): model backend picker on the spawn screen

Fetch the registered model backends (/claude/models) alongside the
fleet poll and offer the enabled ones in a Model select on the spawn
form, defaulting to the Claude subscription — the mobile counterpart of
the web dashboard's per-spawn dropdown. The select is hidden when no
backends are registered, and Select itself now takes value/label pairs
as well as bare strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01731mKtVzsfeT4Vi3TvkR48
This commit is contained in:
Claude
2026-08-13 13:08:44 +00:00
parent dbf8f46df8
commit 6319bf0fd4
3 changed files with 61 additions and 12 deletions
+16 -7
View File
@@ -14,7 +14,11 @@ import { Icon } from "./Icon";
* Leeworks Select, ported from components/forms/Select.jsx. Native <select> * Leeworks Select, ported from components/forms/Select.jsx. Native <select>
* has no cross-platform styling in RN, so the field opens a bottom sheet of * has no cross-platform styling in RN, so the field opens a bottom sheet of
* options — same visual field (value + chevron), real picking behavior. * options — same visual field (value + chevron), real picking behavior.
* Options are either bare strings (value doubles as the label) or
* value/label pairs, matching the web dashboard's Select.
*/ */
export type SelectOption = string | { value: string; label: string };
export function Select({ export function Select({
label, label,
options, options,
@@ -22,13 +26,18 @@ export function Select({
onChange, onChange,
}: { }: {
label: string; label: string;
options: string[]; options: SelectOption[];
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
}) { }) {
const { colors } = useTheme(); const { colors } = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const opts = options.map((o) =>
typeof o === "string" ? { value: o, label: o } : o,
);
const current = opts.find((o) => o.value === value);
return ( return (
<View style={{ gap: 6 }}> <View style={{ gap: 6 }}>
<Text style={[styles.label, { color: colors.textHeading }]}>{label}</Text> <Text style={[styles.label, { color: colors.textHeading }]}>{label}</Text>
@@ -40,7 +49,7 @@ export function Select({
]} ]}
> >
<Text style={[text.body, { color: colors.textHeading, flex: 1 }]}> <Text style={[text.body, { color: colors.textHeading, flex: 1 }]}>
{value} {current?.label ?? value}
</Text> </Text>
<Icon name="chevronDown" size={16} color={colors.textMuted} /> <Icon name="chevronDown" size={16} color={colors.textMuted} />
</Pressable> </Pressable>
@@ -54,13 +63,13 @@ export function Select({
shadows.raised, shadows.raised,
]} ]}
> >
{options.map((opt) => { {opts.map((opt) => {
const on = opt === value; const on = opt.value === value;
return ( return (
<Pressable <Pressable
key={opt} key={opt.value}
onPress={() => { onPress={() => {
onChange(opt); onChange(opt.value);
setOpen(false); setOpen(false);
}} }}
style={({ pressed }) => [ style={({ pressed }) => [
@@ -78,7 +87,7 @@ export function Select({
}, },
]} ]}
> >
{opt} {opt.label}
</Text> </Text>
{on && <Icon name="chevronRight" size={16} color={colors.textMuted} />} {on && <Icon name="chevronRight" size={16} color={colors.textMuted} />}
</Pressable> </Pressable>
+21 -2
View File
@@ -18,14 +18,24 @@ import { useAppState } from "../state/AppState";
export function SpawnScreen() { export function SpawnScreen() {
const { colors } = useTheme(); const { colors } = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { go, projects, spawn } = useAppState(); const { go, projects, models, spawn } = useAppState();
const projectIds = projects.map((p) => p.id); const projectIds = projects.map((p) => p.id);
const [project, setProject] = useState(""); const [project, setProject] = useState("");
const [task, setTask] = useState(""); const [task, setTask] = useState("");
const [model, setModel] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Same choice the web dashboard's spawn form offers: the Claude subscription by
// default, plus every enabled registered model backend.
const modelOptions = [
{ value: "", label: "Claude (subscription)" },
...models
.filter((m) => m.enabled)
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
];
// Default to the first project once they load (or if the current pick vanished). // Default to the first project once they load (or if the current pick vanished).
useEffect(() => { useEffect(() => {
if (projectIds.length > 0 && !projectIds.includes(project)) { if (projectIds.length > 0 && !projectIds.includes(project)) {
@@ -45,7 +55,7 @@ export function SpawnScreen() {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
await spawn(project, task.trim()); await spawn(project, task.trim(), model ? Number(model) : null);
go("fleet"); go("fleet");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Couldnt spawn the agent."); setError(e instanceof Error ? e.message : "Couldnt spawn the agent.");
@@ -87,6 +97,15 @@ export function SpawnScreen() {
</View> </View>
)} )}
{modelOptions.length > 1 ? (
<Select
label="Model"
options={modelOptions}
value={model}
onChange={setModel}
/>
) : null}
<View> <View>
<Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}> <Text style={[text.label, { color: colors.textHeading, marginBottom: 6 }]}>
Task Task
+24 -3
View File
@@ -14,6 +14,7 @@ import {
type ApiClient, type ApiClient,
type ApiError, type ApiError,
type Checkmark, type Checkmark,
type ClaudeModel,
type LogEntry, type LogEntry,
type Project, type Project,
} from "../api/client"; } from "../api/client";
@@ -95,6 +96,8 @@ interface AppStateValue {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
projects: Project[]; projects: Project[];
/* Registered model backends (the spawn/schedule dropdown next to the subscription). */
models: ClaudeModel[];
waiting: WaitingItem[]; waiting: WaitingItem[];
recent: RecentItem[]; recent: RecentItem[];
counts: { running: number; waiting: number; done: number }; counts: { running: number; waiting: number; done: number };
@@ -108,7 +111,7 @@ interface AppStateValue {
// Mutations. // Mutations.
sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>; sendAnswer: (text: string) => Promise<{ resumed: boolean; note?: string }>;
spawn: (project: string, task: string) => Promise<void>; spawn: (project: string, task: string, modelId?: number | null) => Promise<void>;
kill: (project: string, name: string) => Promise<void>; kill: (project: string, name: string) => Promise<void>;
} }
@@ -152,6 +155,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
// Fleet data. // Fleet data.
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [models, setModels] = useState<ClaudeModel[]>([]);
const [agentsByProject, setAgentsByProject] = useState<Record<string, Agent[]>>({}); const [agentsByProject, setAgentsByProject] = useState<Record<string, Agent[]>>({});
const [checkmarks, setCheckmarks] = useState<Record<string, Checkmark | null>>({}); const [checkmarks, setCheckmarks] = useState<Record<string, Checkmark | null>>({});
const [logsByAgent, setLogsByAgent] = useState<Record<string, LogEntry[]>>({}); const [logsByAgent, setLogsByAgent] = useState<Record<string, LogEntry[]>>({});
@@ -166,6 +170,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
const resetData = useCallback(() => { const resetData = useCallback(() => {
setProjects([]); setProjects([]);
setModels([]);
setAgentsByProject({}); setAgentsByProject({});
setCheckmarks({}); setCheckmarks({});
setLogsByAgent({}); setLogsByAgent({});
@@ -190,6 +195,15 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
setError(null); setError(null);
const projs = await client.api<Project[]>("/projects"); const projs = await client.api<Project[]>("/projects");
// Model backends feed the spawn dropdown; an older server without the endpoint
// (404) just means "subscription only", so failures leave the list empty.
const modelList = await client
.api<ClaudeModel[]>("/claude/models")
.catch((e) => {
if (e instanceof AuthError) throw e;
return [] as ClaudeModel[];
});
// Per-agent/per-project sub-requests are isolated: one flaky agent (a 500 on // Per-agent/per-project sub-requests are isolated: one flaky agent (a 500 on
// its log, say) must not blank the whole fleet. A rejected AuthError still // its log, say) must not blank the whole fleet. A rejected AuthError still
// propagates via onUnauthorized inside the client; here we just record the // propagates via onUnauthorized inside the client; here we just record the
@@ -249,6 +263,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
for (const [k, v] of logEntries) logMap[k] = v; for (const [k, v] of logEntries) logMap[k] = v;
setProjects(projs); setProjects(projs);
setModels(modelList);
setAgentsByProject(abp); setAgentsByProject(abp);
setCheckmarks(cmMap); setCheckmarks(cmMap);
setLogsByAgent(logMap); setLogsByAgent(logMap);
@@ -424,11 +439,15 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
); );
const spawn = useCallback( const spawn = useCallback(
async (project: string, task: string) => { async (project: string, task: string, modelId?: number | null) => {
if (!client) throw new Error("not connected"); if (!client) throw new Error("not connected");
const name = deriveAgentName(task); const name = deriveAgentName(task);
await client.api(`/projects/${enc(project)}/agents/spawn`, { await client.api(`/projects/${enc(project)}/agents/spawn`, {
body: { name, ...(task.trim() ? { task: task.trim() } : {}) }, body: {
name,
...(task.trim() ? { task: task.trim() } : {}),
...(modelId != null ? { model_id: modelId } : {}),
},
}); });
await refresh(); await refresh();
}, },
@@ -460,6 +479,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
loading, loading,
error, error,
projects, projects,
models,
waiting, waiting,
recent, recent,
counts, counts,
@@ -483,6 +503,7 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
loading, loading,
error, error,
projects, projects,
models,
waiting, waiting,
recent, recent,
counts, counts,