mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 05:36:24 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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 : "Couldn’t spawn the agent.");
|
setError(e instanceof Error ? e.message : "Couldn’t 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user