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* () {
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* () {
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 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";
2121const 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 {
136136const vendorPrefix = (platform: DevicePlatform) =>
137137 platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu";
138138
139export const make = Effect.gen(function* () {
140 const localHost = yield* DeviceHost.DeviceHost;
139export 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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
749751 });
750752});
751753
754export const make = Effect.gen(function* () {
755 const host = yield* DeviceHost.DeviceHost;
756 return yield* makeWithHosts(new Map([[host.id, host]]));
757});
758
752759export 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
......@@ -56,6 +56,7 @@ const state = {
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 = {
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 = {
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(
16691669
16701670const 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) {
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) {
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) {
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) {
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) {
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
......@@ -2,6 +2,32 @@ import React, { type SVGProps, useId } from "react";
22import { cn } from "~/lib/utils";
33export 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
531export 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";
4646import { cn } from "~/lib/utils";
4747import { readLocalApi } from "~/localApi";
4848import { Button } from "~/components/ui/button";
49import { AndroidIcon, AppleIcon } from "~/components/Icons";
4950import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
5051import { Kbd } from "~/components/ui/kbd";
5152import {
......@@ -97,6 +98,7 @@ interface RightPanelTabsProps {
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 = {
182184} as const;
183185
184186type TabContextMenuAction =
187 | "rename"
185188 | "copy-path"
186189 | "toggle-mute"
187190 | "close"
......@@ -648,7 +651,7 @@ function surfaceTitle(
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({
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) {
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) {
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) {
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) {
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 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 {
66} from "@t3tools/contracts";
77import {
88 ChevronLeft,
9 Circle,
109 Home,
1110 Power,
1211 RotateCcw,
......@@ -15,21 +14,13 @@ import {
1514 Square,
1615 X,
1716} from "lucide-react";
18import { useCallback, useEffect, useMemo, useState } from "react";
17import { useEffect, useMemo, useState } from "react";
1918
19import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
2020import { Button } from "~/components/ui/button";
2121import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list";
2222import { Dialog } from "~/components/ui/dialog";
2323import { 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";
3324import { Spinner } from "~/components/ui/spinner";
3425import { Toggle } from "~/components/ui/toggle";
3526import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
......@@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d
3829import { formatEnvironmentQueryError } from "~/state/query";
3930import { useAtomCommand } from "~/state/use-atom-command";
4031import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView";
32import { DeviceLoadingView } from "./DeviceLoadingView";
4133import { DeviceSetup } from "./DeviceSetup";
4234import { DeviceToolsPanel } from "./DeviceToolsPanel";
4335import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell";
4436
45const NEW_DEVICE_VALUE = "__new__";
46
4737const platformLabel = (platform: DevicePlatform) =>
4838 platform === "ios" ? "iOS Simulators" : "Android Emulators";
4939
5040const 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. */
5844export 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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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
......@@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () =>
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";
33
44import { cn } from "~/lib/utils";
55import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device";
6import { Spinner } from "~/components/ui/spinner";
6import { DeviceLoadingView } from "./DeviceLoadingView";
77import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi";
88import {
99 createDeviceStreamClient,
......@@ -32,13 +32,16 @@ export function DeviceStreamView(props: {
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: {
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
......@@ -105,6 +105,7 @@ const deviceState = (overrides: Partial<DeviceServiceState> = {}): DeviceService
105105 },
106106 ],
107107 hostStatus: "ready",
108 hostStatuses: {},
108109 devices: [],
109110 sessions: [],
110111 onboardingCompleted: false,
apps/web/src/rightPanelStore.test.ts+81
......@@ -21,6 +21,87 @@ beforeEach(() => {
2121});
2222
2323describe("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 = [
3232] as const;
3333export 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
3542export 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";
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.
93const RIGHT_PANEL_STORAGE_VERSION = 12;
95const RIGHT_PANEL_STORAGE_VERSION = 13;
9496
9597/** A fixed workspace-level ref: each PR surface carries its own real environment. */
9698export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
......@@ -108,6 +110,7 @@ export interface ThreadRightPanelState {
108110 isOpen: boolean;
109111 activeSurfaceId: string | null;
110112 surfaces: RightPanelSurface[];
113 dismissedDeviceSurfaceIds?: string[];
111114}
112115
113116interface RightPanelStoreState {
......@@ -128,6 +131,8 @@ interface RightPanelStoreState {
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 = (
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 = (
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): {
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>()(
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 @@
1import { useMemo } from "react";
12import { useAtomValue } from "@effect/atom-react";
23import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device";
34import {
......@@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt
1819
1920const 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) =>
5456 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)),
5557);
5658
57export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null {
59export 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
6475const 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({
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({
129136export type DeviceOpenInput = typeof DeviceOpenInput.Type;
130137
131138export 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),