authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-10 11:59:33-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 11:59:33-07:00
loge022fa430edbf8d448efe0f34303ff290ebb3dc0
treeb0fca352f9dc9cad1ba35510f7664c50c876f04e
parentdca7b59bea7e8a4a24e75b290027496a523b0ca7
signature Signed by PGP key B5690EEEBB952194

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


19 files changed, 649 insertions(+), 257 deletions(-)

apps/server/src/device/DeviceHubProxy.ts+2-1
...@@ -196,7 +196,7 @@ const handler = Effect.gen(function* () {...@@ -196,7 +196,7 @@ const handler = Effect.gen(function* () {
196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));196 (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath));
197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);197 yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope);
198 const devices = yield* DeviceService.DeviceService;198 const devices = yield* DeviceService.DeviceService;
199 const ready = yield* devices.currentReadiness();199 const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined);
200 if (!ready) {200 if (!ready) {
201 return HttpServerResponse.text("Device hub is not running", { status: 503 });201 return HttpServerResponse.text("Device hub is not running", { status: 503 });
202 }202 }
...@@ -206,6 +206,7 @@ const handler = Effect.gen(function* () {...@@ -206,6 +206,7 @@ const handler = Effect.gen(function* () {
206 // The ticket authenticates here and must not travel on to the hub.206 // The ticket authenticates here and must not travel on to the hub.
207 const upstreamSearch = new URLSearchParams(url.value.search);207 const upstreamSearch = new URLSearchParams(url.value.search);
208 upstreamSearch.delete("wsTicket");208 upstreamSearch.delete("wsTicket");
209 upstreamSearch.delete("hostId");
209 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";210 const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : "";
210 const upstreamPath = `${hubPath}${search}`;211 const upstreamPath = `${hubPath}${search}`;
211 if (upgrade) {212 if (upgrade) {
apps/server/src/device/DeviceMultiHost.test.ts created+86
...@@ -0,0 +1,86 @@
1import { expect, it } from "@effect/vitest";
2import { ThreadId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import { HttpClient, HttpClientResponse } from "effect/unstable/http";
5import { ServerSettingsService } from "../serverSettings.ts";
6import { DeviceHostError, DeviceHost } from "./DeviceHost.ts";
7import { makeWithHosts } from "./DeviceService.ts";
8
9it.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
...@@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts";...@@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts";
21const baseState: DeviceServiceState = {21const baseState: DeviceServiceState = {
22 hosts: [],22 hosts: [],
23 hostStatus: "idle",23 hostStatus: "idle",
24 hostStatuses: {},
24 devices: [],25 devices: [],
25 sessions: [],26 sessions: [],
26 onboardingCompleted: false,27 onboardingCompleted: false,
apps/server/src/device/DeviceService.ts+58-51
...@@ -136,8 +136,9 @@ interface ServiceState {...@@ -136,8 +136,9 @@ interface ServiceState {
136const vendorPrefix = (platform: DevicePlatform) =>136const vendorPrefix = (platform: DevicePlatform) =>
137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
138138
139export const make = Effect.gen(function* () {139export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (
140 const localHost = yield* DeviceHost.DeviceHost;140 hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]>,
141) {
141 const settings = yield* ServerSettings.ServerSettingsService;142 const settings = yield* ServerSettings.ServerSettingsService;
142 const lifecycleLock = yield* Semaphore.make(1);143 const lifecycleLock = yield* Semaphore.make(1);
143 const readDeviceSettings = settings.getSettings.pipe(144 const readDeviceSettings = settings.getSettings.pipe(
...@@ -152,9 +153,7 @@ export const make = Effect.gen(function* () {...@@ -152,9 +153,7 @@ export const make = Effect.gen(function* () {
152 ),153 ),
153 );154 );
154 const initialSettings = yield* readDeviceSettings;155 const initialSettings = yield* readDeviceSettings;
155 const hosts: ReadonlyMap<DeviceHostId, DeviceHost.DeviceHost["Service"]> = new Map([156
156 [localHost.id, localHost],
157 ]);
158 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);157 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
159 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();158 const statePubSub = yield* PubSub.unbounded<DeviceServiceState>();
160 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);159 const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary);
...@@ -162,6 +161,7 @@ export const make = Effect.gen(function* () {...@@ -162,6 +161,7 @@ export const make = Effect.gen(function* () {
162 state: {161 state: {
163 hosts: initialHosts,162 hosts: initialHosts,
164 hostStatus: initialSettings.enabled ? "idle" : "disabled",163 hostStatus: initialSettings.enabled ? "idle" : "disabled",
164 hostStatuses: {},
165 devices: [],165 devices: [],
166 sessions: [],166 sessions: [],
167 onboardingCompleted: initialSettings.onboardingCompleted,167 onboardingCompleted: initialSettings.onboardingCompleted,
...@@ -187,6 +187,18 @@ export const make = Effect.gen(function* () {...@@ -187,6 +187,18 @@ export const make = Effect.gen(function* () {
187 return host;187 return host;
188 });188 });
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
190 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(202 const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")(
191 function* (hostId) {203 function* (hostId) {
192 const host = yield* resolveHost(hostId);204 const host = yield* resolveHost(hostId);
...@@ -198,34 +210,19 @@ export const make = Effect.gen(function* () {...@@ -198,34 +210,19 @@ export const make = Effect.gen(function* () {
198 });210 });
199 }211 }
200 const ready = yield* host212 const ready = yield* host
201 .ensureReady((phase) =>213 .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid))
202 publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
203 Effect.asVoid,
204 ),
205 )
206 .pipe(214 .pipe(
207 Effect.tapError((error) =>215 Effect.tapError((error) =>
208 publish((state) => ({216 setHostStatus(host.id, { status: "failed", detail: error.message }),
209 ...state,
210 hostStatus: "failed",
211 hostStatusDetail: error.message,
212 })),
213 ),217 ),
214 Effect.mapError(218 Effect.mapError(
215 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),219 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
216 ),220 ),
217 );221 );
218 yield* SynchronizedRef.get(stateRef).pipe(222 const { state } = yield* SynchronizedRef.get(stateRef);
219 Effect.flatMap(({ state }) =>223 if (state.hostStatuses[host.id]?.status !== "ready") {
220 state.hostStatus === "ready"224 yield* setHostStatus(host.id, { status: "ready" });
221 ? Effect.void225 }
222 : publish((current) => ({
223 ...current,
224 hostStatus: "ready",
225 hostStatusDetail: undefined,
226 })),
227 ),
228 );
229 return { hostId: host.id, ...ready };226 return { hostId: host.id, ...ready };
230 },227 },
231 lifecycleLock.withPermit,228 lifecycleLock.withPermit,
...@@ -249,25 +246,18 @@ export const make = Effect.gen(function* () {...@@ -249,25 +246,18 @@ export const make = Effect.gen(function* () {
249 const summary = yield* host.summary;246 const summary = yield* host.summary;
250 if (!summary.platforms.some((platform) => platform.available)) return null;247 if (!summary.platforms.some((platform) => platform.available)) return null;
251 const ready = yield* host248 const ready = yield* host
252 .ensureAgentReady((phase) =>249 .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid))
253 publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe(
254 Effect.asVoid,
255 ),
256 )
257 .pipe(250 .pipe(
258 Effect.tapError((error) =>251 Effect.tapError((error) =>
259 publish((state) => ({252 setHostStatus(host.id, { status: "failed", detail: error.message }),
260 ...state,
261 hostStatus: "failed",
262 hostStatusDetail: error.message,
263 })),
264 ),253 ),
265 Effect.mapError(254 Effect.mapError(
266 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),255 (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }),
267 ),256 ),
268 );257 );
269 const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary);258 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" });
271 return { hostId: host.id, ...ready };261 return { hostId: host.id, ...ready };
272 }, lifecycleLock.withPermit);262 }, lifecycleLock.withPermit);
273263
...@@ -357,8 +347,15 @@ export const make = Effect.gen(function* () {...@@ -357,8 +347,15 @@ export const make = Effect.gen(function* () {
357 return yield* publish((state) => ({347 return yield* publish((state) => ({
358 ...state,348 ...state,
359 hosts: hostSummaries,349 hosts: hostSummaries,
360 devices,350 ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}),
361 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 },
362 }));359 }));
363 }),360 }),
364 );361 );
...@@ -366,18 +363,21 @@ export const make = Effect.gen(function* () {...@@ -366,18 +363,21 @@ export const make = Effect.gen(function* () {
366363
367 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {364 const list: DeviceService["Service"]["list"] = Effect.gen(function* () {
368 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;365 if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state;
369 const ready = yield* readiness();366 yield* Effect.forEach(
370 return yield* refresh(ready);367 hosts.values(),
371 }).pipe(368 (host) =>
372 Effect.tapError((error) =>369 Effect.gen(function* () {
373 publish((state) =>370 const ready = yield* readinessIfSupported(host.id);
374 state.hostStatus === "disabled"371 if (ready) yield* refresh(ready);
375 ? state372 }).pipe(
376 : { ...state, hostStatus: "failed", hostStatusDetail: error.message },373 Effect.catch((error) =>
377 ),374 setHostStatus(host.id, { status: "failed", detail: error.message }),
378 ),375 ),
379 Effect.withSpan("DeviceService.list"),376 ),
380 );377 { concurrency: 4 },
378 );
379 return (yield* SynchronizedRef.get(stateRef)).state;
380 }).pipe(Effect.withSpan("DeviceService.list"));
381381
382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(382 const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")(
383 function* (input) {383 function* (input) {
...@@ -415,6 +415,7 @@ export const make = Effect.gen(function* () {...@@ -415,6 +415,7 @@ export const make = Effect.gen(function* () {
415 ...state,415 ...state,
416 hostStatus: nextEnabled ? "idle" : "disabled",416 hostStatus: nextEnabled ? "idle" : "disabled",
417 hostStatusDetail: undefined,417 hostStatusDetail: undefined,
418 hostStatuses: {},
418 devices: nextEnabled ? state.devices : [],419 devices: nextEnabled ? state.devices : [],
419 sessions: nextEnabled ? state.sessions : [],420 sessions: nextEnabled ? state.sessions : [],
420 bootingDevices: nextEnabled ? state.bootingDevices : [],421 bootingDevices: nextEnabled ? state.bootingDevices : [],
...@@ -636,6 +637,7 @@ export const make = Effect.gen(function* () {...@@ -636,6 +637,7 @@ export const make = Effect.gen(function* () {
636 const closing = state.sessions.filter(637 const closing = state.sessions.filter(
637 (session) =>638 (session) =>
638 session.threadId === input.threadId &&639 session.threadId === input.threadId &&
640 (input.hostId === undefined || session.hostId === input.hostId) &&
639 (input.deviceId === undefined || session.deviceId === input.deviceId),641 (input.deviceId === undefined || session.deviceId === input.deviceId),
640 );642 );
641 if (closing.length === 0) return;643 if (closing.length === 0) return;
...@@ -749,6 +751,11 @@ export const make = Effect.gen(function* () {...@@ -749,6 +751,11 @@ export const make = Effect.gen(function* () {
749 });751 });
750});752});
751753
754export const make = Effect.gen(function* () {
755 const host = yield* DeviceHost.DeviceHost;
756 return yield* makeWithHosts(new Map([[host.id, host]]));
757});
758
752export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));759export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer));
753760
754/** State stream for WS subscribers: current snapshot first, then every change. */761/** State stream for WS subscribers: current snapshot first, then every change. */
apps/server/src/mcp/McpDeviceToolkit.test.ts+1
...@@ -56,6 +56,7 @@ const state = {...@@ -56,6 +56,7 @@ const state = {
56 },56 },
57 ],57 ],
58 hostStatus: "ready" as const,58 hostStatus: "ready" as const,
59 hostStatuses: { local: { status: "ready" as const } },
59 devices: [device],60 devices: [device],
60 sessions: [],61 sessions: [],
61 onboardingCompleted: true,62 onboardingCompleted: true,
apps/server/src/mcp/toolkits/device/handlers.ts+4-1
...@@ -161,7 +161,9 @@ const handlers = {...@@ -161,7 +161,9 @@ const handlers = {
161 const target =161 const target =
162 input.deviceId !== undefined162 input.deviceId !== undefined
163 ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId }163 ? { 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);
165 if (!target) {167 if (!target) {
166 return yield* new DeviceToolUnavailableError({168 return yield* new DeviceToolUnavailableError({
167 reason: "No device is open in this thread. Call device_open first.",169 reason: "No device is open in this thread. Call device_open first.",
...@@ -183,6 +185,7 @@ const handlers = {...@@ -183,6 +185,7 @@ const handlers = {
183 const devices = yield* DeviceService.DeviceService;185 const devices = yield* DeviceService.DeviceService;
184 yield* devices.close({186 yield* devices.close({
185 threadId: scope.threadId,187 threadId: scope.threadId,
188 ...(input.hostId === undefined ? {} : { hostId: input.hostId }),
186 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),189 ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }),
187 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),190 ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }),
188 });191 });
apps/server/src/server.test.ts+2-1
...@@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe(...@@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe(
16691669
1670const EMPTY_DEVICE_STATE: DeviceServiceState = {1670const EMPTY_DEVICE_STATE: DeviceServiceState = {
1671 hosts: [],1671 hosts: [],
1672 hostStatus: "idle",1672 hostStatus: "disabled",
1673 hostStatuses: {},
1673 devices: [],1674 devices: [],
1674 sessions: [],1675 sessions: [],
1675 onboardingCompleted: false,1676 onboardingCompleted: false,
apps/web/src/components/ChatView.tsx+59-19
...@@ -4143,7 +4143,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -4143,7 +4143,9 @@ export default function ChatView(props: ChatViewProps) {
4143 if (!activeThreadRef || !supportsThreadPullRequests) return;4143 if (!activeThreadRef || !supportsThreadPullRequests) return;
4144 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");4144 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
4145 }, [activeThreadRef, supportsThreadPullRequests]);4145 }, [activeThreadRef, supportsThreadPullRequests]);
4146 const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null);4146 const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState(
4147 activeThreadRef?.environmentId ?? null,
4148 );
4147 const [deviceSetupThread, setDeviceSetupThread] = useState<ScopedThreadRef | null>(null);4149 const [deviceSetupThread, setDeviceSetupThread] = useState<ScopedThreadRef | null>(null);
4148 const addDeviceSurface = useCallback(() => {4150 const addDeviceSurface = useCallback(() => {
4149 if (!activeThreadRef) return;4151 if (!activeThreadRef) return;
...@@ -4153,24 +4155,53 @@ export default function ChatView(props: ChatViewProps) {...@@ -4153,24 +4155,53 @@ export default function ChatView(props: ChatViewProps) {
4153 }4155 }
4154 useRightPanelStore.getState().open(activeThreadRef, "device");4156 useRightPanelStore.getState().open(activeThreadRef, "device");
4155 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);4157 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
4156 // An agent's `device_open` surfaces in every client the same way a4158 // Reconcile new server sessions into separate tabs, including sessions opened
4157 // `preview_open` does: the thread starts a device or gains a session and the panel4159 // by an agent or another client. The first snapshot is a baseline: persisted
4158 // opens on it. Closing the last session leaves the tab in place so the4160 // tabs restore themselves, and existing sessions must not resurrect closed tabs.
4159 // user keeps their picker; only new sessions raise the panel.4161 const previousDeviceSessions = useRef(new Map<string, Set<string>>());
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);
4167 useEffect(() => {4162 useEffect(() => {
4168 const previous = previousDeviceSessionCount.current;4163 if (!activeThreadRef || !deviceStateLoaded) return;
4169 previousDeviceSessionCount.current = threadDeviceSessionCount;4164 const threadKey = `${activeThreadRef.environmentId}:${activeThreadRef.threadId}`;
4170 if (!activeThreadRef || threadDeviceSessionCount <= previous) return;4165 const sessions = deviceState.sessions.filter(
4171 if (shouldUseRightPanelSheet) return;4166 (session) => session.threadId === activeThreadRef.threadId,
4172 useRightPanelStore.getState().open(activeThreadRef, "device");4167 );
4173 }, [activeThreadRef, shouldUseRightPanelSheet, threadDeviceSessionCount]);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 ]);
4174 const openFileSurface = useCallback(4205 const openFileSurface = useCallback(
4175 (relativePath: string) => {4206 (relativePath: string) => {
4176 if (!activeThreadRef || !activeProject) return;4207 if (!activeThreadRef || !activeProject) return;
...@@ -8153,7 +8184,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -8153,7 +8184,8 @@ export default function ChatView(props: ChatViewProps) {
8153 <DevicePanel8184 <DevicePanel
8154 mode="embedded"8185 mode="embedded"
8155 threadRef={activeThreadRef}8186 threadRef={activeThreadRef}
8156 deviceId={null}8187 key={renderedRightPanelSurface.id}
8188 surface={renderedRightPanelSurface}
8157 visible={rightPanelOpen}8189 visible={rightPanelOpen}
8158 onDismissSetup={() => {8190 onDismissSetup={() => {
8159 closeRightPanelSurface(renderedRightPanelSurface);8191 closeRightPanelSurface(renderedRightPanelSurface);
...@@ -8725,6 +8757,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -8725,6 +8757,10 @@ export default function ChatView(props: ChatViewProps) {
8725 terminalLabelsById={activeTerminalLabelsById}8757 terminalLabelsById={activeTerminalLabelsById}
8726 onActivate={activateRightPanelSurface}8758 onActivate={activateRightPanelSurface}
8727 onCloseSurface={closeRightPanelSurface}8759 onCloseSurface={closeRightPanelSurface}
8760 onRenameDevice={(surfaceId, title) => {
8761 if (activeThreadRef)
8762 useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
8763 }}
8728 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}8764 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
8729 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}8765 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
8730 onCloseAllSurfaces={closeAllRightPanelSurfaces}8766 onCloseAllSurfaces={closeAllRightPanelSurfaces}
...@@ -8779,6 +8815,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -8779,6 +8815,10 @@ export default function ChatView(props: ChatViewProps) {
8779 terminalLabelsById={activeTerminalLabelsById}8815 terminalLabelsById={activeTerminalLabelsById}
8780 onActivate={activateRightPanelSurface}8816 onActivate={activateRightPanelSurface}
8781 onCloseSurface={closeRightPanelSurface}8817 onCloseSurface={closeRightPanelSurface}
8818 onRenameDevice={(surfaceId, title) => {
8819 if (activeThreadRef)
8820 useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
8821 }}
8782 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}8822 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
8783 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}8823 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
8784 onCloseAllSurfaces={closeAllRightPanelSurfaces}8824 onCloseAllSurfaces={closeAllRightPanelSurfaces}
apps/web/src/components/Icons.tsx+26
...@@ -2,6 +2,32 @@ import React, { type SVGProps, useId } from "react";...@@ -2,6 +2,32 @@ import React, { type SVGProps, useId } from "react";
2import { cn } from "~/lib/utils";2import { cn } from "~/lib/utils";
3export type Icon = React.FC<SVGProps<SVGSVGElement>>;3export type Icon = React.FC<SVGProps<SVGSVGElement>>;
44
5// Apple brand mark from Simple Icons (CC0).
6export 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
12export 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
5export const LinuxIcon: Icon = ({ className, ...props }) => (31export const LinuxIcon: Icon = ({ className, ...props }) => (
6 <svg {...props} className={className} viewBox="-2 -2 28 28" fill="currentColor">32 <svg {...props} className={className} viewBox="-2 -2 28 28" fill="currentColor">
7 <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" />33 <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";...@@ -46,6 +46,7 @@ import type { RightPanelSurface } from "~/rightPanelStore";
46import { cn } from "~/lib/utils";46import { cn } from "~/lib/utils";
47import { readLocalApi } from "~/localApi";47import { readLocalApi } from "~/localApi";
48import { Button } from "~/components/ui/button";48import { Button } from "~/components/ui/button";
49import { AndroidIcon, AppleIcon } from "~/components/Icons";
49import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";50import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
50import { Kbd } from "~/components/ui/kbd";51import { Kbd } from "~/components/ui/kbd";
51import {52import {
...@@ -97,6 +98,7 @@ interface RightPanelTabsProps {...@@ -97,6 +98,7 @@ interface RightPanelTabsProps {
97 previewRuntimeTabId?: ((tabId: string) => string) | undefined;98 previewRuntimeTabId?: ((tabId: string) => string) | undefined;
98 terminalLabelsById: ReadonlyMap<string, string>;99 terminalLabelsById: ReadonlyMap<string, string>;
99 onActivate: (surface: RightPanelSurface) => void;100 onActivate: (surface: RightPanelSurface) => void;
101 onRenameDevice?: (surfaceId: string, title: string) => void;
100 onCloseSurface: (surface: RightPanelSurface) => void;102 onCloseSurface: (surface: RightPanelSurface) => void;
101 onCloseOtherSurfaces: (surface: RightPanelSurface) => void;103 onCloseOtherSurfaces: (surface: RightPanelSurface) => void;
102 onCloseSurfacesToRight: (surface: RightPanelSurface) => void;104 onCloseSurfacesToRight: (surface: RightPanelSurface) => void;
...@@ -182,6 +184,7 @@ const SURFACE_UNAVAILABLE_HINTS = {...@@ -182,6 +184,7 @@ const SURFACE_UNAVAILABLE_HINTS = {
182} as const;184} as const;
183185
184type TabContextMenuAction =186type TabContextMenuAction =
187 | "rename"
185 | "copy-path"188 | "copy-path"
186 | "toggle-mute"189 | "toggle-mute"
187 | "close"190 | "close"
...@@ -648,7 +651,7 @@ function surfaceTitle(...@@ -648,7 +651,7 @@ function surfaceTitle(
648 case "agents":651 case "agents":
649 return "Agents";652 return "Agents";
650 case "device":653 case "device":
651 return "Device";654 return surface.title ?? surface.target?.name ?? "Device";
652 case "preview": {655 case "preview": {
653 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;656 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
654 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";657 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
...@@ -733,7 +736,13 @@ function SurfaceIcon({...@@ -733,7 +736,13 @@ function SurfaceIcon({
733 case "agents":736 case "agents":
734 return <Bot className="size-3 shrink-0" />;737 return <Bot className="size-3 shrink-0" />;
735 case "device":738 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 );
737 }746 }
738}747}
739748
...@@ -832,6 +841,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -832,6 +841,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
832 const browserProfiles = useBrowserDefaults().profiles;841 const browserProfiles = useBrowserDefaults().profiles;
833 const { resolvedTheme } = useTheme();842 const { resolvedTheme } = useTheme();
834 const tabListRef = useRef<HTMLDivElement>(null);843 const tabListRef = useRef<HTMLDivElement>(null);
844 const [renamingDevice, setRenamingDevice] = useState<string | null>(null);
835 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);845 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
836 const [tabScrollState, setTabScrollState] = useState({846 const [tabScrollState, setTabScrollState] = useState({
837 hasOverflow: false,847 hasOverflow: false,
...@@ -958,6 +968,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -958,6 +968,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
958 if (surfaceIndex < 0) return;968 if (surfaceIndex < 0) return;
959969
960 const items: ContextMenuItem<TabContextMenuAction>[] = [];970 const items: ContextMenuItem<TabContextMenuAction>[] = [];
971 if (surface.kind === "device" && props.onRenameDevice)
972 items.push({ id: "rename", label: "Rename" });
961 if (surface.kind === "file" && surface.attachment === undefined) {973 if (surface.kind === "file" && surface.attachment === undefined) {
962 items.push({ id: "copy-path", label: "Copy path" });974 items.push({ id: "copy-path", label: "Copy path" });
963 }975 }
...@@ -1001,6 +1013,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1001,6 +1013,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
10011013
1002 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });1014 const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
1003 switch (action) {1015 switch (action) {
1016 case "rename":
1017 setRenamingDevice(surface.id);
1018 break;
1004 case "copy-path":1019 case "copy-path":
1005 if (surface.kind === "file" && surface.attachment === undefined) {1020 if (surface.kind === "file" && surface.attachment === undefined) {
1006 props.onCopyFilePath(surface.relativePath);1021 props.onCopyFilePath(surface.relativePath);
...@@ -1202,20 +1217,48 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1202,20 +1217,48 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1202 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>1217 <TooltipPopup>{audio === "muted" ? "Unmute tab" : "Mute tab"}</TooltipPopup>
1203 </Tooltip>1218 </Tooltip>
1204 )}1219 )}
1205 <Tooltip>1220 {renamingDevice === surface.id ? (
1206 <TooltipTrigger1221 <input
1207 render={1222 aria-label="Device tab name"
1208 <button1223 className="w-24 min-w-0 rounded-sm bg-background px-1 outline-none ring-1 ring-ring"
1209 type="button"1224 defaultValue={title}
1210 className="cursor-pointer flex min-w-0 items-center"1225 ref={(element) => {
1211 onClick={() => props.onActivate(surface)}1226 element?.focus();
1212 >1227 element?.select();
1213 <span className="truncate">{title}</span>1228 }}
1214 </button>1229 onBlur={(event) => {
1215 }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 }}
1216 />1241 />
1217 <TooltipPopup>{title}</TooltipPopup>1242 ) : (
1218 </Tooltip>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 )}
1219 </div>1262 </div>
1220 );1263 );
1221 })}1264 })}
apps/web/src/components/device/DeviceLoadingView.tsx created+47
...@@ -0,0 +1,47 @@
1import { Smartphone } from "lucide-react";
2
3import { Spinner } from "~/components/ui/spinner";
4
5export 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 {...@@ -6,7 +6,6 @@ import type {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {7import {
8 ChevronLeft,8 ChevronLeft,
9 Circle,
10 Home,9 Home,
11 Power,10 Power,
12 RotateCcw,11 RotateCcw,
...@@ -15,21 +14,13 @@ import {...@@ -15,21 +14,13 @@ import {
15 Square,14 Square,
16 X,15 X,
17} from "lucide-react";16} from "lucide-react";
18import { useCallback, useEffect, useMemo, useState } from "react";17import { useEffect, useMemo, useState } from "react";
1918
19import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
20import { Button } from "~/components/ui/button";20import { Button } from "~/components/ui/button";
21import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";21import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
22import { Dialog } from "~/components/ui/dialog";22import { Dialog } from "~/components/ui/dialog";
23import { WizardPopup } from "~/components/ui/wizard";23import { WizardPopup } from "~/components/ui/wizard";
24import {
25 Select,
26 SelectGroup,
27 SelectGroupLabel,
28 SelectItem,
29 SelectPopup,
30 SelectTrigger,
31 SelectValue,
32} from "~/components/ui/select";
33import { Spinner } from "~/components/ui/spinner";24import { Spinner } from "~/components/ui/spinner";
34import { Toggle } from "~/components/ui/toggle";25import { Toggle } from "~/components/ui/toggle";
35import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";26import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
...@@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d...@@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d
38import { formatEnvironmentQueryError } from "~/state/query";29import { formatEnvironmentQueryError } from "~/state/query";
39import { useAtomCommand } from "~/state/use-atom-command";30import { useAtomCommand } from "~/state/use-atom-command";
40import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";31import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
32import { DeviceLoadingView } from "./DeviceLoadingView";
41import { DeviceSetup } from "./DeviceSetup";33import { DeviceSetup } from "./DeviceSetup";
42import { DeviceToolsPanel } from "./DeviceToolsPanel";34import { DeviceToolsPanel } from "./DeviceToolsPanel";
43import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";35import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
4436
45const NEW_DEVICE_VALUE = "__new__";
46
47const platformLabel = (platform: DevicePlatform) =>37const platformLabel = (platform: DevicePlatform) =>
48 platform === "ios" ? "iOS Simulators" : "Android Emulators";38 platform === "ios" ? "iOS Simulators" : "Android Emulators";
4939
50const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>40const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) =>
51 `${device.hostId}\u0000${device.id}`;41 `${device.hostId}\u0000${device.id}`;
5242
53/**43/** Each surface owns one host/device; only the visible surface streams. */
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 */
58export function DevicePanel(props: {44export function DevicePanel(props: {
59 readonly mode: PreviewPanelMode;45 readonly mode: PreviewPanelMode;
60 readonly threadRef: ScopedThreadRef;46 readonly threadRef: ScopedThreadRef;
61 /** `null` renders the picker with nothing open. */47 readonly surface: Extract<RightPanelSurface, { kind: "device" }>;
62 readonly deviceId: string | null;
63 readonly visible: boolean;48 readonly visible: boolean;
64 readonly onDismissSetup: () => void;49 readonly onDismissSetup: () => void;
65}) {50}) {
...@@ -69,7 +54,8 @@ export function DevicePanel(props: {...@@ -69,7 +54,8 @@ export function DevicePanel(props: {
69 const open = useAtomCommand(deviceEnvironment.open);54 const open = useAtomCommand(deviceEnvironment.open);
70 const close = useAtomCommand(deviceEnvironment.close);55 const close = useAtomCommand(deviceEnvironment.close);
71 const [operationError, setOperationError] = useState<string | null>(null);56 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;
73 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);59 const [handle, setHandle] = useState<DeviceStreamHandle | null>(null);
74 const [toolsOpen, setToolsOpen] = useState(false);60 const [toolsOpen, setToolsOpen] = useState(false);
75 const [axOverlay, setAxOverlay] = useState(false);61 const [axOverlay, setAxOverlay] = useState(false);
...@@ -87,10 +73,13 @@ export function DevicePanel(props: {...@@ -87,10 +73,13 @@ export function DevicePanel(props: {
87 () => state.sessions.filter((session) => session.threadId === threadId),73 () => state.sessions.filter((session) => session.threadId === threadId),
88 [state.sessions, threadId],74 [state.sessions, threadId],
89 );75 );
90 const activeSession =76 const activeSession = props.surface.target
91 (props.deviceId77 ? sessions.find(
92 ? sessions.find((session) => session.deviceId === props.deviceId)78 (session) =>
93 : undefined) ?? sessions.at(-1);79 session.deviceId === props.surface.target?.deviceId &&
80 session.hostId === props.surface.target.hostId,
81 )
82 : undefined;
94 const activeDevice = activeSession83 const activeDevice = activeSession
95 ? state.devices.find(84 ? state.devices.find(
96 (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,85 (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId,
...@@ -99,51 +88,67 @@ export function DevicePanel(props: {...@@ -99,51 +88,67 @@ export function DevicePanel(props: {
9988
100 const grouped = useMemo(() => groupDevices(state), [state]);89 const grouped = useMemo(() => groupDevices(state), [state]);
10190
102 const selectDevice = useCallback(91 const selectDevice = async (value: string) => {
103 async (value: string) => {92 const device = state.devices.find((candidate) => deviceKey(candidate) === value);
104 if (value === NEW_DEVICE_VALUE) return;93 if (!device) return;
105 const device = state.devices.find((candidate) => deviceKey(candidate) === value);94 setOperationError(null);
106 if (!device) return;95 setPendingDevice(device);
107 setOperationError(null);96 try {
108 setPendingDeviceKey(value);97 const result = await open({
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({
132 environmentId,98 environmentId,
133 input: { threadId, deviceId: activeSession.deviceId, shutdown: powerOff },99 input: {
134 }).then((result) => {100 threadId,
135 if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));101 hostId: device.hostId,
102 deviceId: device.id,
103 platform: device.platform,
104 },
136 });105 });
137 },106 if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
138 [activeSession, close, environmentId, threadId],107 else
139 );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
141 const bootingDevices =140 const bootingDevices =
142 state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];141 state.bootingDevices?.filter((device) => device.threadId === threadId) ?? [];
143 const hostReady = state.hostStatus === "ready";142 const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready");
144 const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting";143 const hostBusy =
144 !hostReady &&
145 Object.values(state.hostStatuses).some(
146 (host) => host.status === "installing" || host.status === "starting",
147 );
145 const unavailablePlatforms = state.hosts.flatMap((host) =>148 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 })),
147 );152 );
148153
149 if (loaded && (!state.onboardingCompleted || hostDisabled)) {154 if (loaded && (!state.onboardingCompleted || hostDisabled)) {
...@@ -164,63 +169,11 @@ export function DevicePanel(props: {...@@ -164,63 +169,11 @@ export function DevicePanel(props: {
164 return (169 return (
165 <PreviewPanelShell mode={props.mode}>170 <PreviewPanelShell mode={props.mode}>
166 <div className="flex h-9 shrink-0 items-center gap-1.5 border-b px-2">171 <div className="flex h-9 shrink-0 items-center gap-1.5 border-b px-2">
167 <Select172 <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
168 value={activeDevice ? deviceKey(activeDevice) : NEW_DEVICE_VALUE}173 {props.surface.target
169 onValueChange={(value) => {174 ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}`
170 if (value !== null) void selectDevice(value);175 : (pendingDevice?.name ?? "Choose a device")}
171 }}176 </span>
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>
224 {activeDevice ? (177 {activeDevice ? (
225 <>178 <>
226 <DeviceButton179 <DeviceButton
...@@ -311,7 +264,10 @@ export function DevicePanel(props: {...@@ -311,7 +264,10 @@ export function DevicePanel(props: {
311 key={deviceKey(activeDevice)}264 key={deviceKey(activeDevice)}
312 environmentId={environmentId}265 environmentId={environmentId}
313 platform={activeDevice.platform}266 platform={activeDevice.platform}
267 deviceName={activeDevice.name}
268 deviceDescription={`${state.hosts.find((host) => host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`}
314 deviceId={activeDevice.id}269 deviceId={activeDevice.id}
270 hostId={activeDevice.hostId}
315 visible={props.visible}271 visible={props.visible}
316 axOverlay={axOverlay}272 axOverlay={axOverlay}
317 onHandle={setHandle}273 onHandle={setHandle}
...@@ -330,6 +286,25 @@ export function DevicePanel(props: {...@@ -330,6 +286,25 @@ export function DevicePanel(props: {
330 />286 />
331 ) : null}287 ) : null}
332 </>288 </>
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 />
333 ) : (308 ) : (
334 <div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">309 <div className="flex size-full flex-col overflow-y-auto px-5 py-8 text-sm text-muted-foreground">
335 <div310 <div
...@@ -338,27 +313,13 @@ export function DevicePanel(props: {...@@ -338,27 +313,13 @@ export function DevicePanel(props: {
338 grouped.length === 0 && "my-auto items-center text-center",313 grouped.length === 0 && "my-auto items-center text-center",
339 )}314 )}
340 >315 >
341 {grouped.length === 0 || hostBusy || pendingDeviceKey ? (316 {grouped.length === 0 ? (
342 <>317 <>
343 {hostBusy || pendingDeviceKey ? (318 <Smartphone className="size-6 opacity-60" />
344 <Spinner />
345 ) : (
346 <Smartphone className="size-6 opacity-60" />
347 )}
348 <p className="max-w-sm">319 <p className="max-w-sm">
349 {state.hostStatus === "failed"320 {state.hostStatus === "failed"
350 ? (state.hostStatusDetail ?? "The device hub failed to start.")321 ? (state.hostStatusDetail ?? "The device hub failed to start.")
351 : pendingDeviceKey322 : "No simulators or emulators were found on this environment."}
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."}
362 </p>323 </p>
363 </>324 </>
364 ) : null}325 ) : null}
...@@ -380,7 +341,7 @@ export function DevicePanel(props: {...@@ -380,7 +341,7 @@ export function DevicePanel(props: {
380 </span>341 </span>
381 }342 }
382 title={device.name}343 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"}`}
384 disabled={pendingDeviceKey !== null}345 disabled={pendingDeviceKey !== null}
385 aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}346 aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`}
386 onClick={() => void selectDevice(deviceKey(device))}347 onClick={() => void selectDevice(deviceKey(device))}
...@@ -418,15 +379,6 @@ export function DevicePanel(props: {...@@ -418,15 +379,6 @@ export function DevicePanel(props: {
418 Refresh devices379 Refresh devices
419 </Button>380 </Button>
420 ) : null}381 ) : 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}
430 </div>382 </div>
431 </div>383 </div>
432 )}384 )}
apps/web/src/components/device/DeviceStreamView.test.tsx+1
...@@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () =>...@@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () =>
36 );36 );
37 const view = (visible: boolean) => (37 const view = (visible: boolean) => (
38 <DeviceStreamView38 <DeviceStreamView
39 hostId="local"
39 environmentId={EnvironmentId.make("test")}40 environmentId={EnvironmentId.make("test")}
40 deviceId="test"41 deviceId="test"
41 platform="ios"42 platform="ios"
apps/web/src/components/device/DeviceStreamView.tsx+13-8
...@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";...@@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";5import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
6import { Spinner } from "~/components/ui/spinner";6import { DeviceLoadingView } from "./DeviceLoadingView";
7import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";7import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";
8import {8import {
9 createDeviceStreamClient,9 createDeviceStreamClient,
...@@ -32,13 +32,16 @@ export function DeviceStreamView(props: {...@@ -32,13 +32,16 @@ export function DeviceStreamView(props: {
32 readonly environmentId: EnvironmentId;32 readonly environmentId: EnvironmentId;
33 readonly platform: DevicePlatform;33 readonly platform: DevicePlatform;
34 readonly deviceId: string;34 readonly deviceId: string;
35 readonly deviceName?: string;
36 readonly deviceDescription?: string;
35 readonly visible: boolean;37 readonly visible: boolean;
38 readonly hostId: string;
36 /** Draw accessibility element frames over the screen. */39 /** Draw accessibility element frames over the screen. */
37 readonly axOverlay?: boolean;40 readonly axOverlay?: boolean;
38 readonly onHandle?: (handle: DeviceStreamHandle | null) => void;41 readonly onHandle?: (handle: DeviceStreamHandle | null) => void;
39 readonly onScreen?: (screen: DeviceScreenSize | null) => void;42 readonly onScreen?: (screen: DeviceScreenSize | null) => void;
40}) {43}) {
41 const access = useDeviceHubAccess(props.environmentId);44 const access = useDeviceHubAccess(props.environmentId, props.hostId);
42 const canvasRef = useRef<HTMLCanvasElement | null>(null);45 const canvasRef = useRef<HTMLCanvasElement | null>(null);
43 const clientRef = useRef<DeviceStreamClient | null>(null);46 const clientRef = useRef<DeviceStreamClient | null>(null);
44 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");47 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
...@@ -314,12 +317,14 @@ export function DeviceStreamView(props: {...@@ -314,12 +317,14 @@ export function DeviceStreamView(props: {
314 </div>317 </div>
315 ) : null}318 ) : null}
316 {status !== "streaming" ? (319 {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">320 <div className="pointer-events-none absolute inset-0">
318 {status === "connecting" ? <Spinner /> : null}321 <DeviceLoadingView
319 <span>{status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"}</span>322 name={props.deviceName ?? "Device"}
320 {status === "connecting" && detail ? (323 description={props.deviceDescription ?? ""}
321 <span className="max-w-xs text-center text-xs opacity-70">{detail}</span>324 stage="stream"
322 ) : null}325 message={status === "error" ? (detail ?? "Stream failed.") : "Connecting video…"}
326 error={status === "error"}
327 />
323 </div>328 </div>
324 ) : null}329 ) : null}
325 </div>330 </div>
apps/web/src/components/settings/IntegrationsSettings.test.tsx+1
...@@ -105,6 +105,7 @@ const deviceState = (overrides: Partial<DeviceServiceState> = {}): DeviceService...@@ -105,6 +105,7 @@ const deviceState = (overrides: Partial<DeviceServiceState> = {}): DeviceService
105 },105 },
106 ],106 ],
107 hostStatus: "ready",107 hostStatus: "ready",
108 hostStatuses: {},
108 devices: [],109 devices: [],
109 sessions: [],110 sessions: [],
110 onboardingCompleted: false,111 onboardingCompleted: false,
apps/web/src/rightPanelStore.test.ts+81
...@@ -21,6 +21,87 @@ beforeEach(() => {...@@ -21,6 +21,87 @@ beforeEach(() => {
21});21});
2222
23describe("rightPanelStore", () => {23describe("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
24 const completedDiff = { id: "diff", kind: "diff" } as const;105 const completedDiff = { id: "diff", kind: "diff" } as const;
25 const linkedPullRequest = pullRequestSurface({106 const linkedPullRequest = pullRequestSurface({
26 projectId: "project-a",107 projectId: "project-a",
apps/web/src/rightPanelStore.ts+87-10
...@@ -32,15 +32,17 @@ const RIGHT_PANEL_KINDS = [...@@ -32,15 +32,17 @@ const RIGHT_PANEL_KINDS = [
32] as const;32] as const;
33export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];33export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
3434
35export interface DeviceTabTarget {
36 hostId: string;
37 deviceId: string;
38 platform: "ios" | "android";
39 name: string;
40}
41
35export type RightPanelSurface =42export type RightPanelSurface =
36 | { id: `browser:${string}`; kind: "preview"; resourceId: string }43 | { id: `browser:${string}`; kind: "preview"; resourceId: string }
37 | { id: "browser:new"; kind: "preview"; resourceId: null }44 | { id: "browser:new"; kind: "preview"; resourceId: null }
38 /**45 | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string }
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" }
44 | {46 | {
45 id: `terminal:${string}`;47 id: `terminal:${string}`;
46 kind: "terminal";48 kind: "terminal";
...@@ -90,7 +92,7 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";...@@ -90,7 +92,7 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
90// v10 keys pull-request surfaces by reference instead of a singleton tab.92// v10 keys pull-request surfaces by reference instead of a singleton tab.
91// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.93// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.
92// v12 adds the device surface.94// v12 adds the device surface.
93const RIGHT_PANEL_STORAGE_VERSION = 12;95const RIGHT_PANEL_STORAGE_VERSION = 13;
9496
95/** A fixed workspace-level ref: each PR surface carries its own real environment. */97/** A fixed workspace-level ref: each PR surface carries its own real environment. */
96export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(98export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
...@@ -108,6 +110,7 @@ export interface ThreadRightPanelState {...@@ -108,6 +110,7 @@ export interface ThreadRightPanelState {
108 isOpen: boolean;110 isOpen: boolean;
109 activeSurfaceId: string | null;111 activeSurfaceId: string | null;
110 surfaces: RightPanelSurface[];112 surfaces: RightPanelSurface[];
113 dismissedDeviceSurfaceIds?: string[];
111}114}
112115
113interface RightPanelStoreState {116interface RightPanelStoreState {
...@@ -128,6 +131,8 @@ interface RightPanelStoreState {...@@ -128,6 +131,8 @@ interface RightPanelStoreState {
128 ref: ScopedThreadRef,131 ref: ScopedThreadRef,
129 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,132 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">,
130 ) => void;133 ) => void;
134 openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void;
135 renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void;
131 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;136 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
132 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;137 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
133 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;138 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
...@@ -281,7 +286,12 @@ const updateThread = (...@@ -281,7 +286,12 @@ const updateThread = (
281): Record<string, ThreadRightPanelState> => {286): Record<string, ThreadRightPanelState> => {
282 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;287 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
283 const next = updater(current);288 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 ) {
285 if (!(threadKey in byThreadKey)) return byThreadKey;295 if (!(threadKey in byThreadKey)) return byThreadKey;
286 const { [threadKey]: _removed, ...rest } = byThreadKey;296 const { [threadKey]: _removed, ...rest } = byThreadKey;
287 return rest;297 return rest;
...@@ -306,7 +316,25 @@ const userAction = (...@@ -306,7 +316,25 @@ const userAction = (
306 threadKey: string,316 threadKey: string,
307 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,317 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
308): Partial<RightPanelStoreState> => ({318): 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 }),
310 userActionRevisionByThreadKey: {338 userActionRevisionByThreadKey: {
311 ...state.userActionRevisionByThreadKey,339 ...state.userActionRevisionByThreadKey,
312 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,340 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
...@@ -426,7 +454,22 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -426,7 +454,22 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
426 // first survivor instead of rendering an open empty panel.454 // first survivor instead of rendering an open empty panel.
427 const activeSurfaceId =455 const activeSurfaceId =
428 persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null);456 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 ];
430 }),473 }),
431 )474 )
432 : {};475 : {};
...@@ -472,6 +515,40 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -472,6 +515,40 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
472 return upsertSurface(current, singletonSurface(kind));515 return upsertSurface(current, singletonSurface(kind));
473 }),516 }),
474 ),517 ),
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 ),
475 openBrowser: (ref, tabId) =>552 openBrowser: (ref, tabId) =>
476 set((state) =>553 set((state) =>
477 userAction(state, scopedThreadKey(ref), (current) => {554 userAction(state, scopedThreadKey(ref), (current) => {
apps/web/src/state/device.ts+14-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useMemo } from "react";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";3import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
3import {4import {
...@@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt...@@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt
1819
19const EMPTY_DEVICE_STATE: DeviceServiceState = {20const EMPTY_DEVICE_STATE: DeviceServiceState = {
20 hosts: [],21 hosts: [],
21 hostStatus: "idle",22 hostStatus: "disabled",
23 hostStatuses: {},
22 devices: [],24 devices: [],
23 sessions: [],25 sessions: [],
24 onboardingCompleted: false,26 onboardingCompleted: false,
...@@ -54,11 +56,20 @@ const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) =>...@@ -54,11 +56,20 @@ const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) =>
54 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),56 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),
55);57);
5658
57export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null {59export function useDeviceHubAccess(
60 environmentId: EnvironmentId | null,
61 hostId = "local",
62): DeviceHubAccess | null {
58 const result = useAtomValue(63 const result = useAtomValue(
59 environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId),64 environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId),
60 );65 );
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 );
62}73}
6374
64const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>()).pipe(75const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>()).pipe(
packages/contracts/src/device.ts+8
...@@ -95,6 +95,13 @@ export const DeviceServiceState = Schema.Struct({...@@ -95,6 +95,13 @@ export const DeviceServiceState = Schema.Struct({
95 hosts: Schema.Array(DeviceHostSummary),95 hosts: Schema.Array(DeviceHostSummary),
96 hostStatus: DeviceHostStatus,96 hostStatus: DeviceHostStatus,
97 hostStatusDetail: Schema.optional(Schema.String),97 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 ),
98 devices: Schema.Array(DeviceSummary),105 devices: Schema.Array(DeviceSummary),
99 sessions: Schema.Array(DeviceSession),106 sessions: Schema.Array(DeviceSession),
100 bootingDevices: Schema.optional(107 bootingDevices: Schema.optional(
...@@ -129,6 +136,7 @@ export const DeviceOpenInput = Schema.Struct({...@@ -129,6 +136,7 @@ export const DeviceOpenInput = Schema.Struct({
129export type DeviceOpenInput = typeof DeviceOpenInput.Type;136export type DeviceOpenInput = typeof DeviceOpenInput.Type;
130137
131export const DeviceCloseInput = Schema.Struct({138export const DeviceCloseInput = Schema.Struct({
139 hostId: Schema.optional(DeviceHostId),
132 threadId: ThreadId,140 threadId: ThreadId,
133 /** Omit to close every device session for the thread. */141 /** Omit to close every device session for the thread. */
134 deviceId: Schema.optional(DeviceId),142 deviceId: Schema.optional(DeviceId),