import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";import type { DeviceActionInput, DeviceDetail, DevicePermission, DeviceSummary, DeviceTextSize, EnvironmentId,} from "@t3tools/contracts";import { ChevronDown, X } from "lucide-react";import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "~/components/ui/button";import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible";import { Input } from "~/components/ui/input";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue,} from "~/components/ui/select";import { Spinner } from "~/components/ui/spinner";import { Switch } from "~/components/ui/switch";import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";import { cn } from "~/lib/utils";import { deviceEnvironment } from "~/state/device";import { formatEnvironmentQueryError } from "~/state/query";import { useAtomCommand } from "~/state/use-atom-command";import { type DeviceEventLogEntry, type DeviceForegroundInfo, subscribeDeviceEventLog, subscribeDeviceForeground,} from "./deviceHubApi"; type ActionBody = DeviceActionInput extends infer A ? A extends { readonly type: string } ? Omit<A, "hostId" | "deviceId"> : never : never; const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [ { value: "small", label: "Small" }, { value: "default", label: "Default" }, { value: "large", label: "Large" }, { value: "extra-large", label: "Extra large" },]; const COLOR_FILTERS = [ { value: "none", label: "None" }, { value: "grayscale", label: "Grayscale" }, { value: "red-green", label: "Red / green (protanopia)" }, { value: "green-red", label: "Green / red (deuteranopia)" }, { value: "blue-yellow", label: "Blue / yellow (tritanopia)" },] as const; const ORIENTATIONS = [ { value: "portrait", label: "Portrait" }, { value: "landscape_left", label: "Landscape left" }, { value: "portrait_upside_down", label: "Upside down" }, { value: "landscape_right", label: "Landscape right" },] as const; const IOS_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ { value: "camera", label: "Camera" }, { value: "microphone", label: "Microphone" }, { value: "photos", label: "Photos" }, { value: "contacts", label: "Contacts" }, { value: "calendar", label: "Calendar" }, { value: "reminders", label: "Reminders" }, { value: "location", label: "Location" }, { value: "notifications", label: "Notifications" }, { value: "motion", label: "Motion" }, { value: "media-library", label: "Media library" }, { value: "faceid", label: "Face ID" },]; const ANDROID_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ { value: "camera", label: "Camera" }, { value: "microphone", label: "Microphone" }, { value: "photos", label: "Photos" }, { value: "contacts", label: "Contacts" }, { value: "calendar", label: "Calendar" }, { value: "location", label: "Location" }, { value: "notifications", label: "Notifications" }, { value: "motion", label: "Physical activity" },]; const LOCATION_PRESETS = [ { label: "San Francisco", latitude: 37.7749, longitude: -122.4194 }, { label: "New York", latitude: 40.7128, longitude: -74.006 }, { label: "London", latitude: 51.5074, longitude: -0.1278 }, { label: "Stockholm", latitude: 59.3293, longitude: 18.0686 }, { label: "Tokyo", latitude: 35.6762, longitude: 139.6503 },] as const; /** * The Tools drawer for one open device: current settings read from the device, * one control per supported action, and the read-only feeds the hub exposes. * Every change is a `device.action` round trip; the returned detail replaces * local state so the controls never show a value the device did not confirm. */export function DeviceToolsPanel(props: { readonly environmentId: EnvironmentId; readonly device: DeviceSummary; readonly access: DeviceHubAccess | null; readonly axOverlay: boolean; readonly onAxOverlayChange: (enabled: boolean) => void; readonly onClose: () => void; readonly className?: string;}) { const { environmentId, device } = props; const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false }); const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false }); const [detail, setDetail] = useState<DeviceDetail | null>(null); const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>(undefined); const isIos = device.platform === "ios"; const target = useMemo( () => ({ hostId: device.hostId, deviceId: device.id }), [device.hostId, device.id], ); // The panel is keyed by device, so a mount is always a fresh device. useEffect(() => { let cancelled = false; void readDetail({ environmentId, input: target }).then((result) => { if (cancelled) return; if (result._tag === "Success") setDetail(result.value); else setError(formatEnvironmentQueryError(result.cause)); }); return () => { cancelled = true; }; }, [environmentId, readDetail, target]); useEffect(() => { if (!props.access) return; return subscribeDeviceForeground( { access: props.access, platform: device.platform, deviceId: device.id }, setForeground, ); }, [device.id, device.platform, props.access]); const act = useCallback( async (body: ActionBody) => { setPending(true); setError(null); try { const result = await runAction({ environmentId, input: { ...target, ...body } as DeviceActionInput, }); if (result._tag === "Success") setDetail(result.value); else setError(formatEnvironmentQueryError(result.cause)); } finally { setPending(false); } }, [environmentId, runAction, target], ); const settings = detail?.settings; const foregroundApp = foreground === undefined ? (detail?.foregroundApp ?? null) : foreground; const disabled = pending || detail === null; return ( <div className={cn("flex min-h-0 flex-col border-border bg-background text-sm", props.className)} > <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3"> <span className="font-medium">Tools</span> {pending ? <Spinner className="size-3.5" /> : null} <Button size="icon-xs" variant="ghost-muted" aria-label="Close tools" className="ml-auto" onClick={props.onClose} > <X /> </Button> </div> <div className="min-h-0 flex-1 overflow-y-auto"> {error ? ( <p className="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{error}</p> ) : null} {detail === null && !error ? ( <div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground"> <Spinner className="size-3.5" /> Reading device settings… </div> ) : null} <Section title="App"> <Row label="Foreground"> <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span> </Row> {foregroundApp ? ( <div className="flex gap-1.5"> <Button size="xs" variant="outline" disabled={disabled} onClick={() => void act({ type: "terminateApp", appId: foregroundApp.id })} > Terminate </Button> <Button size="xs" variant="outline" disabled={disabled} onClick={() => void act({ type: "launchApp", appId: foregroundApp.id })} > Relaunch </Button> </div> ) : null} <SubmitRow placeholder="https://… or myapp://" action="Open" disabled={disabled} onSubmit={(url) => act({ type: "openUrl", url })} /> <SubmitRow placeholder={isIos ? "Bundle ID to launch" : "Package name to launch"} action="Launch" disabled={disabled} onSubmit={(appId) => act({ type: "launchApp", appId })} /> </Section> <Section title={isIos ? "Simulator" : "Emulator"}> <Row label="Appearance"> <ToggleGroup aria-label="Appearance" value={settings?.appearance ? [settings.appearance] : []} disabled={disabled} onValueChange={(value) => { const next = value[0]; if (next === "light" || next === "dark") void act({ type: "setAppearance", value: next }); }} > <Toggle value="light">Light</Toggle> <Toggle value="dark">Dark</Toggle> </ToggleGroup> </Row> <Row label="Text size"> <ChoiceSelect ariaLabel="Text size" value={settings?.textSize ?? null} options={TEXT_SIZES} disabled={disabled} onChange={(value) => act({ type: "setTextSize", value })} /> </Row> {isIos ? ( <> <Row label="Liquid Glass"> <ToggleGroup aria-label="Liquid Glass" value={settings?.liquidGlass ? [settings.liquidGlass] : []} disabled={disabled || settings?.liquidGlass === undefined} onValueChange={(value) => { const next = value[0]; if (next === "clear" || next === "tinted") { void act({ type: "setLiquidGlass", value: next }); } }} > <Toggle value="clear">Clear</Toggle> <Toggle value="tinted">Tinted</Toggle> </ToggleGroup> </Row> <Row label="Color filter"> <ChoiceSelect ariaLabel="Color filter" value={settings?.colorFilter ?? null} options={COLOR_FILTERS} disabled={disabled} onChange={(value) => act({ type: "setColorFilter", value })} /> </Row> </> ) : ( <Row label="Orientation"> <ChoiceSelect ariaLabel="Orientation" value={null} placeholder="Rotate to…" options={ORIENTATIONS} disabled={disabled} onChange={(value) => act({ type: "setOrientation", value })} /> </Row> )} <SwitchRow label="Reduce Motion" checked={settings?.reduceMotion} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "reduceMotion", value })} /> {isIos ? ( <> <SwitchRow label="Increase Contrast" checked={settings?.increaseContrast} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "increaseContrast", value })} /> <SwitchRow label="Reduce Transparency" checked={settings?.reduceTransparency} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "reduceTransparency", value }) } /> <SwitchRow label="Show Borders" checked={settings?.showBorders} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "showBorders", value })} /> <SwitchRow label="VoiceOver" checked={settings?.voiceOver} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "voiceOver", value })} /> </> ) : ( <SwitchRow label="Network" checked={settings?.networkEnabled} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "networkEnabled", value })} /> )} </Section> <Section title="Accessibility"> <SwitchRow label="Overlay element frames" checked={props.axOverlay} disabled={props.access === null} onChange={(value) => { props.onAxOverlayChange(value); return Promise.resolve(); }} /> </Section> <LocationSection disabled={disabled} canClear={isIos} onSet={(latitude, longitude) => act({ type: "setLocation", latitude, longitude })} onClear={() => act({ type: "clearLocation" })} /> <PermissionsSection permissions={isIos ? IOS_PERMISSIONS : ANDROID_PERMISSIONS} canReset={isIos} defaultAppId={foregroundApp?.id ?? ""} disabled={disabled} onDecide={(appId, permission, decision) => act({ type: "setPermission", appId, permission, decision }) } /> {isIos ? ( <Section title="Push notification"> <SubmitRow placeholder="Alert text" action="Send" disabled={disabled || !foregroundApp} onSubmit={(payload) => foregroundApp ? act({ type: "sendPush", appId: foregroundApp.id, payload }) : Promise.resolve() } /> {!foregroundApp ? ( <p className="text-xs text-muted-foreground">Open an app first.</p> ) : null} </Section> ) : null} {isIos && props.access ? <EventLogSection access={props.access} device={device} /> : null} </div> </div> );} function Section(props: { readonly title: string; readonly children: React.ReactNode }) { return ( <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0"> <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> {props.title} </h3> {props.children} </section> );} function Row(props: { readonly label: string; readonly children: React.ReactNode }) { return ( <div className="flex min-h-7 items-center justify-between gap-3"> <span className="shrink-0 text-xs text-muted-foreground">{props.label}</span> <div className="flex min-w-0 items-center justify-end">{props.children}</div> </div> );} function SwitchRow(props: { readonly label: string; readonly checked: boolean | undefined; readonly disabled: boolean; readonly onChange: (value: boolean) => Promise<void>;}) { return ( <Row label={props.label}> <Switch size="sm" aria-label={props.label} checked={props.checked ?? false} disabled={props.disabled || props.checked === undefined} onCheckedChange={(checked) => void props.onChange(checked)} /> </Row> );} function ChoiceSelect<V extends string>(props: { readonly ariaLabel: string; readonly value: V | null; readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; readonly disabled: boolean; readonly placeholder?: string; readonly onChange: (value: V) => Promise<void>;}) { const current = props.options.find((option) => option.value === props.value); return ( <Select value={props.value} disabled={props.disabled} onValueChange={(value) => { if (value !== null && value !== props.value) void props.onChange(value as V); }} > <SelectTrigger size="xs" className="w-40" aria-label={props.ariaLabel}> <SelectValue> {current ? ( current.label ) : ( <span className="text-muted-foreground">{props.placeholder ?? "Unknown"}</span> )} </SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {props.options.map((option) => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> ))} </SelectPopup> </Select> );} function SubmitRow(props: { readonly placeholder: string; readonly action: string; readonly disabled: boolean; readonly onSubmit: (value: string) => Promise<void>;}) { const [value, setValue] = useState(""); const submit = () => { const trimmed = value.trim(); if (!trimmed) return; void props.onSubmit(trimmed).then(() => setValue("")); }; return ( <form className="flex gap-1.5" onSubmit={(event) => { event.preventDefault(); submit(); }} > <Input size="compact" className="min-w-0 flex-1 font-mono" placeholder={props.placeholder} value={value} disabled={props.disabled} onChange={(event) => setValue(event.target.value)}Lines 1–500 of 722. Load more
import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; import type { DeviceActionInput, DeviceDetail, DevicePermission, DeviceSummary, DeviceTextSize, EnvironmentId, } from "@t3tools/contracts"; import { ChevronDown, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "~/components/ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; import { Input } from "~/components/ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { Spinner } from "~/components/ui/spinner"; import { Switch } from "~/components/ui/switch"; import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; import { cn } from "~/lib/utils"; import { deviceEnvironment } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { type DeviceEventLogEntry, type DeviceForegroundInfo, subscribeDeviceEventLog, subscribeDeviceForeground, } from "./deviceHubApi"; type ActionBody = DeviceActionInput extends infer A ? A extends { readonly type: string } ? Omit<A, "hostId" | "deviceId"> : never : never; const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [ { value: "small", label: "Small" }, { value: "default", label: "Default" }, { value: "large", label: "Large" }, { value: "extra-large", label: "Extra large" }, ]; const COLOR_FILTERS = [ { value: "none", label: "None" }, { value: "grayscale", label: "Grayscale" }, { value: "red-green", label: "Red / green (protanopia)" }, { value: "green-red", label: "Green / red (deuteranopia)" }, { value: "blue-yellow", label: "Blue / yellow (tritanopia)" }, ] as const; const ORIENTATIONS = [ { value: "portrait", label: "Portrait" }, { value: "landscape_left", label: "Landscape left" }, { value: "portrait_upside_down", label: "Upside down" }, { value: "landscape_right", label: "Landscape right" }, ] as const; const IOS_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ { value: "camera", label: "Camera" }, { value: "microphone", label: "Microphone" }, { value: "photos", label: "Photos" }, { value: "contacts", label: "Contacts" }, { value: "calendar", label: "Calendar" }, { value: "reminders", label: "Reminders" }, { value: "location", label: "Location" }, { value: "notifications", label: "Notifications" }, { value: "motion", label: "Motion" }, { value: "media-library", label: "Media library" }, { value: "faceid", label: "Face ID" }, ]; const ANDROID_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ { value: "camera", label: "Camera" }, { value: "microphone", label: "Microphone" }, { value: "photos", label: "Photos" }, { value: "contacts", label: "Contacts" }, { value: "calendar", label: "Calendar" }, { value: "location", label: "Location" }, { value: "notifications", label: "Notifications" }, { value: "motion", label: "Physical activity" }, ]; const LOCATION_PRESETS = [ { label: "San Francisco", latitude: 37.7749, longitude: -122.4194 }, { label: "New York", latitude: 40.7128, longitude: -74.006 }, { label: "London", latitude: 51.5074, longitude: -0.1278 }, { label: "Stockholm", latitude: 59.3293, longitude: 18.0686 }, { label: "Tokyo", latitude: 35.6762, longitude: 139.6503 }, ] as const; /** * The Tools drawer for one open device: current settings read from the device, * one control per supported action, and the read-only feeds the hub exposes. * Every change is a `device.action` round trip; the returned detail replaces * local state so the controls never show a value the device did not confirm. */ export function DeviceToolsPanel(props: { readonly environmentId: EnvironmentId; readonly device: DeviceSummary; readonly access: DeviceHubAccess | null; readonly axOverlay: boolean; readonly onAxOverlayChange: (enabled: boolean) => void; readonly onClose: () => void; readonly className?: string; }) { const { environmentId, device } = props; const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false }); const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false }); const [detail, setDetail] = useState<DeviceDetail | null>(null); const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); const [foreground, setForeground] = useState<DeviceForegroundInfo | null | undefined>(undefined); const isIos = device.platform === "ios"; const target = useMemo( () => ({ hostId: device.hostId, deviceId: device.id }), [device.hostId, device.id], ); // The panel is keyed by device, so a mount is always a fresh device. useEffect(() => { let cancelled = false; void readDetail({ environmentId, input: target }).then((result) => { if (cancelled) return; if (result._tag === "Success") setDetail(result.value); else setError(formatEnvironmentQueryError(result.cause)); }); return () => { cancelled = true; }; }, [environmentId, readDetail, target]); useEffect(() => { if (!props.access) return; return subscribeDeviceForeground( { access: props.access, platform: device.platform, deviceId: device.id }, setForeground, ); }, [device.id, device.platform, props.access]); const act = useCallback( async (body: ActionBody) => { setPending(true); setError(null); try { const result = await runAction({ environmentId, input: { ...target, ...body } as DeviceActionInput, }); if (result._tag === "Success") setDetail(result.value); else setError(formatEnvironmentQueryError(result.cause)); } finally { setPending(false); } }, [environmentId, runAction, target], ); const settings = detail?.settings; const foregroundApp = foreground === undefined ? (detail?.foregroundApp ?? null) : foreground; const disabled = pending || detail === null; return ( <div className={cn("flex min-h-0 flex-col border-border bg-background text-sm", props.className)} > <div className="flex h-9 shrink-0 items-center gap-2 border-b px-3"> <span className="font-medium">Tools</span> {pending ? <Spinner className="size-3.5" /> : null} <Button size="icon-xs" variant="ghost-muted" aria-label="Close tools" className="ml-auto" onClick={props.onClose} > <X /> </Button> </div> <div className="min-h-0 flex-1 overflow-y-auto"> {error ? ( <p className="border-b bg-destructive/10 px-3 py-2 text-xs text-destructive">{error}</p> ) : null} {detail === null && !error ? ( <div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground"> <Spinner className="size-3.5" /> Reading device settings… </div> ) : null} <Section title="App"> <Row label="Foreground"> <span className="truncate font-mono text-xs">{foregroundApp?.id ?? "—"}</span> </Row> {foregroundApp ? ( <div className="flex gap-1.5"> <Button size="xs" variant="outline" disabled={disabled} onClick={() => void act({ type: "terminateApp", appId: foregroundApp.id })} > Terminate </Button> <Button size="xs" variant="outline" disabled={disabled} onClick={() => void act({ type: "launchApp", appId: foregroundApp.id })} > Relaunch </Button> </div> ) : null} <SubmitRow placeholder="https://… or myapp://" action="Open" disabled={disabled} onSubmit={(url) => act({ type: "openUrl", url })} /> <SubmitRow placeholder={isIos ? "Bundle ID to launch" : "Package name to launch"} action="Launch" disabled={disabled} onSubmit={(appId) => act({ type: "launchApp", appId })} /> </Section> <Section title={isIos ? "Simulator" : "Emulator"}> <Row label="Appearance"> <ToggleGroup aria-label="Appearance" value={settings?.appearance ? [settings.appearance] : []} disabled={disabled} onValueChange={(value) => { const next = value[0]; if (next === "light" || next === "dark") void act({ type: "setAppearance", value: next }); }} > <Toggle value="light">Light</Toggle> <Toggle value="dark">Dark</Toggle> </ToggleGroup> </Row> <Row label="Text size"> <ChoiceSelect ariaLabel="Text size" value={settings?.textSize ?? null} options={TEXT_SIZES} disabled={disabled} onChange={(value) => act({ type: "setTextSize", value })} /> </Row> {isIos ? ( <> <Row label="Liquid Glass"> <ToggleGroup aria-label="Liquid Glass" value={settings?.liquidGlass ? [settings.liquidGlass] : []} disabled={disabled || settings?.liquidGlass === undefined} onValueChange={(value) => { const next = value[0]; if (next === "clear" || next === "tinted") { void act({ type: "setLiquidGlass", value: next }); } }} > <Toggle value="clear">Clear</Toggle> <Toggle value="tinted">Tinted</Toggle> </ToggleGroup> </Row> <Row label="Color filter"> <ChoiceSelect ariaLabel="Color filter" value={settings?.colorFilter ?? null} options={COLOR_FILTERS} disabled={disabled} onChange={(value) => act({ type: "setColorFilter", value })} /> </Row> </> ) : ( <Row label="Orientation"> <ChoiceSelect ariaLabel="Orientation" value={null} placeholder="Rotate to…" options={ORIENTATIONS} disabled={disabled} onChange={(value) => act({ type: "setOrientation", value })} /> </Row> )} <SwitchRow label="Reduce Motion" checked={settings?.reduceMotion} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "reduceMotion", value })} /> {isIos ? ( <> <SwitchRow label="Increase Contrast" checked={settings?.increaseContrast} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "increaseContrast", value })} /> <SwitchRow label="Reduce Transparency" checked={settings?.reduceTransparency} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "reduceTransparency", value }) } /> <SwitchRow label="Show Borders" checked={settings?.showBorders} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "showBorders", value })} /> <SwitchRow label="VoiceOver" checked={settings?.voiceOver} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "voiceOver", value })} /> </> ) : ( <SwitchRow label="Network" checked={settings?.networkEnabled} disabled={disabled} onChange={(value) => act({ type: "setToggle", setting: "networkEnabled", value })} /> )} </Section> <Section title="Accessibility"> <SwitchRow label="Overlay element frames" checked={props.axOverlay} disabled={props.access === null} onChange={(value) => { props.onAxOverlayChange(value); return Promise.resolve(); }} /> </Section> <LocationSection disabled={disabled} canClear={isIos} onSet={(latitude, longitude) => act({ type: "setLocation", latitude, longitude })} onClear={() => act({ type: "clearLocation" })} /> <PermissionsSection permissions={isIos ? IOS_PERMISSIONS : ANDROID_PERMISSIONS} canReset={isIos} defaultAppId={foregroundApp?.id ?? ""} disabled={disabled} onDecide={(appId, permission, decision) => act({ type: "setPermission", appId, permission, decision }) } /> {isIos ? ( <Section title="Push notification"> <SubmitRow placeholder="Alert text" action="Send" disabled={disabled || !foregroundApp} onSubmit={(payload) => foregroundApp ? act({ type: "sendPush", appId: foregroundApp.id, payload }) : Promise.resolve() } /> {!foregroundApp ? ( <p className="text-xs text-muted-foreground">Open an app first.</p> ) : null} </Section> ) : null} {isIos && props.access ? <EventLogSection access={props.access} device={device} /> : null} </div> </div> ); } function Section(props: { readonly title: string; readonly children: React.ReactNode }) { return ( <section className="flex flex-col gap-2 border-b px-3 py-2.5 last:border-b-0"> <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> {props.title} </h3> {props.children} </section> ); } function Row(props: { readonly label: string; readonly children: React.ReactNode }) { return ( <div className="flex min-h-7 items-center justify-between gap-3"> <span className="shrink-0 text-xs text-muted-foreground">{props.label}</span> <div className="flex min-w-0 items-center justify-end">{props.children}</div> </div> ); } function SwitchRow(props: { readonly label: string; readonly checked: boolean | undefined; readonly disabled: boolean; readonly onChange: (value: boolean) => Promise<void>; }) { return ( <Row label={props.label}> <Switch size="sm" aria-label={props.label} checked={props.checked ?? false} disabled={props.disabled || props.checked === undefined} onCheckedChange={(checked) => void props.onChange(checked)} /> </Row> ); } function ChoiceSelect<V extends string>(props: { readonly ariaLabel: string; readonly value: V | null; readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; readonly disabled: boolean; readonly placeholder?: string; readonly onChange: (value: V) => Promise<void>; }) { const current = props.options.find((option) => option.value === props.value); return ( <Select value={props.value} disabled={props.disabled} onValueChange={(value) => { if (value !== null && value !== props.value) void props.onChange(value as V); }} > <SelectTrigger size="xs" className="w-40" aria-label={props.ariaLabel}> <SelectValue> {current ? ( current.label ) : ( <span className="text-muted-foreground">{props.placeholder ?? "Unknown"}</span> )} </SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {props.options.map((option) => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> ))} </SelectPopup> </Select> ); } function SubmitRow(props: { readonly placeholder: string; readonly action: string; readonly disabled: boolean; readonly onSubmit: (value: string) => Promise<void>; }) { const [value, setValue] = useState(""); const submit = () => { const trimmed = value.trim(); if (!trimmed) return; void props.onSubmit(trimmed).then(() => setValue("")); }; return ( <form className="flex gap-1.5" onSubmit={(event) => { event.preventDefault(); submit(); }} > <Input size="compact" className="min-w-0 flex-1 font-mono" placeholder={props.placeholder} value={value} disabled={props.disabled} onChange={(event) => setValue(event.target.value)}