import type { DevicePlatform, DeviceServiceState, DeviceSummary, ScopedThreadRef, } from "@t3tools/contracts"; import { ChevronLeft, Home, Power, RotateCcw, SlidersHorizontal, Smartphone, Square, X, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; import { Button } from "~/components/ui/button"; import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; import { Dialog } from "~/components/ui/dialog"; import { WizardPopup } from "~/components/ui/wizard"; import { Spinner } from "~/components/ui/spinner"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { cn } from "~/lib/utils"; import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView"; import { DeviceLoadingView } from "./DeviceLoadingView"; import { DeviceSetup } from "./DeviceSetup"; import { DeviceToolsPanel } from "./DeviceToolsPanel"; import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; /** Each surface owns one host/device; only the visible surface streams. */ export function DevicePanel(props: { readonly mode: PreviewPanelMode; readonly threadRef: ScopedThreadRef; readonly surface: Extract; readonly visible: boolean; readonly onDismissSetup: () => void; }) { const { environmentId, threadId } = props.threadRef; const { state, loaded } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); const [pendingDevice, setPendingDevice] = useState(null); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const [handle, setHandle] = useState(null); const [toolsOpen, setToolsOpen] = useState(false); const [axOverlay, setAxOverlay] = useState(false); const access = useDeviceHubAccess(environmentId); const hostDisabled = state.hostStatus === "disabled"; // Opening setup never grants permission to install or start helpers. useEffect(() => { if (!props.visible || !loaded || hostDisabled) return; void list({ environmentId, input: {} }); }, [environmentId, list, loaded, props.visible, hostDisabled]); const sessions = useMemo( () => state.sessions.filter((session) => session.threadId === threadId), [state.sessions, threadId], ); const activeSession = props.surface.target ? sessions.find( (session) => session.deviceId === props.surface.target?.deviceId && session.hostId === props.surface.target.hostId, ) : undefined; const activeDevice = activeSession ? state.devices.find( (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, ) : undefined; const grouped = useMemo(() => groupDevices(state), [state]); const selectDevice = async (value: string) => { const device = state.devices.find((candidate) => deviceKey(candidate) === value); if (!device) return; setOperationError(null); setPendingDevice(device); try { const result = await open({ environmentId, input: { threadId, hostId: device.hostId, deviceId: device.id, platform: device.platform, }, }); if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); else useRightPanelStore.getState().openDevice(props.threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, }); } finally { setPendingDevice(null); } }; const closeActive = (powerOff: boolean) => { if (!powerOff) { useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); return; } if (!activeSession) return; setOperationError(null); void close({ environmentId, input: { threadId, hostId: activeSession.hostId, deviceId: activeSession.deviceId, shutdown: powerOff, }, }).then((result) => { if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); }); }; const bootingDevices = state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready"); const hostBusy = !hostReady && Object.values(state.hostStatuses).some( (host) => host.status === "installing" || host.status === "starting", ); const unavailablePlatforms = state.hosts.flatMap((host) => host.platforms .filter((platform) => !platform.available) .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })), ); if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} > ); } return (
{props.surface.target ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}` : (pendingDevice?.name ?? "Choose a device")} {activeDevice ? ( <> handle?.pressButton("home")} disabled={!handle?.inputConnected} > {activeDevice.platform === "android" ? ( <> handle?.pressButton("back")} disabled={!handle?.inputConnected} > handle?.pressButton("recents")} disabled={!handle?.inputConnected} > ) : ( handle?.rotate()} disabled={!handle?.inputConnected} > )} setToolsOpen(Boolean(pressed))} > closeActive(true)}> closeActive(false)}> ) : null}
{hostReady && state.hostStatusDetail ? (
{state.hostStatusDetail}
) : null} {bootingDevices.length > 0 ? (
Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.
) : null} {operationError ? (

{operationError}

) : null}
{activeDevice && activeSession ? ( <>
host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`} deviceId={activeDevice.id} hostId={activeDevice.hostId} visible={props.visible} axOverlay={axOverlay} onHandle={setHandle} />
{toolsOpen ? ( setToolsOpen(false)} className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[560px]:static @[560px]:w-72 @[560px]:shrink-0 @[560px]:shadow-none" /> ) : null} ) : pendingDevice || hostBusy || !loaded ? ( host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}` : "" } stage="opening" message={ pendingDevice ? pendingDevice.booted ? "Opening device…" : "Starting device…" : state.hostStatus === "installing" ? "Installing device support…" : "Finding devices…" } /> ) : (
{grouped.length === 0 ? ( <>

{state.hostStatus === "failed" ? (state.hostStatusDetail ?? "The device hub failed to start.") : "No simulators or emulators were found on this environment."}

) : null} {hostReady && grouped.length > 0 ? (
{grouped.map((group) => (

{platformLabel(group.platform)}

{group.devices.map((device) => ( } title={device.name} description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`} disabled={pendingDeviceKey !== null} aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`} onClick={() => void selectDevice(deviceKey(device))} action={ pendingDeviceKey === deviceKey(device) ? ( ) : ( {device.booted ? "Open" : "Start"} ) } /> ))}
))}
) : null} {hostReady && !state.devices.some((device) => device.platform === "android") && !unavailablePlatforms.some((platform) => platform.platform === "android") ? (

No Android virtual devices found. Create one in Android Studio's Device Manager, then refresh.

) : null} {loaded && !hostBusy ? ( ) : null}
)}
); } function DeviceButton(props: { readonly label: string; readonly onClick: () => void; readonly disabled?: boolean; readonly children: React.ReactNode; }) { return ( } > {props.children} {props.label} ); } function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { const devices = state.devices .filter((device) => device.platform === platform) .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name)); if (devices.length > 0) groups.push({ platform, devices }); } return groups; }