Files
handler/app/src/components/Select.tsx
T
Claude 6319bf0fd4 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
2026-08-13 13:08:44 +00:00

138 lines
3.7 KiB
TypeScript

import React, { useState } from "react";
import {
Modal,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
import { fonts, radius, shadows, text } from "../theme/tokens";
import { useTheme } from "../theme/useTheme";
import { Icon } from "./Icon";
/**
* 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
* 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({
label,
options,
value,
onChange,
}: {
label: string;
options: SelectOption[];
value: string;
onChange: (v: string) => void;
}) {
const { colors } = useTheme();
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 (
<View style={{ gap: 6 }}>
<Text style={[styles.label, { color: colors.textHeading }]}>{label}</Text>
<Pressable
onPress={() => setOpen(true)}
style={[
styles.field,
{ backgroundColor: colors.surfaceCard, borderColor: colors.borderDefault },
]}
>
<Text style={[text.body, { color: colors.textHeading, flex: 1 }]}>
{current?.label ?? value}
</Text>
<Icon name="chevronDown" size={16} color={colors.textMuted} />
</Pressable>
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
<Pressable
style={[
styles.sheet,
{ backgroundColor: colors.surfaceCard, borderColor: colors.borderSubtle },
shadows.raised,
]}
>
{opts.map((opt) => {
const on = opt.value === value;
return (
<Pressable
key={opt.value}
onPress={() => {
onChange(opt.value);
setOpen(false);
}}
style={({ pressed }) => [
styles.option,
pressed && { backgroundColor: colors.surfaceSunken },
]}
>
<Text
style={[
text.body,
{
color: colors.textHeading,
fontFamily: on ? fonts.bodySemiBold : fonts.bodyRegular,
flex: 1,
},
]}
>
{opt.label}
</Text>
{on && <Icon name="chevronRight" size={16} color={colors.textMuted} />}
</Pressable>
);
})}
</Pressable>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
label: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
lineHeight: 16,
},
field: {
height: 48,
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: radius.md,
paddingHorizontal: 12,
gap: 8,
},
backdrop: {
flex: 1,
backgroundColor: "rgba(20,20,19,0.4)",
justifyContent: "flex-end",
padding: 20,
},
sheet: {
borderWidth: 1,
borderRadius: radius.xl,
overflow: "hidden",
marginBottom: 24,
},
option: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 16,
paddingHorizontal: 20,
minHeight: 44,
},
});