1import { DeviceHostUpdates } from "./DeviceHostUpdates";
2import type {
3 DevicePlatform,
4 DeviceServiceState,
5 DeviceSummary,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
8import { ArrowUpRight, CircleAlert, Play, Smartphone, X } from "lucide-react";
9import { useAtomMount } from "@effect/atom-react";
10import { type ReactNode, useState } from "react";
11
12import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
13import {
14 selectThreadRightPanelState,
15 useRightPanelStore,
16 type RightPanelSurface,
17} from "~/rightPanelStore";
18import { Button } from "~/components/ui/button";
19import { DiscoveryList } from "~/components/ui/discovery-list";
20import { Dialog } from "~/components/ui/dialog";
21import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
22import { WizardPopup } from "~/components/ui/wizard";
23import { Spinner } from "~/components/ui/spinner";
24import { cn } from "~/lib/utils";
25import { focusPanelTerminalAfterClose } from "~/lib/terminalFocus";
26import {
27 deviceEnvironment,
28 deviceHostProbeAtom,
29 NO_DEVICE_PROBE_ATOM,
30 useDeviceState,
31} from "~/state/device";
32import { formatEnvironmentQueryError } from "~/state/query";
33import { useAtomCommand } from "~/state/use-atom-command";
34import { DeviceLoadingView } from "./DeviceLoadingView";
35import {
36 DeviceSetup,
37 deviceHubReadiness,
38 incompleteListings,
39 isAndroidDiscoveryUncertain,
40} from "./DeviceSetup";
41import { DeviceWorkspace } from "./DeviceWorkspace";
42import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
43
44const platformLabel = (platform: DevicePlatform) =>
45 platform === "ios" ? "iOS Simulators" : "Android Emulators";
46
47const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>
48 `${device.hostId}\u0000${device.id}`;
49
50export function DevicePanel(props: {
51 readonly mode: PreviewPanelMode;
52 readonly threadRef: ScopedThreadRef;
53 readonly surface: Extract<RightPanelSurface, { kind: "device" }>;
54 readonly visible: boolean;
55 readonly onDismissSetup: () => void;
56}) {
57 const { environmentId, threadId } = props.threadRef;
58 const { state, loaded, error: stateError, refresh: refreshState } = useDeviceState(environmentId);
59 const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false });
60 const open = useAtomCommand(deviceEnvironment.open);
61 const close = useAtomCommand(deviceEnvironment.close);
62 // The panel stays mounted across thread switches, so an in-flight open or
63 // power-off is tagged with the thread it belongs to and only shown there.
64 const scope = `${environmentId}\u0000${threadId}`;
65 const [operation, setOperation] = useState<{
66 readonly scope: string;
67 readonly pending: DeviceSummary | null;
68 readonly error: string | null;
69 } | null>(null);
70 const pendingDevice = operation?.scope === scope ? operation.pending : null;
71 const operationError = operation?.scope === scope ? operation.error : null;
72 const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;
73
74 const hostDisabled = state.hostStatus === "disabled";
75
76 useAtomMount(
77 props.visible && loaded && !hostDisabled
78 ? deviceHostProbeAtom(environmentId)
79 : NO_DEVICE_PROBE_ATOM,
80 );
81
82 const sessions = state.sessions.filter((session) => session.threadId === threadId);
83 const activeSession = props.surface.target
84 ? sessions.find(
85 (session) =>
86 session.deviceId === props.surface.target?.deviceId &&
87 session.hostId === props.surface.target.hostId,
88 )
89 : undefined;
90 const activeDevice = activeSession
91 ? state.devices.find(
92 (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,
93 )
94 : undefined;
95 const activeHost = activeDevice
96 ? state.hosts.find((host) => host.id === activeDevice.hostId)
97 : undefined;
98
99 const grouped = groupDevices(state);
100
101 const startOperation = (pending: DeviceSummary | null) => {
102 const started = { scope, pending, error: null };
103 setOperation(started);
104 // Returning to the same thread can start a newer operation before this one settles.
105 return (error: string | null) =>
106 setOperation((current) =>
107 current === started ? { ...started, pending: null, error } : current,
108 );
109 };
110
111 const selectDevice = async (value: string) => {
112 const device = state.devices.find((candidate) => deviceKey(candidate) === value);
113 if (!device) return;
114 const settleOperation = startOperation(device);
115 const result = await open({
116 environmentId,
117 input: {
118 threadId,
119 hostId: device.hostId,
120 deviceId: device.id,
121 platform: device.platform,
122 },
123 });
124 if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause));
125 else {
126 // The server opened the device for the starting thread, so its tab opens there.
127 useRightPanelStore.getState().openDevice(props.threadRef, {
128 hostId: result.value.hostId,
129 deviceId: result.value.deviceId,
130 platform: device.platform,
131 name: device.name,
132 });
133 settleOperation(null);
134 }
135 };
136
137 const floatActive = () => {
138 if (!activeDevice) return;
139 usePreviewMiniPlayerStore.getState().open(props.threadRef, {
140 kind: "device",
141 hostId: activeDevice.hostId,
142 deviceId: activeDevice.id,
143 platform: activeDevice.platform,
144 name: activeDevice.name,
145 });
146 useRightPanelStore.getState().close(props.threadRef);
147 };
148
149 const closeSurface = () => {
150 const store = useRightPanelStore.getState();
151 const { activeSurfaceId } = selectThreadRightPanelState(store.byThreadKey, props.threadRef);
152 store.closeSurface(props.threadRef, props.surface.id);
153 focusPanelTerminalAfterClose(props.threadRef, activeSurfaceId);
154 };
155
156 const closeActive = (powerOff: boolean) => {
157 if (!powerOff) {
158 closeSurface();
159 return;
160 }
161 if (!activeSession) return;
162 const settleOperation = startOperation(null);
163 void close({
164 environmentId,
165 input: {
166 threadId,
167 hostId: activeSession.hostId,
168 deviceId: activeSession.deviceId,
169 shutdown: powerOff,
170 },
171 }).then((result) => {
172 if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause));
173 else closeSurface();
174 });
175 };
176
177 const bootingDevices =
178 state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];
179 const { ready: hostReady, busy: hostBusy } = deviceHubReadiness(state);
180 const loading = !activeDevice && (pendingDevice !== null || hostBusy || !loaded);
181 // Without a first snapshot a failed subscription never settles on its own.
182 const loadError = loaded ? null : stateError;
183 const diagnostics = state.hosts.flatMap((host) => {
184 const status = state.hostStatuses[host.id];
185 const detail = status?.detail ?? (host.kind === "local" ? state.hostStatusDetail : undefined);
186 return detail && (status?.status ?? state.hostStatus) === "ready"
187 ? [
188 {
189 hostId: host.id,
190 label: host.label,
191 detail,
192 incompletePlatforms: incompleteListings(status),
193 },
194 ]
195 : [];
196 });
197 const unavailablePlatforms = state.hosts.flatMap((host) =>
198 host.platforms
199 .filter((platform) => !platform.available)
200 .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })),
201 );
202
203 if (loaded && (!state.onboardingCompleted || hostDisabled)) {
204 return (
205 <Dialog
206 open={props.visible}
207 onOpenChange={(isOpen) => {
208 if (!isOpen) props.onDismissSetup();
209 }}
210 >
211 <WizardPopup>
212 <DeviceSetup environmentId={environmentId} state={state} />
213 </WizardPopup>
214 </Dialog>
215 );
216 }
217
218 return (
219 <PreviewPanelShell mode={props.mode}>
220 <div className="relative flex min-h-0 flex-1 flex-col">
221 <div
222 className={cn(
223 "shrink-0 bg-background",
224 loading && "absolute inset-x-0 top-0 z-10 max-h-[40%] overflow-y-auto",
225 )}
226 >
227 <div hidden={!!activeDevice}>
228 {diagnostics.map((diagnostic) => (
229 <DeviceDiscoveryWarning
230 key={`${environmentId}\u0000${diagnostic.hostId}`}
231 label={diagnostic.label}
232 detail={diagnostic.detail}
233 incompletePlatforms={diagnostic.incompletePlatforms}
234 onRetry={() => list({ environmentId, input: {} })}
235 />
236 ))}
237 </div>
238 <DeviceHostUpdates state={state} environmentId={environmentId} />
239 {bootingDevices.length > 0 ? (
240 <div role="status" className="border-b px-3 py-2 text-xs text-muted-foreground">
241 Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a
242 minute.
243 </div>
244 ) : null}
245 {operationError ? (
246 <DeviceNotice onDismiss={() => setOperation(null)}>{operationError}</DeviceNotice>
247 ) : null}
248 </div>
249 <div className="@container relative flex min-h-0 flex-1">
250 {activeDevice && activeSession ? (
251 <DeviceWorkspace
252 key={`${environmentId}\u0000${deviceKey(activeDevice)}`}
253 environmentId={environmentId}
254 device={activeDevice}
255 hostLabel={activeHost?.label ?? "Device host"}
256 hostKind={activeHost?.kind}
257 hostDiagnostics={
258 diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail
259 }
260 visible={props.visible}
261 onFloat={floatActive}
262 onClose={() => closeActive(false)}
263 onPowerOff={() => closeActive(true)}
264 />
265 ) : loading ? (
266 <DeviceLoadingView
267 name={pendingDevice?.name ?? "Devices"}
268 description={
269 pendingDevice
270 ? `${state.hosts.find((host) => host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}`
271 : ""
272 }
273 stage="opening"
274 message={
275 loadError ??
276 (pendingDevice
277 ? pendingDevice.booted
278 ? "Opening device…"
279 : "Starting device…"
280 : state.hostStatus === "installing"
281 ? (state.hostStatusDetail ?? "Installing device support…")
282 : "Finding devices…")
283 }
284 error={loadError !== null}
285 >
286 {loadError !== null ? (
287 <Button size="sm" variant="outline" onClick={refreshState}>
288 Retry
289 </Button>
290 ) : null}
291 </DeviceLoadingView>
292 ) : (
293 <div className="scrollbar-gutter-both flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
294 <div
295 className={cn(
296 "mx-auto flex w-full max-w-xl flex-col gap-6",
297 grouped.length === 0 && "my-auto items-center text-center",
298 )}
299 >
300 {grouped.length === 0 ? (
301 <>
302 <Smartphone className="size-6 opacity-60" />
303 <p
304 className="max-h-32 w-full max-w-sm overflow-y-auto whitespace-pre-line break-words"
305 tabIndex={state.hostStatus === "failed" ? 0 : undefined}
306 >
307 {state.hostStatus === "failed"
308 ? (state.hostStatusDetail ?? "The device hub failed to start.")
309 : "No simulators or emulators were found on this environment."}
310 </p>
311 </>
312 ) : null}
313 {hostReady && grouped.length > 0 ? (
314 <div className="w-full space-y-6 text-left">
315 {grouped.map((group) => (
316 <section key={group.platform} className="space-y-3">
317 <div className="flex items-center gap-2 text-sm text-muted-foreground">
318 <Smartphone className="size-4 shrink-0" />
319 <h3 className="font-medium">{platformLabel(group.platform)}</h3>
320 </div>
321 <DiscoveryList>
322 {group.devices.map((device) => (
323 <div
324 key={deviceKey(device)}
325 className="flex items-center gap-3 px-3 py-3"
326 >
327 <span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/60">
328 <Smartphone className="size-4" />
329 </span>
330 <div className="flex min-w-0 flex-1 flex-col">
331 <span className="truncate text-sm font-medium text-foreground">
332 {device.name}
333 </span>
334 <span className="truncate text-xs text-muted-foreground">
335 {`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`}
336 </span>
337 </div>
338 <Button
339 variant="outline"
340 size="sm"
341 disabled={pendingDeviceKey !== null}
342 aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}
343 onClick={() => void selectDevice(deviceKey(device))}
344 >
345 {pendingDeviceKey === deviceKey(device) ? (
346 <Spinner size="xs" />
347 ) : device.booted ? (
348 <ArrowUpRight />
349 ) : (
350 <Play />
351 )}
352 {device.booted ? "Open" : "Start"}
353 </Button>
354 </div>
355 ))}
356 </DiscoveryList>
357 </section>
358 ))}
359 </div>
360 ) : null}
361 {hostReady &&
362 !isAndroidDiscoveryUncertain(state) &&
363 !state.devices.some((device) => device.platform === "android") &&
364 !unavailablePlatforms.some((platform) => platform.platform === "android") ? (
365 <p className="max-w-sm text-xs">
366 No Android virtual devices found. Create one in Android Studio's Device Manager,
367 then refresh.
368 </p>
369 ) : null}
370 {loaded && !hostBusy ? (
371 <Button
372 className={grouped.length > 0 ? "self-start" : "self-center"}
373 variant="outline"
374 size="sm"
375 onClick={() => void list({ environmentId, input: {} })}
376 >
377 Refresh devices
378 </Button>
379 ) : null}
380 </div>
381 </div>
382 )}
383 </div>
384 </div>
385 </PreviewPanelShell>
386 );
387}
388
389function DeviceNotice(props: { readonly onDismiss: () => void; readonly children: ReactNode }) {
390 return (
391 <div
392 role="alert"
393 className="flex items-start gap-2 border-b bg-destructive/5 px-3 py-2 text-xs text-destructive"
394 >
395 <CircleAlert className="mt-1 size-3.5 shrink-0" />
396 <div
397 className="max-h-32 min-w-0 flex-1 overflow-y-auto whitespace-pre-wrap break-words"
398 tabIndex={0}
399 >
400 {props.children}
401 </div>
402 <Button
403 size="icon-xs"
404 variant="ghost"
405 aria-label="Dismiss device error"
406 onClick={props.onDismiss}
407 >
408 <X />
409 </Button>
410 </div>
411 );
412}
413
414function DeviceDiscoveryWarning(props: {
415 readonly label: string;
416 readonly detail: string;
417 readonly incompletePlatforms: readonly DevicePlatform[];
418 readonly onRetry: () => Promise<unknown>;
419}) {
420 const [open, setOpen] = useState(false);
421 const [retrying, setRetrying] = useState(false);
422 const [dismissedCause, setDismissedCause] = useState<string | null>(null);
423 const cause = `${props.incompletePlatforms.join()}\u0000${props.detail}`;
424 // A dismissal hides one occurrence; a cause that returns later shows again.
425 if (dismissedCause !== null && dismissedCause !== cause) setDismissedCause(null);
426 if (dismissedCause === cause) return null;
427 const platforms = props.incompletePlatforms.map((platform) =>
428 platform === "ios" ? "iOS" : "Android",
429 );
430 return (
431 <div
432 role="status"
433 className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b px-3 py-1 text-xs text-muted-foreground"
434 >
435 <span className="flex min-w-0 flex-1 basis-56 items-start gap-2">
436 <CircleAlert className="mt-px size-3.5 shrink-0 text-warning-foreground" />
437 <span className="min-w-0">
438 {platforms.length > 0
439 ? `${platforms.join(" and ")} ${platforms.length > 1 ? "lists" : "list"} on ${props.label} may be incomplete.`
440 : `Device discovery on ${props.label} reported a warning.`}
441 </span>
442 </span>
443 <span className="ml-auto flex shrink-0 items-center gap-0.5">
444 {platforms.length > 0 ? (
445 <Button
446 size="micro"
447 variant="ghost"
448 disabled={retrying}
449 aria-label={`Retry device discovery on ${props.label}`}
450 onClick={() => {
451 setRetrying(true);
452 void props.onRetry().finally(() => setRetrying(false));
453 }}
454 >
455 {retrying ? "Retrying…" : "Retry"}
456 </Button>
457 ) : null}
458 <Popover open={open} onOpenChange={setOpen}>
459 <PopoverTrigger
460 aria-label={`Details for ${props.label}`}
461 render={<Button size="micro" variant="ghost" />}
462 >
463 Details
464 </PopoverTrigger>
465 <PopoverPopup side="bottom" align="end" width="lg" padding="compact">
466 <p className="whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">
467 {props.detail}
468 </p>
469 </PopoverPopup>
470 </Popover>
471 <Button
472 size="icon-micro"
473 variant="ghost"
474 aria-label={`Dismiss device warning for ${props.label}`}
475 onClick={() => {
476 setOpen(false);
477 setDismissedCause(cause);
478 }}
479 >
480 <X />
481 </Button>
482 </span>
483 </div>
484 );
485}
486
487function groupDevices(state: DeviceServiceState) {
488 const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];
489 for (const platform of ["ios", "android"] as const) {
490 const devices = state.devices
491 .filter((device) => device.platform === platform)
492 .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name));
493 if (devices.length > 0) groups.push({ platform, devices });
494 }
495 return groups;
496}