History

feat(devices): scope targets and sessions to their hosts (#10854)

Julius Marminge committed 2 months ago

Commit e022fa41 parent dca7b5919 files · +649 −257Browse files

Unified · Split

19 files
  1. apps/server/src/device/DeviceHubProxy.ts +2 −1
  2. apps/server/src/device/DeviceMultiHost.test.ts +86 −0
  3. apps/server/src/device/DeviceService.test.ts +1 −0
  4. apps/server/src/device/DeviceService.ts +58 −51
  5. apps/server/src/mcp/McpDeviceToolkit.test.ts +1 −0
  6. apps/server/src/mcp/toolkits/device/handlers.ts +4 −1
  7. apps/server/src/server.test.ts +2 −1
  8. apps/web/src/components/ChatView.tsx +59 −19
  9. apps/web/src/components/Icons.tsx +26 −0
  10. apps/web/src/components/RightPanelTabs.tsx +58 −15
  11. apps/web/src/components/device/DeviceLoadingView.tsx +47 −0
  12. apps/web/src/components/device/DevicePanel.tsx +100 −148
  13. apps/web/src/components/device/DeviceStreamView.test.tsx +1 −0
  14. apps/web/src/components/device/DeviceStreamView.tsx +13 −8
  15. apps/web/src/components/settings/IntegrationsSettings.test.tsx +1 −0
  16. apps/web/src/rightPanelStore.test.ts +81 −0
  17. apps/web/src/rightPanelStore.ts +87 −10
  18. apps/web/src/state/device.ts +14 −3
  19. packages/contracts/src/device.ts +8 −0
apps/server/src/device/DeviceHubProxy.ts+2−1
@@ -196,7 +196,7 @@ const handler = Effect.gen(function* () { · 195 unmodified lines
196196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));
197197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
198198 const devices = yield* DeviceService.DeviceService;
199− const ready = yield* devices.currentReadiness();
199+ const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
200200 if (!ready) {
201201 return HttpServerResponse.text("Device hub is not running", { status: 503 });
202202 }
@@ -206,6 +206,7 @@ const handler = Effect.gen(function* () { · 3 unmodified lines
206206 // The ticket authenticates here and must not travel on to the hub.
207207 const upstreamSearch = new URLSearchParams(url.value.search);
208208 upstreamSearch.delete("wsTicket");
209+ upstreamSearch.delete("hostId");
209210 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
210211 const upstreamPath = `${hubPath}${search}`;
211212 if (upgrade) {
apps/server/src/device/DeviceMultiHost.test.ts+86−0
@@ -0,0 +1,86 @@
1+import { expect, it } from "@effect/vitest";
2+import { ThreadId } from "@t3tools/contracts";
3+import * as Effect from "effect/Effect";
4+import { HttpClient, HttpClientResponse } from "effect/unstable/http";
5+import { ServerSettingsService } from "../serverSettings.ts";
6+import { DeviceHostError, DeviceHost } from "./DeviceHost.ts";
7+import { makeWithHosts } from "./DeviceService.ts";
8+
9+it.effect("keeps hosts independent when serials collide and another host fails", () =>
10+ Effect.gen(function* () {
11+ const host = (id: string, failed = false): DeviceHost["Service"] => {
12+ const ready = {
13+ hub: { origin: `http://${id}` },
14+ agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" },
15+ run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
16+ helpers: { serveSimAxSettings: null, serveSimCli: null },
17+ };
18+ return {
19+ id,
20+ summary: Effect.succeed({
21+ id,
22+ label: id,
23+ kind: "local",
24+ hubInstalled: true,
25+ agentDeviceInstalled: true,
26+ platforms: [{ platform: "android", available: true }],
27+ }),
28+ platformAvailability: (platform) => Effect.succeed({ platform, available: true }),
29+ ensureReady: () =>
30+ failed
31+ ? Effect.fail(
32+ new DeviceHostError({ hostId: id, step: "connect", cause: new Error("offline") }),
33+ )
34+ : Effect.succeed(ready),
35+ ensureAgentReady: () => Effect.succeed(ready),
36+ current: Effect.succeed(ready),
37+ stopAgent: Effect.void,
38+ stop: Effect.void,
39+ };
40+ };
41+ const http = HttpClient.make((request) =>
42+ Effect.succeed(
43+ HttpClientResponse.fromWeb(
44+ request,
45+ Response.json({
46+ simulators: [],
47+ emulators: [
48+ {
49+ id: "emulator-5554",
50+ name: "Pixel",
51+ version: "36",
52+ platform: "android",
53+ booted: true,
54+ physical: false,
55+ },
56+ ],
57+ }),
58+ ),
59+ ),
60+ );
61+ const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")]));
62+ const service = yield* makeWithHosts(hosts).pipe(
63+ Effect.provideService(HttpClient.HttpClient, http),
64+ );
65+ const listed = yield* service.list;
66+ expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
67+ expect(listed.hostStatuses.offline?.status).toBe("failed");
68+ const threadId = ThreadId.make("thread");
69+ for (const hostId of ["a", "b"])
70+ yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" });
71+ yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" });
72+ const state = yield* service.state;
73+ expect(state.devices).toHaveLength(2);
74+ expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]);
75+ expect(state.hostStatuses.a?.status).toBe("ready");
76+ expect(state.hostStatuses.offline?.status).toBe("failed");
77+ yield* service.agentReadinessIfSupported("b");
78+ expect((yield* service.state).hostStatuses.b?.status).toBe("ready");
79+ yield* service.configure({ enabled: false });
80+ expect((yield* service.state).hostStatuses).toEqual({});
81+ }).pipe(
82+ Effect.provide(
83+ ServerSettingsService.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }),
84+ ),
85+ ),
86+);
apps/server/src/device/DeviceService.test.ts+1−0
@@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts"; · 20 unmodified lines
2121 const baseState: DeviceServiceState = {
2222 hosts: [],
2323 hostStatus: "idle",
24+ hostStatuses: {},
2425 devices: [],
2526 sessions: [],
2627 onboardingCompleted: false,
apps/server/src/device/DeviceService.ts+58−51
@@ -136,8 +136,9 @@ interface ServiceState { · 135 unmodified lines
136136 const vendorPrefix = (platform: DevicePlatform) =>
137137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
138138
139−export const make = Effect.gen(function* () {
140− const localHost = yield* DeviceHost.DeviceHost;
139+export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
140+ hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
141+) {
141142 const settings = yield* ServerSettings.ServerSettingsService;
142143 const lifecycleLock = yield* Semaphore.make(1);
143144 const readDeviceSettings = settings.getSettings.pipe(
@@ -152,9 +153,7 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
152153 ),
153154 );
154155 const initialSettings = yield* readDeviceSettings;
155− const hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]> = new Map([
156− [localHost.id, localHost],
157− ]);
156+
158157 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
159158 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
160159 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
@@ -162,6 +161,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
162161 state: {
163162 hosts: initialHosts,
164163 hostStatus: initialSettings.enabled ? "idle" : "disabled",
164+ hostStatuses: {},
165165 devices: [],
166166 sessions: [],
167167 onboardingCompleted: initialSettings.onboardingCompleted,
@@ -187,6 +187,18 @@ export const make = Effect.gen(function* () { · 19 unmodified lines
187187 return host;
188188 });
189189
190+ const setHostStatus = (
191+ hostId: DeviceHostId,
192+ status: DeviceServiceState["hostStatuses"][string],
193+ ) =>
194+ publish((state) => ({
195+ ...state,
196+ ...(hostId === LOCAL_DEVICE_HOST_ID
197+ ? { hostStatus: status.status, hostStatusDetail: status.detail }
198+ : {}),
199+ hostStatuses: { ...state.hostStatuses, [hostId]: status },
200+ }));
201+
190202 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
191203 function* (hostId) {
192204 const host = yield* resolveHost(hostId);
@@ -198,34 +210,19 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
198210 });
199211 }
200212 const ready = yield* host
201− .ensureReady((phase) =>
202− publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
203− Effect.asVoid,
204− ),
205− )
213+ .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid))
206214 .pipe(
207215 Effect.tapError((error) =>
208− publish((state) => ({
209− ...state,
210− hostStatus: "failed",
211− hostStatusDetail: error.message,
212− })),
216+ setHostStatus(host.id, { status: "failed", detail: error.message }),
213217 ),
214218 Effect.mapError(
215219 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
216220 ),
217221 );
218− yield* SynchronizedRef.get(stateRef).pipe(
219− Effect.flatMap(({ state }) =>
220− state.hostStatus === "ready"
221− ? Effect.void
222− : publish((current) => ({
223− ...current,
224− hostStatus: "ready",
225− hostStatusDetail: undefined,
226− })),
227− ),
228− );
222+ const { state } = yield* SynchronizedRef.get(stateRef);
223+ if (state.hostStatuses[host.id]?.status !== "ready") {
224+ yield* setHostStatus(host.id, { status: "ready" });
225+ }
229226 return { hostId: host.id, ...ready };
230227 },
231228 lifecycleLock.withPermit,
@@ -249,25 +246,18 @@ export const make = Effect.gen(function* () { · 17 unmodified lines
249246 const summary = yield* host.summary;
250247 if (!summary.platforms.some((platform) => platform.available)) return null;
251248 const ready = yield* host
252− .ensureAgentReady((phase) =>
253− publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
254− Effect.asVoid,
255− ),
256− )
249+ .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
257250 .pipe(
258251 Effect.tapError((error) =>
259− publish((state) => ({
260− ...state,
261− hostStatus: "failed",
262− hostStatusDetail: error.message,
263− })),
252+ setHostStatus(host.id, { status: "failed", detail: error.message }),
264253 ),
265254 Effect.mapError(
266255 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
267256 ),
268257 );
269258 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);
270− yield* publish((state) => ({ ...state, hosts: hostSummaries, hostStatus: "ready" }));
259+ yield* publish((state) => ({ ...state, hosts: hostSummaries }));
260+ yield* setHostStatus(host.id, { status: "ready" });
271261 return { hostId: host.id, ...ready };
272262 }, lifecycleLock.withPermit);
273263
@@ -357,8 +347,15 @@ export const make = Effect.gen(function* () { · 83 unmodified lines
357347 return yield* publish((state) => ({
358348 ...state,
359349 hosts: hostSummaries,
360− devices,
361− hostStatusDetail: detail,
350+ ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}),
351+ devices: [
352+ ...state.devices.filter((device) => device.hostId !== ready.hostId),
353+ ...devices,
354+ ],
355+ hostStatuses: {
356+ ...state.hostStatuses,
357+ [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) },
358+ },
362359 }));
363360 }),
364361 );
@@ -366,18 +363,21 @@ export const make = Effect.gen(function* () { · 1 unmodified line
366363
367364 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
368365 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
369− const ready = yield* readiness();
370− return yield* refresh(ready);
371− }).pipe(
372− Effect.tapError((error) =>
373− publish((state) =>
374− state.hostStatus === "disabled"
375− ? state
376− : { ...state, hostStatus: "failed", hostStatusDetail: error.message },
377− ),
378− ),
379− Effect.withSpan("DeviceService.list"),
380− );
366+ yield* Effect.forEach(
367+ hosts.values(),
368+ (host) =>
369+ Effect.gen(function* () {
370+ const ready = yield* readinessIfSupported(host.id);
371+ if (ready) yield* refresh(ready);
372+ }).pipe(
373+ Effect.catch((error) =>
374+ setHostStatus(host.id, { status: "failed", detail: error.message }),
375+ ),
376+ ),
377+ { concurrency: 4 },
378+ );
379+ return (yield* SynchronizedRef.get(stateRef)).state;
380+ }).pipe(Effect.withSpan("DeviceService.list"));
381381
382382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
383383 function* (input) {
@@ -415,6 +415,7 @@ export const make = Effect.gen(function* () { · 31 unmodified lines
415415 ...state,
416416 hostStatus: nextEnabled ? "idle" : "disabled",
417417 hostStatusDetail: undefined,
418+ hostStatuses: {},
418419 devices: nextEnabled ? state.devices : [],
419420 sessions: nextEnabled ? state.sessions : [],
420421 bootingDevices: nextEnabled ? state.bootingDevices : [],
@@ -636,6 +637,7 @@ export const make = Effect.gen(function* () { · 215 unmodified lines
636637 const closing = state.sessions.filter(
637638 (session) =>
638639 session.threadId === input.threadId &&
640+ (input.hostId === undefined || session.hostId === input.hostId) &&
639641 (input.deviceId === undefined || session.deviceId === input.deviceId),
640642 );
641643 if (closing.length === 0) return;
@@ -749,6 +751,11 @@ export const make = Effect.gen(function* () { · 107 unmodified lines
749751 });
750752 });
751753
754+export const make = Effect.gen(function* () {
755+ const host = yield* DeviceHost.DeviceHost;
756+ return yield* makeWithHosts(new Map([[host.id, host]]));
757+});
758+
752759 export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
753760
754761 /** State stream for WS subscribers: current snapshot first, then every change. */
apps/server/src/mcp/McpDeviceToolkit.test.ts+1−0
@@ -56,6 +56,7 @@ const state = { · 55 unmodified lines
5656 },
5757 ],
5858 hostStatus: "ready" as const,
59+ hostStatuses: { local: { status: "ready" as const } },
5960 devices: [device],
6061 sessions: [],
6162 onboardingCompleted: true,
apps/server/src/mcp/toolkits/device/handlers.ts+4−1
@@ -161,7 +161,9 @@ const handlers = { · 160 unmodified lines
161161 const target =
162162 input.deviceId !== undefined
163163 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }
164− : sessions.at(-1);
164+ : sessions
165+ .filter((session) => input.hostId === undefined || session.hostId === input.hostId)
166+ .at(-1);
165167 if (!target) {
166168 return yield* new DeviceToolUnavailableError({
167169 reason: "No device is open in this thread. Call device_open first.",
@@ -183,6 +185,7 @@ const handlers = { · 15 unmodified lines
183185 const devices = yield* DeviceService.DeviceService;
184186 yield* devices.close({
185187 threadId: scope.threadId,
188+ ...(input.hostId === undefined ? {} : { hostId: input.hostId }),
186189 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
187190 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),
188191 });
apps/server/src/server.test.ts+2−1
@@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( · 1668 unmodified lines
16691669
16701670 const EMPTY_DEVICE_STATE: DeviceServiceState = {
16711671 hosts: [],
1672− hostStatus: "idle",
1672+ hostStatus: "disabled",
1673+ hostStatuses: {},
16731674 devices: [],
16741675 sessions: [],
16751676 onboardingCompleted: false,
apps/web/src/components/ChatView.tsx+59−19
@@ -4143,7 +4143,9 @@ export default function ChatView(props: ChatViewProps) { · 4142 unmodified lines
41434143 if (!activeThreadRef || !supportsThreadPullRequests) return;
41444144 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
41454145 }, [activeThreadRef, supportsThreadPullRequests]);
4146− const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null);
4146+ const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState(
4147+ activeThreadRef?.environmentId ?? null,
4148+ );
41474149 const [deviceSetupThread, setDeviceSetupThread] = useState<ScopedThreadRef | null>(null);
41484150 const addDeviceSurface = useCallback(() => {
41494151 if (!activeThreadRef) return;
@@ -4153,24 +4155,53 @@ export default function ChatView(props: ChatViewProps) { · 3 unmodified lines
41534155 }
41544156 useRightPanelStore.getState().open(activeThreadRef, "device");
41554157 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
4156− // An agent's `device_open` surfaces in every client the same way a
4157− // `preview_open` does: the thread starts a device or gains a session and the panel
4158− // opens on it. Closing the last session leaves the tab in place so the
4159− // user keeps their picker; only new sessions raise the panel.
4160− const threadDeviceSessionCount = activeThreadRef
4161− ? deviceState.sessions.filter((session) => session.threadId === activeThreadRef.threadId)
4162− .length +
4163− (deviceState.bootingDevices?.filter((device) => device.threadId === activeThreadRef.threadId)
4164− .length ?? 0)
4165− : 0;
4166− const previousDeviceSessionCount = useRef(threadDeviceSessionCount);
4158+ // Reconcile new server sessions into separate tabs, including sessions opened
4159+ // by an agent or another client. The first snapshot is a baseline: persisted
4160+ // tabs restore themselves, and existing sessions must not resurrect closed tabs.
4161+ const previousDeviceSessions = useRef(new Map<string, Set<string>>());
41674162 useEffect(() => {
4168− const previous = previousDeviceSessionCount.current;
4169− previousDeviceSessionCount.current = threadDeviceSessionCount;
4170− if (!activeThreadRef || threadDeviceSessionCount <= previous) return;
4171− if (shouldUseRightPanelSheet) return;
4172− useRightPanelStore.getState().open(activeThreadRef, "device");
4173− }, [activeThreadRef, shouldUseRightPanelSheet, threadDeviceSessionCount]);
4163+ if (!activeThreadRef || !deviceStateLoaded) return;
4164+ const threadKey = `${activeThreadRef.environmentId}:${activeThreadRef.threadId}`;
4165+ const sessions = deviceState.sessions.filter(
4166+ (session) => session.threadId === activeThreadRef.threadId,
4167+ );
4168+ const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`;
4169+ const previous = previousDeviceSessions.current.get(threadKey);
4170+ previousDeviceSessions.current.set(threadKey, new Set(sessions.map(key)));
4171+ if (!previous || shouldUseRightPanelSheet) return;
4172+ for (const session of sessions) {
4173+ if (previous?.has(key(session))) continue;
4174+ const existing = useRightPanelStore
4175+ .getState()
4176+ .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some(
4177+ (surface) =>
4178+ surface.kind === "device" &&
4179+ surface.target?.hostId === session.hostId &&
4180+ surface.target.deviceId === session.deviceId,
4181+ );
4182+ if (existing) continue;
4183+ const device = deviceState.devices.find(
4184+ (entry) => entry.hostId === session.hostId && entry.id === session.deviceId,
4185+ );
4186+ if (!device) continue;
4187+ useRightPanelStore.getState().openDevice(
4188+ activeThreadRef,
4189+ {
4190+ hostId: session.hostId,
4191+ deviceId: session.deviceId,
4192+ platform: device.platform,
4193+ name: device.name,
4194+ },
4195+ true,
4196+ );
4197+ }
4198+ }, [
4199+ activeThreadRef,
4200+ deviceStateLoaded,
4201+ shouldUseRightPanelSheet,
4202+ deviceState.sessions,
4203+ deviceState.devices,
4204+ ]);
41744205 const openFileSurface = useCallback(
41754206 (relativePath: string) => {
41764207 if (!activeThreadRef || !activeProject) return;
@@ -8153,7 +8184,8 @@ export default function ChatView(props: ChatViewProps) { · 3976 unmodified lines
81538184 <DevicePanel
81548185 mode="embedded"
81558186 threadRef={activeThreadRef}
8156− deviceId={null}
8187+ key={renderedRightPanelSurface.id}
8188+ surface={renderedRightPanelSurface}
81578189 visible={rightPanelOpen}
81588190 onDismissSetup={() => {
81598191 closeRightPanelSurface(renderedRightPanelSurface);
@@ -8725,6 +8757,10 @@ export default function ChatView(props: ChatViewProps) { · 565 unmodified lines
87258757 terminalLabelsById={activeTerminalLabelsById}
87268758 onActivate={activateRightPanelSurface}
87278759 onCloseSurface={closeRightPanelSurface}
8760+ onRenameDevice={(surfaceId, title) => {
8761+ if (activeThreadRef)
8762+ useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
8763+ }}
87288764 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
87298765 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
87308766 onCloseAllSurfaces={closeAllRightPanelSurfaces}
@@ -8779,6 +8815,10 @@ export default function ChatView(props: ChatViewProps) { · 48 unmodified lines
87798815 terminalLabelsById={activeTerminalLabelsById}
87808816 onActivate={activateRightPanelSurface}
87818817 onCloseSurface={closeRightPanelSurface}
8818+ onRenameDevice={(surfaceId, title) => {
8819+ if (activeThreadRef)
8820+ useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
8821+ }}
87828822 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
87838823 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
87848824 onCloseAllSurfaces={closeAllRightPanelSurfaces}
apps/web/src/components/Icons.tsx+26−0
@@ -2,6 +2,32 @@ import React, { type SVGProps, useId } from "react"; · 1 unmodified line
22 import { cn } from "~/lib/utils";
33 export type Icon = React.FC<SVGProps<SVGSVGElement>>;
44
5+// Apple brand mark from Simple Icons (CC0).
6+export const AppleIcon: Icon = (props) => (
7+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
8+ <path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
9+ </svg>
10+);
11+
12+export const AndroidIcon: Icon = (props) => (
13+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
14+ <path
15+ d="m8 3-1-2m9 2 1-2"
16+ fill="none"
17+ stroke="currentColor"
18+ strokeWidth="1.4"
19+ strokeLinecap="round"
20+ />
21+ <path
22+ fillRule="evenodd"
23+ d="M5 10a7 7 0 0 1 14 0H5Zm4-4a.8.8 0 1 0 0 1.6A.8.8 0 0 0 9 6Zm6 0a.8.8 0 1 0 0 1.6A.8.8 0 0 0 15 6Z"
24+ />
25+ <path d="M5 11h14v7a2 2 0 0 1-2 2v2a1.5 1.5 0 0 1-3 0v-2h-4v2a1.5 1.5 0 0 1-3 0v-2a2 2 0 0 1-2-2v-7Z" />
26+ <rect x="1" y="11" width="3" height="8" rx="1.5" />
27+ <rect x="20" y="11" width="3" height="8" rx="1.5" />
28+ </svg>
29+);
30+
531 export const LinuxIcon: Icon = ({ className, ...props }) => (
632 <svg {...props} className={className} viewBox="-2 -2 28 28" fill="currentColor">
733 <path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139zm.529 3.405h.013c.213 0 .396.062.584.198.19.135.33.332.438.533.105.259.158.459.166.724 0-.02.006-.04.006-.06v.105a.086.086 0 01-.004-.021l-.004-.024a1.807 1.807 0 01-.15.706.953.953 0 01-.213.335.71.71 0 00-.088-.042c-.104-.045-.198-.064-.284-.133a1.312 1.312 0 00-.22-.066c.05-.06.146-.133.183-.198.053-.128.082-.264.088-.402v-.02a1.21 1.21 0 00-.061-.4c-.045-.134-.101-.2-.183-.333-.084-.066-.167-.132-.267-.132h-.016c-.093 0-.176.03-.262.132a.8.8 0 00-.205.334 1.18 1.18 0 00-.09.4v.019c.002.089.008.179.02.267-.193-.067-.438-.135-.607-.202a1.635 1.635 0 01-.018-.2v-.02a1.772 1.772 0 01.15-.768c.082-.22.232-.406.43-.533a.985.985 0 01.594-.2zm-2.962.059h.036c.142 0 .27.048.399.135.146.129.264.288.344.465.09.199.14.4.153.667v.004c.007.134.006.2-.002.266v.08c-.03.007-.056.018-.083.024-.152.055-.274.135-.393.2.012-.09.013-.18.003-.267v-.015c-.012-.133-.04-.2-.082-.333a.613.613 0 00-.166-.267.248.248 0 00-.183-.064h-.021c-.071.006-.13.04-.186.132a.552.552 0 00-.12.27.944.944 0 00-.023.33v.015c.012.135.037.2.08.334.046.134.098.2.166.268.01.009.02.018.034.024-.07.057-.117.07-.176.136a.304.304 0 01-.131.068 2.62 2.62 0 01-.275-.402 1.772 1.772 0 01-.155-.667 1.759 1.759 0 01.08-.668 1.43 1.43 0 01.283-.535c.128-.133.26-.2.418-.2zm1.37 1.706c.332 0 .733.065 1.216.399.293.2.523.269 1.052.468h.003c.255.136.405.266.478.399v-.131a.571.571 0 01.016.47c-.123.31-.516.643-1.063.842v.002c-.268.135-.501.333-.775.465-.276.135-.588.292-1.012.267a1.139 1.139 0 01-.448-.067 3.566 3.566 0 01-.322-.198c-.195-.135-.363-.332-.612-.465v-.005h-.005c-.4-.246-.616-.512-.686-.71-.07-.268-.005-.47.193-.6.224-.135.38-.271.483-.336.104-.074.143-.102.176-.131h.002v-.003c.169-.202.436-.47.839-.601.139-.036.294-.065.466-.065zm2.8 2.142c.358 1.417 1.196 3.475 1.735 4.473.286.534.855 1.659 1.102 3.024.156-.005.33.018.513.064.646-1.671-.546-3.467-1.089-3.966-.22-.2-.232-.335-.123-.335.59.534 1.365 1.572 1.646 2.757.13.535.16 1.104.021 1.67.067.028.135.06.205.067 1.032.534 1.413.938 1.23 1.537v-.043c-.06-.003-.12 0-.18 0h-.016c.151-.467-.182-.825-1.065-1.224-.915-.4-1.646-.336-1.77.465-.008.043-.013.066-.018.135-.068.023-.139.053-.209.064-.43.268-.662.669-.793 1.187-.13.533-.17 1.156-.205 1.869v.003c-.02.334-.17.838-.319 1.35-1.5 1.072-3.58 1.538-5.348.334a2.645 2.645 0 00-.402-.533 1.45 1.45 0 00-.275-.333c.182 0 .338-.03.465-.067a.615.615 0 00.314-.334c.108-.267 0-.697-.345-1.163-.345-.467-.931-.995-1.788-1.521-.63-.4-.986-.87-1.15-1.396-.165-.534-.143-1.085-.015-1.645.245-1.07.873-2.11 1.274-2.763.107-.065.037.135-.408.974-.396.751-1.14 2.497-.122 3.854a8.123 8.123 0 01.647-2.876c.564-1.278 1.743-3.504 1.836-5.268.048.036.217.135.289.202.218.133.38.333.59.465.21.201.477.335.876.335.039.003.075.006.11.006.412 0 .73-.134.997-.268.29-.134.52-.334.74-.4h.005c.467-.135.835-.402 1.044-.7zm2.185 8.958c.037.6.343 1.245.882 1.377.588.134 1.434-.333 1.791-.765l.211-.01c.315-.007.577.01.847.268l.003.003c.208.199.305.53.391.876.085.4.154.78.409 1.066.486.527.645.906.636 1.14l.003-.007v.018l-.003-.012c-.015.262-.185.396-.498.595-.63.401-1.746.712-2.457 1.57-.618.737-1.37 1.14-2.036 1.191-.664.053-1.237-.2-1.574-.898l-.005-.003c-.21-.4-.12-1.025.056-1.69.176-.668.428-1.344.463-1.897.037-.714.076-1.335.195-1.814.12-.465.308-.797.641-.984l.045-.022zm-10.814.049h.01c.053 0 .105.005.157.014.376.055.706.333 1.023.752l.91 1.664.003.003c.243.533.754 1.064 1.189 1.637.434.598.77 1.131.729 1.57v.006c-.057.744-.48 1.148-1.125 1.294-.645.135-1.52.002-2.395-.464-.968-.536-2.118-.469-2.857-.602-.369-.066-.61-.2-.723-.4-.11-.2-.113-.602.123-1.23v-.004l.002-.003c.117-.334.03-.752-.027-1.118-.055-.401-.083-.71.043-.94.16-.334.396-.4.69-.533.294-.135.64-.202.915-.47h.002v-.002c.256-.268.445-.601.668-.838.19-.201.38-.336.663-.336zm7.159-9.074c-.435.201-.945.535-1.488.535-.542 0-.97-.267-1.28-.466-.154-.134-.28-.268-.373-.335-.164-.134-.144-.333-.074-.333.109.016.129.134.199.2.096.066.215.2.36.333.292.2.68.467 1.167.467.485 0 1.053-.267 1.398-.466.195-.135.445-.334.648-.467.156-.136.149-.267.279-.267.128.016.034.134-.147.332a8.097 8.097 0 01-.69.468zm-1.082-1.583V5.64c-.006-.02.013-.042.029-.05.074-.043.18-.027.26.004.063 0 .16.067.15.135-.006.049-.085.066-.135.066-.055 0-.092-.043-.141-.068-.052-.018-.146-.008-.163-.065zm-.551 0c-.02.058-.113.049-.166.066-.047.025-.086.068-.14.068-.05 0-.13-.02-.136-.068-.01-.066.088-.133.15-.133.08-.031.184-.047.259-.005.019.009.036.03.03.05v.02h.003z" />
apps/web/src/components/RightPanelTabs.tsx+58−15
@@ -46,6 +46,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; · 45 unmodified lines
4646 import { cn } from "~/lib/utils";
4747 import { readLocalApi } from "~/localApi";
4848 import { Button } from "~/components/ui/button";
49+import { AndroidIcon, AppleIcon } from "~/components/Icons";
4950 import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
5051 import { Kbd } from "~/components/ui/kbd";
5152 import {
@@ -97,6 +98,7 @@ interface RightPanelTabsProps { · 45 unmodified lines
9798 previewRuntimeTabId?: ((tabId: string) => string) | undefined;
9899 terminalLabelsById: ReadonlyMap<string, string>;
99100 onActivate: (surface: RightPanelSurface) => void;
101+ onRenameDevice?: (surfaceId: string, title: string) => void;
100102 onCloseSurface: (surface: RightPanelSurface) => void;
101103 onCloseOtherSurfaces: (surface: RightPanelSurface) => void;
102104 onCloseSurfacesToRight: (surface: RightPanelSurface) => void;
@@ -182,6 +184,7 @@ const SURFACE_UNAVAILABLE_HINTS = { · 79 unmodified lines
182184 } as const;
183185
184186 type TabContextMenuAction =
187+ | "rename"
185188 | "copy-path"
186189 | "toggle-mute"
187190 | "close"
@@ -648,7 +651,7 @@ function surfaceTitle( · 460 unmodified lines
648651 case "agents":
649652 return "Agents";
650653 case "device":
651− return "Device";
654+ return surface.title ?? surface.target?.name ?? "Device";
652655 case "preview": {
653656 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
654657 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
@@ -733,7 +736,13 @@ function SurfaceIcon({ · 78 unmodified lines
733736 case "agents":
734737 return <Bot className="size-3 shrink-0" />;
735738 case "device":
736− return <Smartphone className="size-3 shrink-0" />;
739+ return surface.target?.platform === "ios" ? (
740+ <AppleIcon className="size-3 shrink-0" />
741+ ) : surface.target?.platform === "android" ? (
742+ <AndroidIcon className="size-3 shrink-0" />
743+ ) : (
744+ <Smartphone className="size-3 shrink-0" />
745+ );
737746 }
738747 }
739748
@@ -832,6 +841,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 92 unmodified lines
832841 const browserProfiles = useBrowserDefaults().profiles;
833842 const { resolvedTheme } = useTheme();
834843 const tabListRef = useRef<HTMLDivElement>(null);
844+ const [renamingDevice, setRenamingDevice] = useState<string | null>(null);
835845 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
836846 const [tabScrollState, setTabScrollState] = useState({
837847 hasOverflow: false,
@@ -958,6 +968,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 120 unmodified lines
958968 if (surfaceIndex < 0) return;
959969
960970 const items: ContextMenuItem<TabContextMenuAction>[] = [];
971+ if (surface.kind === "device" && props.onRenameDevice)
972+ items.push({ id: "rename", label: "Rename" });
961973 if (surface.kind === "file" && surface.attachment === undefined) {
962974 items.push({ id: "copy-path", label: "Copy path" });
963975 }
@@ -1001,6 +1013,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 37 unmodified lines
10011013
10021014 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
10031015 switch (action) {
1016+ case "rename":
1017+ setRenamingDevice(surface.id);
1018+ break;
10041019 case "copy-path":
10051020 if (surface.kind === "file" && surface.attachment === undefined) {
10061021 props.onCopyFilePath(surface.relativePath);
@@ -1202,20 +1217,48 @@ export function RightPanelTabs(props: RightPanelTabsProps) { · 195 unmodified lines
12021217 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
12031218 </Tooltip>
12041219 )}
1205− <Tooltip>
1206− <TooltipTrigger
1207− render={
1208− <button
1209− type="button"
1210− className="cursor-pointer flex min-w-0 items-center"
1211− onClick={() => props.onActivate(surface)}
1212− >
1213− <span className="truncate">{title}</span>
1214− </button>
1215− }
1220+ {renamingDevice === surface.id ? (
1221+ <input
1222+ aria-label="Device tab name"
1223+ className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-ring"
1224+ defaultValue={title}
1225+ ref={(element) => {
1226+ element?.focus();
1227+ element?.select();
1228+ }}
1229+ onBlur={(event) => {
1230+ props.onRenameDevice?.(surface.id, event.currentTarget.value);
1231+ setRenamingDevice(null);
1232+ }}
1233+ onKeyDown={(event) => {
1234+ event.stopPropagation();
1235+ if (event.key === "Enter") event.currentTarget.blur();
1236+ if (event.key === "Escape") {
1237+ event.currentTarget.value = title;
1238+ event.currentTarget.blur();
1239+ }
1240+ }}
12161241 />
1217− <TooltipPopup>{title}</TooltipPopup>
1218− </Tooltip>
1242+ ) : (
1243+ <Tooltip>
1244+ <TooltipTrigger
1245+ render={
1246+ <button
1247+ type="button"
1248+ onDoubleClick={() => {
1249+ if (surface.kind === "device" && props.onRenameDevice)
1250+ setRenamingDevice(surface.id);
1251+ }}
1252+ className="cursor-pointer flex min-w-0 items-center"
1253+ onClick={() => props.onActivate(surface)}
1254+ >
1255+ <span className="truncate">{title}</span>
1256+ </button>
1257+ }
1258+ />
1259+ <TooltipPopup>{title}</TooltipPopup>
1260+ </Tooltip>
1261+ )}
12191262 </div>
12201263 );
12211264 })}
apps/web/src/components/device/DeviceLoadingView.tsx+47−0
@@ -0,0 +1,47 @@
1+import { Smartphone } from "lucide-react";
2+
3+import { Spinner } from "~/components/ui/spinner";
4+
5+export function DeviceLoadingView(props: {
6+ readonly name: string;
7+ readonly description?: string;
8+ readonly stage: "opening" | "stream";
9+ readonly message: string;
10+ readonly error?: boolean;
11+}) {
12+ return (
13+ <div
14+ role={props.error ? "alert" : "status"}
15+ className="flex size-full items-center justify-center bg-background px-6 py-10"
16+ >
17+ <div className="flex max-w-sm flex-col items-center gap-3 text-center">
18+ <div className="grid size-12 place-items-center rounded-xl border bg-muted/30">
19+ <Smartphone className="size-6 text-muted-foreground" />
20+ </div>
21+ <div className="space-y-1">
22+ <p className="text-sm font-medium">{props.name}</p>
23+ {props.description ? (
24+ <p className="text-xs text-muted-foreground">{props.description}</p>
25+ ) : null}
26+ </div>
27+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
28+ {!props.error ? <Spinner className="size-3" /> : null}
29+ <span>{props.message}</span>
30+ </div>
31+ {!props.error ? (
32+ <div
33+ className="flex w-24 gap-1"
34+ aria-label={
35+ props.stage === "opening" ? "Step 1 of 2: open device" : "Step 2 of 2: connect video"
36+ }
37+ >
38+ <span className="h-1 flex-1 rounded-full bg-foreground/60" />
39+ <span
40+ className={`h-1 flex-1 rounded-full ${props.stage === "stream" ? "bg-foreground/60" : "bg-muted"}`}
41+ />
42+ </div>
43+ ) : null}
44+ </div>
45+ </div>
46+ );
47+}
apps/web/src/components/device/DevicePanel.tsx+100−148
@@ -6,7 +6,6 @@ import type { · 5 unmodified lines
66 } from "@t3tools/contracts";
77 import {
88 ChevronLeft,
9− Circle,
109 Home,
1110 Power,
1211 RotateCcw,
@@ -15,21 +14,13 @@ import { · 2 unmodified lines
1514 Square,
1615 X,
1716 } from "lucide-react";
18−import { useCallback, useEffect, useMemo, useState } from "react";
17+import { useEffect, useMemo, useState } from "react";
1918
19+import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
2020 import { Button } from "~/components/ui/button";
2121 import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
2222 import { Dialog } from "~/components/ui/dialog";
2323 import { WizardPopup } from "~/components/ui/wizard";
24−import {
25− Select,
26− SelectGroup,
27− SelectGroupLabel,
28− SelectItem,
29− SelectPopup,
30− SelectTrigger,
31− SelectValue,
32−} from "~/components/ui/select";
3324 import { Spinner } from "~/components/ui/spinner";
3425 import { Toggle } from "~/components/ui/toggle";
3526 import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
@@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d · 2 unmodified lines
3829 import { formatEnvironmentQueryError } from "~/state/query";
3930 import { useAtomCommand } from "~/state/use-atom-command";
4031 import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
32+import { DeviceLoadingView } from "./DeviceLoadingView";
4133 import { DeviceSetup } from "./DeviceSetup";
4234 import { DeviceToolsPanel } from "./DeviceToolsPanel";
4335 import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
4436
45−const NEW_DEVICE_VALUE = "__new__";
46−
4737 const platformLabel = (platform: DevicePlatform) =>
4838 platform === "ios" ? "iOS Simulators" : "Android Emulators";
4939
5040 const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>
5141 `${device.hostId}\u0000${device.id}`;
5242
53−/**
54− * The Device right-panel surface: one open device (from the thread's device
55− * sessions) with a picker to switch or boot another. Booting and streaming are
56− * server-owned; this panel only asks and renders.
57− */
43+/** Each surface owns one host/device; only the visible surface streams. */
5844 export function DevicePanel(props: {
5945 readonly mode: PreviewPanelMode;
6046 readonly threadRef: ScopedThreadRef;
61− /** `null` renders the picker with nothing open. */
62− readonly deviceId: string | null;
47+ readonly surface: Extract<RightPanelSurface, { kind: "device" }>;
6348 readonly visible: boolean;
6449 readonly onDismissSetup: () => void;
6550 }) {
@@ -69,7 +54,8 @@ export function DevicePanel(props: { · 3 unmodified lines
6954 const open = useAtomCommand(deviceEnvironment.open);
7055 const close = useAtomCommand(deviceEnvironment.close);
7156 const [operationError, setOperationError] = useState<string | null>(null);
72− const [pendingDeviceKey, setPendingDeviceKey] = useState<string | null>(null);
57+ const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null);
58+ const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;
7359 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);
7460 const [toolsOpen, setToolsOpen] = useState(false);
7561 const [axOverlay, setAxOverlay] = useState(false);
@@ -87,10 +73,13 @@ export function DevicePanel(props: { · 11 unmodified lines
8773 () => state.sessions.filter((session) => session.threadId === threadId),
8874 [state.sessions, threadId],
8975 );
90− const activeSession =
91− (props.deviceId
92− ? sessions.find((session) => session.deviceId === props.deviceId)
93− : undefined) ?? sessions.at(-1);
76+ const activeSession = props.surface.target
77+ ? sessions.find(
78+ (session) =>
79+ session.deviceId === props.surface.target?.deviceId &&
80+ session.hostId === props.surface.target.hostId,
81+ )
82+ : undefined;
9483 const activeDevice = activeSession
9584 ? state.devices.find(
9685 (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,
@@ -99,51 +88,67 @@ export function DevicePanel(props: { · 2 unmodified lines
9988
10089 const grouped = useMemo(() => groupDevices(state), [state]);
10190
102− const selectDevice = useCallback(
103− async (value: string) => {
104− if (value === NEW_DEVICE_VALUE) return;
105− const device = state.devices.find((candidate) => deviceKey(candidate) === value);
106− if (!device) return;
107− setOperationError(null);
108− setPendingDeviceKey(value);
109− try {
110− const result = await open({
111− environmentId,
112− input: {
113− threadId,
114− hostId: device.hostId,
115− deviceId: device.id,
116− platform: device.platform,
117− },
118− });
119− if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
120− } finally {
121− setPendingDeviceKey(null);
122− }
123− },
124− [environmentId, open, state.devices, threadId],
125− );
126−
127− const closeActive = useCallback(
128− (powerOff: boolean) => {
129− if (!activeSession) return;
130− setOperationError(null);
131− void close({
91+ const selectDevice = async (value: string) => {
92+ const device = state.devices.find((candidate) => deviceKey(candidate) === value);
93+ if (!device) return;
94+ setOperationError(null);
95+ setPendingDevice(device);
96+ try {
97+ const result = await open({
13298 environmentId,
133− input: { threadId, deviceId: activeSession.deviceId, shutdown: powerOff },
134− }).then((result) => {
135− if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
99+ input: {
100+ threadId,
101+ hostId: device.hostId,
102+ deviceId: device.id,
103+ platform: device.platform,
104+ },
136105 });
137− },
138− [activeSession, close, environmentId, threadId],
139− );
106+ if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
107+ else
108+ useRightPanelStore.getState().openDevice(props.threadRef, {
109+ hostId: result.value.hostId,
110+ deviceId: result.value.deviceId,
111+ platform: device.platform,
112+ name: device.name,
113+ });
114+ } finally {
115+ setPendingDevice(null);
116+ }
117+ };
118+
119+ const closeActive = (powerOff: boolean) => {
120+ if (!powerOff) {
121+ useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
122+ return;
123+ }
124+ if (!activeSession) return;
125+ setOperationError(null);
126+ void close({
127+ environmentId,
128+ input: {
129+ threadId,
130+ hostId: activeSession.hostId,
131+ deviceId: activeSession.deviceId,
132+ shutdown: powerOff,
133+ },
134+ }).then((result) => {
135+ if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
136+ else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
137+ });
138+ };
140139
141140 const bootingDevices =
142141 state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];
143− const hostReady = state.hostStatus === "ready";
144− const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting";
142+ const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready");
143+ const hostBusy =
144+ !hostReady &&
145+ Object.values(state.hostStatuses).some(
146+ (host) => host.status === "installing" || host.status === "starting",
147+ );
145148 const unavailablePlatforms = state.hosts.flatMap((host) =>
146− host.platforms.filter((platform) => !platform.available),
149+ host.platforms
150+ .filter((platform) => !platform.available)
151+ .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })),
147152 );
148153
149154 if (loaded && (!state.onboardingCompleted || hostDisabled)) {
@@ -164,63 +169,11 @@ export function DevicePanel(props: { · 14 unmodified lines
164169 return (
165170 <PreviewPanelShell mode={props.mode}>
166171 <div className="flex h-9 shrink-0 items-center gap-1.5 border-b px-2">
167− <Select
168− value={activeDevice ? deviceKey(activeDevice) : NEW_DEVICE_VALUE}
169− onValueChange={(value) => {
170− if (value !== null) void selectDevice(value);
171− }}
172− disabled={!loaded || hostBusy || hostDisabled || pendingDeviceKey !== null}
173− >
174− <SelectTrigger size="sm" className="min-w-0 flex-1" aria-label="Device">
175− <SelectValue>
176− {activeDevice ? (
177− <span className="flex items-center gap-1.5 truncate">
178− <Smartphone className="size-3.5 shrink-0" />
179− <span className="truncate">{activeDevice.name}</span>
180− <span className="shrink-0 text-muted-foreground">{activeDevice.version}</span>
181− </span>
182− ) : (
183− <span className="text-muted-foreground">
184− {hostBusy
185− ? state.hostStatus === "installing"
186− ? "Installing device tools…"
187− : "Starting device hub…"
188− : "Choose a device"}
189− </span>
190− )}
191− </SelectValue>
192− </SelectTrigger>
193− <SelectPopup align="start" alignItemWithTrigger={false} className="min-w-72">
194− {grouped.map((group) => (
195− <SelectGroup key={group.platform}>
196− <SelectGroupLabel>{platformLabel(group.platform)}</SelectGroupLabel>
197− {group.devices.map((device) => (
198− <SelectItem key={deviceKey(device)} value={deviceKey(device)}>
199− <span className="flex w-full items-center gap-2">
200− <Circle
201− className={cn(
202− "size-2 shrink-0",
203− device.booted ? "fill-success text-success" : "text-muted-foreground/50",
204− )}
205− />
206− <span className="truncate">
207− {device.booted ? device.name : `Start ${device.name}`}
208− </span>
209− <span className="ml-auto shrink-0 text-xs text-muted-foreground">
210− {device.version}
211− </span>
212− </span>
213− </SelectItem>
214− ))}
215− </SelectGroup>
216− ))}
217− {grouped.length === 0 ? (
218− <SelectItem value={NEW_DEVICE_VALUE} disabled>
219− {loaded ? "No devices found" : "Loading…"}
220− </SelectItem>
221− ) : null}
222− </SelectPopup>
223− </Select>
172+ <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
173+ {props.surface.target
174+ ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}`
175+ : (pendingDevice?.name ?? "Choose a device")}
176+ </span>
224177 {activeDevice ? (
225178 <>
226179 <DeviceButton
@@ -311,7 +264,10 @@ export function DevicePanel(props: { · 84 unmodified lines
311264 key={deviceKey(activeDevice)}
312265 environmentId={environmentId}
313266 platform={activeDevice.platform}
267+ deviceName={activeDevice.name}
268+ deviceDescription={`${state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`}
314269 deviceId={activeDevice.id}
270+ hostId={activeDevice.hostId}
315271 visible={props.visible}
316272 axOverlay={axOverlay}
317273 onHandle={setHandle}
@@ -330,6 +286,25 @@ export function DevicePanel(props: { · 12 unmodified lines
330286 />
331287 ) : null}
332288 </>
289+ ) : pendingDevice || hostBusy || !loaded ? (
290+ <DeviceLoadingView
291+ name={pendingDevice?.name ?? "Devices"}
292+ description={
293+ pendingDevice
294+ ? `${state.hosts.find((host) => host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}`
295+ : ""
296+ }
297+ stage="opening"
298+ message={
299+ pendingDevice
300+ ? pendingDevice.booted
301+ ? "Opening device…"
302+ : "Starting device…"
303+ : state.hostStatus === "installing"
304+ ? "Installing device support…"
305+ : "Finding devices…"
306+ }
307+ />
333308 ) : (
334309 <div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
335310 <div
@@ -338,27 +313,13 @@ export function DevicePanel(props: { · 2 unmodified lines
338313 grouped.length === 0 && "my-auto items-center text-center",
339314 )}
340315 >
341− {grouped.length === 0 || hostBusy || pendingDeviceKey ? (
316+ {grouped.length === 0 ? (
342317 <>
343− {hostBusy || pendingDeviceKey ? (
344− <Spinner />
345− ) : (
346− <Smartphone className="size-6 opacity-60" />
347− )}
318+ <Smartphone className="size-6 opacity-60" />
348319 <p className="max-w-sm">
349320 {state.hostStatus === "failed"
350321 ? (state.hostStatusDetail ?? "The device hub failed to start.")
351− : pendingDeviceKey
352− ? "Booting device… this can take a minute."
353− : hostBusy
354− ? state.hostStatus === "installing"
355− ? "Installing device tools…"
356− : "Starting the device hub…"
357− : !loaded
358− ? "Connecting…"
359− : grouped.length === 0
360− ? "No simulators or emulators were found on this environment."
361− : "Choose a device to open."}
322+ : "No simulators or emulators were found on this environment."}
362323 </p>
363324 </>
364325 ) : null}
@@ -380,7 +341,7 @@ export function DevicePanel(props: { · 15 unmodified lines
380341 </span>
381342 }
382343 title={device.name}
383− description={`${device.version} · ${device.booted ? "Running" : "Stopped"}`}
344+ description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`}
384345 disabled={pendingDeviceKey !== null}
385346 aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}
386347 onClick={() => void selectDevice(deviceKey(device))}
@@ -418,15 +379,6 @@ export function DevicePanel(props: { · 31 unmodified lines
418379 Refresh devices
419380 </Button>
420381 ) : null}
421− {unavailablePlatforms.length > 0 && hostReady ? (
422− <ul className="max-w-sm space-y-1 text-xs opacity-70">
423− {unavailablePlatforms.map((platform) => (
424− <li key={platform.platform}>
425− {platform.platform === "ios" ? "iOS" : "Android"}: {platform.reason}
426− </li>
427− ))}
428− </ul>
429− ) : null}
430382 </div>
431383 </div>
432384 )}
apps/web/src/components/device/DeviceStreamView.test.tsx+1−0
@@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () => · 35 unmodified lines
3636 );
3737 const view = (visible: boolean) => (
3838 <DeviceStreamView
39+ hostId="local"
3940 environmentId={EnvironmentId.make("test")}
4041 deviceId="test"
4142 platform="ios"
apps/web/src/components/device/DeviceStreamView.tsx+13−8
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; · 2 unmodified lines
33
44 import { cn } from "~/lib/utils";
55 import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
6−import { Spinner } from "~/components/ui/spinner";
6+import { DeviceLoadingView } from "./DeviceLoadingView";
77 import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";
88 import {
99 createDeviceStreamClient,
@@ -32,13 +32,16 @@ export function DeviceStreamView(props: { · 22 unmodified lines
3232 readonly environmentId: EnvironmentId;
3333 readonly platform: DevicePlatform;
3434 readonly deviceId: string;
35+ readonly deviceName?: string;
36+ readonly deviceDescription?: string;
3537 readonly visible: boolean;
38+ readonly hostId: string;
3639 /** Draw accessibility element frames over the screen. */
3740 readonly axOverlay?: boolean;
3841 readonly onHandle?: (handle: DeviceStreamHandle | null) => void;
3942 readonly onScreen?: (screen: DeviceScreenSize | null) => void;
4043 }) {
41− const access = useDeviceHubAccess(props.environmentId);
44+ const access = useDeviceHubAccess(props.environmentId, props.hostId);
4245 const canvasRef = useRef<HTMLCanvasElement | null>(null);
4346 const clientRef = useRef<DeviceStreamClient | null>(null);
4447 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
@@ -314,12 +317,14 @@ export function DeviceStreamView(props: { · 269 unmodified lines
314317 </div>
315318 ) : null}
316319 {status !== "streaming" ? (
317− <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm text-muted-foreground">
318− {status === "connecting" ? <Spinner /> : null}
319− <span>{status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"}</span>
320− {status === "connecting" && detail ? (
321− <span className="max-w-xs text-center text-xs opacity-70">{detail}</span>
322− ) : null}
320+ <div className="pointer-events-none absolute inset-0">
321+ <DeviceLoadingView
322+ name={props.deviceName ?? "Device"}
323+ description={props.deviceDescription ?? ""}
324+ stage="stream"
325+ message={status === "error" ? (detail ?? "Stream failed.") : "Connecting video…"}
326+ error={status === "error"}
327+ />
323328 </div>
324329 ) : null}
325330 </div>
apps/web/src/components/settings/IntegrationsSettings.test.tsx+1−0
@@ -105,6 +105,7 @@ const deviceState = (overrides: Partial<DeviceServiceState> = {}): DeviceService · 104 unmodified lines
105105 },
106106 ],
107107 hostStatus: "ready",
108+ hostStatuses: {},
108109 devices: [],
109110 sessions: [],
110111 onboardingCompleted: false,
apps/web/src/rightPanelStore.test.ts+81−0
@@ -21,6 +21,87 @@ beforeEach(() => { · 20 unmodified lines
2121 });
2222
2323 describe("rightPanelStore", () => {
24+ it("gives each host/device its own tab and preserves renamed tabs", () => {
25+ const store = useRightPanelStore.getState();
26+ const android = {
27+ hostId: "nucbox",
28+ deviceId: "emulator-5580",
29+ name: "Pixel",
30+ platform: "android",
31+ } as const;
32+ const ios = { hostId: "macmini", deviceId: "ios-1", name: "iPhone", platform: "ios" } as const;
33+ store.open(refA, "device");
34+ store.openDevice(refA, android);
35+ store.open(refA, "device");
36+ expect(
37+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
38+ ).toHaveLength(2);
39+ store.openDevice(refA, ios);
40+ let state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
41+ expect(state.surfaces.map((surface) => surface.id)).toEqual([
42+ "device:nucbox:emulator-5580",
43+ "device:macmini:ios-1",
44+ ]);
45+ store.renameDevice(refA, "device:nucbox:emulator-5580", "Android test");
46+ store.openDevice(refA, android);
47+ state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA);
48+ expect(state.surfaces).toHaveLength(2);
49+ expect(state.surfaces[0]).toMatchObject({ title: "Android test", target: android });
50+ expect(state.activeSurfaceId).toBe("device:nucbox:emulator-5580");
51+ store.closeSurface(refA, state.activeSurfaceId!);
52+ expect(
53+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
54+ ).toEqual([expect.objectContaining({ target: ios })]);
55+ });
56+
57+ it("does not collide when two hosts expose the same device id", () => {
58+ const store = useRightPanelStore.getState();
59+ const device = { deviceId: "emulator-5554", name: "Pixel", platform: "android" } as const;
60+ store.openDevice(refA, { ...device, hostId: "a:b" });
61+ store.openDevice(refA, { ...device, hostId: "a" });
62+ expect(
63+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
64+ ).toHaveLength(2);
65+ expect(
66+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces,
67+ ).toHaveLength(0);
68+ });
69+
70+ it.each(["one", "all", "others", "right"])(
71+ "keeps device tabs dismissed across reload after closing %s",
72+ (mode) => {
73+ const store = useRightPanelStore.getState();
74+ const target = {
75+ hostId: "nucbox",
76+ deviceId: "emulator-5580",
77+ name: "Pixel",
78+ platform: "android",
79+ } as const;
80+ store.open(refA, "files");
81+ store.openDevice(refA, target);
82+ if (mode === "one") store.closeSurface(refA, "device:nucbox:emulator-5580");
83+ if (mode === "all") store.closeAllSurfaces(refA);
84+ if (mode === "others") store.closeOtherSurfaces(refA, "files");
85+ if (mode === "right") store.closeSurfacesToRight(refA, "files");
86+ const persisted = JSON.parse(
87+ JSON.stringify({ byThreadKey: useRightPanelStore.getState().byThreadKey }),
88+ );
89+ useRightPanelStore.setState(migratePersistedRightPanelState(persisted));
90+ store.openDevice(refA, target, true);
91+ expect(
92+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some(
93+ (surface) => surface.kind === "device",
94+ ),
95+ ).toBe(false);
96+ store.openDevice(refA, target);
97+ expect(
98+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some(
99+ (surface) => surface.kind === "device",
100+ ),
101+ ).toBe(true);
102+ },
103+ );
104+
24105 const completedDiff = { id: "diff", kind: "diff" } as const;
25106 const linkedPullRequest = pullRequestSurface({
26107 projectId: "project-a",
apps/web/src/rightPanelStore.ts+87−10
@@ -32,15 +32,17 @@ const RIGHT_PANEL_KINDS = [ · 31 unmodified lines
3232 ] as const;
3333 export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
3434
35+export interface DeviceTabTarget {
36+ hostId: string;
37+ deviceId: string;
38+ platform: "ios" | "android";
39+ name: string;
40+}
41+
3542 export type RightPanelSurface =
3643 | { id: `browser:${string}`; kind: "preview"; resourceId: string }
3744 | { id: "browser:new"; kind: "preview"; resourceId: null }
38− /**
39− * One Device tab per thread. The tab is the surface; which device it shows
40− * comes from the thread's server-side device sessions, so an agent opening a
41− * device from another client lands in the same tab.
42− */
43− | { id: "device"; kind: "device" }
45+ | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string }
4446 | {
4547 id: `terminal:${string}`;
4648 kind: "terminal";
@@ -90,7 +92,7 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; · 43 unmodified lines
9092 // v10 keys pull-request surfaces by reference instead of a singleton tab.
9193 // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
9294 // v12 adds the device surface.
93−const RIGHT_PANEL_STORAGE_VERSION = 12;
95+const RIGHT_PANEL_STORAGE_VERSION = 13;
9496
9597 /** A fixed workspace-level ref: each PR surface carries its own real environment. */
9698 export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
@@ -108,6 +110,7 @@ export interface ThreadRightPanelState { · 11 unmodified lines
108110 isOpen: boolean;
109111 activeSurfaceId: string | null;
110112 surfaces: RightPanelSurface[];
113+ dismissedDeviceSurfaceIds?: string[];
111114 }
112115
113116 interface RightPanelStoreState {
@@ -128,6 +131,8 @@ interface RightPanelStoreState { · 14 unmodified lines
128131 ref: ScopedThreadRef,
129132 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,
130133 ) => void;
134+ openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void;
135+ renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void;
131136 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
132137 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
133138 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
@@ -281,7 +286,12 @@ const updateThread = ( · 147 unmodified lines
281286 ): Record<string, ThreadRightPanelState> => {
282287 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
283288 const next = updater(current);
284− if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) {
289+ if (
290+ !next.isOpen &&
291+ next.activeSurfaceId === null &&
292+ next.surfaces.length === 0 &&
293+ !next.dismissedDeviceSurfaceIds?.length
294+ ) {
285295 if (!(threadKey in byThreadKey)) return byThreadKey;
286296 const { [threadKey]: _removed, ...rest } = byThreadKey;
287297 return rest;
@@ -306,7 +316,25 @@ const userAction = ( · 18 unmodified lines
306316 threadKey: string,
307317 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
308318 ): Partial<RightPanelStoreState> => ({
309− byThreadKey: updateThread(state.byThreadKey, threadKey, updater),
319+ byThreadKey: updateThread(state.byThreadKey, threadKey, (current) => {
320+ const next = updater(current);
321+ const removed = current.surfaces.filter(
322+ (surface) =>
323+ surface.kind === "device" &&
324+ surface.target &&
325+ !next.surfaces.some((entry) => entry.id === surface.id),
326+ );
327+ if (removed.length === 0) return next;
328+ return {
329+ ...next,
330+ dismissedDeviceSurfaceIds: [
331+ ...new Set([
332+ ...(next.dismissedDeviceSurfaceIds ?? []),
333+ ...removed.map((surface) => surface.id),
334+ ]),
335+ ],
336+ };
337+ }),
310338 userActionRevisionByThreadKey: {
311339 ...state.userActionRevisionByThreadKey,
312340 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
@@ -426,7 +454,22 @@ export function migratePersistedRightPanelState(persistedState: unknown): { · 113 unmodified lines
426454 // first survivor instead of rendering an open empty panel.
427455 const activeSurfaceId =
428456 persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null);
429− return [threadKey, { isOpen, surfaces, activeSurfaceId }];
457+ return [
458+ threadKey,
459+ {
460+ isOpen,
461+ surfaces,
462+ activeSurfaceId,
463+ ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds)
464+ ? {
465+ dismissedDeviceSurfaceIds:
466+ validThreadState.dismissedDeviceSurfaceIds.filter(
467+ (id): id is string => typeof id === "string",
468+ ),
469+ }
470+ : {}),
471+ },
472+ ];
430473 }),
431474 )
432475 : {};
@@ -472,6 +515,40 @@ export const useRightPanelStore = create<RightPanelStoreState>()( · 39 unmodified lines
472515 return upsertSurface(current, singletonSurface(kind));
473516 }),
474517 ),
518+ openDevice: (ref, target, automatic = false) =>
519+ set((state) =>
520+ (automatic ? automaticUpdate : userAction)(state, scopedThreadKey(ref), (current) => {
521+ const id =
522+ `device:${encodeURIComponent(target.hostId)}:${encodeURIComponent(target.deviceId)}` as const;
523+ if (automatic && current.dismissedDeviceSurfaceIds?.includes(id)) return current;
524+ const surface: RightPanelSurface = { id, kind: "device", target };
525+ const existing = current.surfaces.find((entry) => entry.id === id);
526+ const surfaces = existing
527+ ? current.surfaces.filter((entry) => entry.id !== "device")
528+ : current.surfaces.map((entry) => (entry.id === "device" ? surface : entry));
529+ return upsertSurface(
530+ {
531+ ...current,
532+ surfaces,
533+ dismissedDeviceSurfaceIds: (current.dismissedDeviceSurfaceIds ?? []).filter(
534+ (entry) => entry !== id,
535+ ),
536+ },
537+ existing ?? surface,
538+ );
539+ }),
540+ ),
541+ renameDevice: (ref, surfaceId, title) =>
542+ set((state) =>
543+ userAction(state, scopedThreadKey(ref), (current) => ({
544+ ...current,
545+ surfaces: current.surfaces.map((surface) =>
546+ surface.id === surfaceId && surface.kind === "device"
547+ ? { ...surface, title: title.trim() || surface.target?.name || "Device" }
548+ : surface,
549+ ),
550+ })),
551+ ),
475552 openBrowser: (ref, tabId) =>
476553 set((state) =>
477554 userAction(state, scopedThreadKey(ref), (current) => {
apps/web/src/state/device.ts+14−3
@@ -1,3 +1,4 @@
1+import { useMemo } from "react";
12 import { useAtomValue } from "@effect/atom-react";
23 import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
34 import {
@@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt · 14 unmodified lines
1819
1920 const EMPTY_DEVICE_STATE: DeviceServiceState = {
2021 hosts: [],
21− hostStatus: "idle",
22+ hostStatus: "disabled",
23+ hostStatuses: {},
2224 devices: [],
2325 sessions: [],
2426 onboardingCompleted: false,
@@ -54,11 +56,20 @@ const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => · 29 unmodified lines
5456 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),
5557 );
5658
57−export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null {
59+export function useDeviceHubAccess(
60+ environmentId: EnvironmentId | null,
61+ hostId = "local",
62+): DeviceHubAccess | null {
5863 const result = useAtomValue(
5964 environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId),
6065 );
61− return AsyncResult.isSuccess(result) ? result.value : null;
66+ return useMemo(
67+ () =>
68+ AsyncResult.isSuccess(result)
69+ ? { ...result.value, query: { ...result.value.query, hostId } }
70+ : null,
71+ [result, hostId],
72+ );
6273 }
6374
6475 const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>()).pipe(
packages/contracts/src/device.ts+8−0
@@ -95,6 +95,13 @@ export const DeviceServiceState = Schema.Struct({ · 94 unmodified lines
9595 hosts: Schema.Array(DeviceHostSummary),
9696 hostStatus: DeviceHostStatus,
9797 hostStatusDetail: Schema.optional(Schema.String),
98+ hostStatuses: Schema.Record(
99+ DeviceHostId,
100+ Schema.Struct({
101+ status: DeviceHostStatus,
102+ detail: Schema.optional(Schema.String),
103+ }),
104+ ),
98105 devices: Schema.Array(DeviceSummary),
99106 sessions: Schema.Array(DeviceSession),
100107 bootingDevices: Schema.optional(
@@ -129,6 +136,7 @@ export const DeviceOpenInput = Schema.Struct({ · 28 unmodified lines
129136 export type DeviceOpenInput = typeof DeviceOpenInput.Type;
130137
131138 export const DeviceCloseInput = Schema.Struct({
139+ hostId: Schema.optional(DeviceHostId),
132140 threadId: ThreadId,
133141 /** Omit to close every device session for the thread. */
134142 deviceId: Schema.optional(DeviceId),