1import { DeviceHostUpdates } from "./DeviceHostUpdates";
2import type {
3 DevicePlatform,
4 DeviceServiceState,
5 DeviceSummary,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
8import { Smartphone, X } from "lucide-react";
9import { useEffect, useMemo, useState } from "react";
10
11import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
12import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
13import { Button } from "~/components/ui/button";
14import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
15import { Dialog } from "~/components/ui/dialog";
16import { WizardPopup } from "~/components/ui/wizard";
17import { Spinner } from "~/components/ui/spinner";
18import { cn } from "~/lib/utils";
19import { deviceEnvironment, useDeviceState } from "~/state/device";
20import { formatEnvironmentQueryError } from "~/state/query";
21import { useAtomCommand } from "~/state/use-atom-command";
22import { DeviceLoadingView } from "./DeviceLoadingView";
23import { DeviceSetup } from "./DeviceSetup";
24import { DeviceWorkspace } from "./DeviceWorkspace";
25import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
26
27const platformLabel = (platform: DevicePlatform) =>
28 platform === "ios" ? "iOS Simulators" : "Android Emulators";
29
30const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>
31 `${device.hostId}\u0000${device.id}`;
32
33/** Each surface owns one host/device; only the visible surface streams. */
34export function DevicePanel(props: {
35 readonly mode: PreviewPanelMode;
36 readonly threadRef: ScopedThreadRef;
37 readonly surface: Extract<RightPanelSurface, { kind: "device" }>;
38 readonly visible: boolean;
39 readonly onDismissSetup: () => void;
40}) {
41 const { environmentId, threadId } = props.threadRef;
42 const { state, loaded } = useDeviceState(environmentId);
43 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
44 const open = useAtomCommand(deviceEnvironment.open);
45 const close = useAtomCommand(deviceEnvironment.close);
46 const [operationError, setOperationError] = useState<string | null>(null);
47 const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null);
48 const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;
49
50 const hostDisabled = state.hostStatus === "disabled";
51
52 // Opening setup never grants permission to install or start helpers.
53 useEffect(() => {
54 if (!props.visible || !loaded || hostDisabled) return;
55 void list({ environmentId, input: {} });
56 }, [environmentId, list, loaded, props.visible, hostDisabled]);
57
58 const sessions = useMemo(
59 () => state.sessions.filter((session) => session.threadId === threadId),
60 [state.sessions, threadId],
61 );
62 const activeSession = props.surface.target
63 ? sessions.find(
64 (session) =>
65 session.deviceId === props.surface.target?.deviceId &&
66 session.hostId === props.surface.target.hostId,
67 )
68 : undefined;
69 const activeDevice = activeSession
70 ? state.devices.find(
71 (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,
72 )
73 : undefined;
74
75 const grouped = useMemo(() => groupDevices(state), [state]);
76
77 const selectDevice = async (value: string) => {
78 const device = state.devices.find((candidate) => deviceKey(candidate) === value);
79 if (!device) return;
80 setOperationError(null);
81 setPendingDevice(device);
82 try {
83 const result = await open({
84 environmentId,
85 input: {
86 threadId,
87 hostId: device.hostId,
88 deviceId: device.id,
89 platform: device.platform,
90 },
91 });
92 if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
93 else
94 useRightPanelStore.getState().openDevice(props.threadRef, {
95 hostId: result.value.hostId,
96 deviceId: result.value.deviceId,
97 platform: device.platform,
98 name: device.name,
99 });
100 } finally {
101 setPendingDevice(null);
102 }
103 };
104
105 // Floating the device closes the panel, like the browser's floating preview.
106 const floatActive = () => {
107 if (!activeDevice) return;
108 usePreviewMiniPlayerStore.getState().open(props.threadRef, {
109 kind: "device",
110 hostId: activeDevice.hostId,
111 deviceId: activeDevice.id,
112 platform: activeDevice.platform,
113 name: activeDevice.name,
114 });
115 useRightPanelStore.getState().close(props.threadRef);
116 };
117
118 const closeActive = (powerOff: boolean) => {
119 if (!powerOff) {
120 useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
121 return;
122 }
123 if (!activeSession) return;
124 setOperationError(null);
125 void close({
126 environmentId,
127 input: {
128 threadId,
129 hostId: activeSession.hostId,
130 deviceId: activeSession.deviceId,
131 shutdown: powerOff,
132 },
133 }).then((result) => {
134 if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
135 else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
136 });
137 };
138
139 const bootingDevices =
140 state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];
141 const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready");
142 const hostBusy =
143 !hostReady &&
144 Object.values(state.hostStatuses).some(
145 (host) => host.status === "installing" || host.status === "starting",
146 );
147 const unavailablePlatforms = state.hosts.flatMap((host) =>
148 host.platforms
149 .filter((platform) => !platform.available)
150 .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })),
151 );
152
153 if (loaded && (!state.onboardingCompleted || hostDisabled)) {
154 return (
155 <Dialog
156 open={props.visible}
157 onOpenChange={(isOpen) => {
158 if (!isOpen) props.onDismissSetup();
159 }}
160 >
161 <WizardPopup>
162 <DeviceSetup environmentId={environmentId} state={state} />
163 </WizardPopup>
164 </Dialog>
165 );
166 }
167
168 return (
169 <PreviewPanelShell mode={props.mode}>
170 {hostReady && !activeDevice && state.hostStatusDetail ? (
171 <div
172 role="status"
173 className="whitespace-pre-line border-b px-3 py-2 text-xs text-muted-foreground"
174 >
175 {state.hostStatusDetail}
176 </div>
177 ) : null}
178 <DeviceHostUpdates state={state} environmentId={environmentId} />
179 {bootingDevices.length > 0 ? (
180 <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">
181 Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.
182 </div>
183 ) : null}
184 {operationError ? (
185 <div
186 role="alert"
187 className="flex items-start gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive"
188 >
189 <p className="min-w-0 flex-1 whitespace-pre-wrap break-words">{operationError}</p>
190 <Button
191 size="icon-xs"
192 variant="ghost"
193 aria-label="Dismiss device error"
194 onClick={() => setOperationError(null)}
195 >
196 <X className="size-3" />
197 </Button>
198 </div>
199 ) : null}
200 <div className="@container relative flex min-h-0 flex-1">
201 {activeDevice && activeSession ? (
202 <DeviceWorkspace
203 key={`${environmentId}\u0000${deviceKey(activeDevice)}`}
204 environmentId={environmentId}
205 device={activeDevice}
206 hostLabel={
207 state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host"
208 }
209 hostDiagnostics={state.hostStatusDetail}
210 visible={props.visible}
211 onFloat={floatActive}
212 onClose={() => closeActive(false)}
213 onPowerOff={() => closeActive(true)}
214 />
215 ) : pendingDevice || hostBusy || !loaded ? (
216 <DeviceLoadingView
217 name={pendingDevice?.name ?? "Devices"}
218 description={
219 pendingDevice
220 ? `${state.hosts.find((host) => host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}`
221 : ""
222 }
223 stage="opening"
224 message={
225 pendingDevice
226 ? pendingDevice.booted
227 ? "Opening device…"
228 : "Starting device…"
229 : state.hostStatus === "installing"
230 ? (state.hostStatusDetail ?? "Installing device support…")
231 : "Finding devices…"
232 }
233 />
234 ) : (
235 <div className="scrollbar-gutter-both flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
236 <div
237 className={cn(
238 "mx-auto flex w-full max-w-xl flex-col gap-6",
239 grouped.length === 0 && "my-auto items-center text-center",
240 )}
241 >
242 {grouped.length === 0 ? (
243 <>
244 <Smartphone className="size-6 opacity-60" />
245 <p className="max-w-sm">
246 {state.hostStatus === "failed"
247 ? (state.hostStatusDetail ?? "The device hub failed to start.")
248 : "No simulators or emulators were found on this environment."}
249 </p>
250 </>
251 ) : null}
252 {hostReady && grouped.length > 0 ? (
253 <div className="w-full space-y-6 text-left">
254 {grouped.map((group) => (
255 <section key={group.platform} className="space-y-3">
256 <div className="flex items-center gap-2 text-sm text-muted-foreground">
257 <Smartphone className="size-4 shrink-0" />
258 <h3 className="font-medium">{platformLabel(group.platform)}</h3>
259 </div>
260 <DiscoveryList>
261 {group.devices.map((device) => (
262 <DiscoveryListRow
263 key={deviceKey(device)}
264 icon={
265 <span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/60">
266 <Smartphone className="size-4" />
267 </span>
268 }
269 title={device.name}
270 description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`}
271 disabled={pendingDeviceKey !== null}
272 aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}
273 onClick={() => void selectDevice(deviceKey(device))}
274 action={
275 pendingDeviceKey === deviceKey(device) ? (
276 <Spinner size="xs" />
277 ) : (
278 <span className="text-xs text-muted-foreground">
279 {device.booted ? "Open" : "Start"}
280 </span>
281 )
282 }
283 />
284 ))}
285 </DiscoveryList>
286 </section>
287 ))}
288 </div>
289 ) : null}
290 {hostReady &&
291 !state.devices.some((device) => device.platform === "android") &&
292 !unavailablePlatforms.some((platform) => platform.platform === "android") ? (
293 <p className="max-w-sm text-xs">
294 No Android virtual devices found. Create one in Android Studio's Device Manager,
295 then refresh.
296 </p>
297 ) : null}
298 {loaded && !hostBusy ? (
299 <Button
300 className={grouped.length > 0 ? "self-start" : "self-center"}
301 variant={grouped.length > 0 ? "ghost" : "outline"}
302 size="sm"
303 onClick={() => void list({ environmentId, input: {} })}
304 >
305 Refresh devices
306 </Button>
307 ) : null}
308 </div>
309 </div>
310 )}
311 </div>
312 </PreviewPanelShell>
313 );
314}
315
316function groupDevices(state: DeviceServiceState) {
317 const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];
318 for (const platform of ["ios", "android"] as const) {
319 const devices = state.devices
320 .filter((device) => device.platform === platform)
321 .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name));
322 if (devices.length > 0) groups.push({ platform, devices });
323 }
324 return groups;
325}