| author | |
| committer | |
| log | e022fa430edbf8d448efe0f34303ff290ebb3dc0 |
| tree | b0fca352f9dc9cad1ba35510f7664c50c876f04e |
| parent | dca7b59bea7e8a4a24e75b290027496a523b0ca7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { ThreadId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 5 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 6 | import { DeviceHostError, DeviceHost } from "./DeviceHost.ts"; | ||
| 7 | import { makeWithHosts } from "./DeviceService.ts"; | ||
| 8 | |||
| 9 | it.effect("keeps hosts independent when serials collide and another host fails", () => | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const host = (id: string, failed = false): DeviceHost["Service"] => { | ||
| 12 | const ready = { | ||
| 13 | hub: { origin: `http://${id}` }, | ||
| 14 | agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, | ||
| 15 | run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | ||
| 16 | helpers: { serveSimAxSettings: null, serveSimCli: null }, | ||
| 17 | }; | ||
| 18 | return { | ||
| 19 | id, | ||
| 20 | summary: Effect.succeed({ | ||
| 21 | id, | ||
| 22 | label: id, | ||
| 23 | kind: "local", | ||
| 24 | hubInstalled: true, | ||
| 25 | agentDeviceInstalled: true, | ||
| 26 | platforms: [{ platform: "android", available: true }], | ||
| 27 | }), | ||
| 28 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), | ||
| 29 | ensureReady: () => | ||
| 30 | failed | ||
| 31 | ? Effect.fail( | ||
| 32 | new DeviceHostError({ hostId: id, step: "connect", cause: new Error("offline") }), | ||
| 33 | ) | ||
| 34 | : Effect.succeed(ready), | ||
| 35 | ensureAgentReady: () => Effect.succeed(ready), | ||
| 36 | current: Effect.succeed(ready), | ||
| 37 | stopAgent: Effect.void, | ||
| 38 | stop: Effect.void, | ||
| 39 | }; | ||
| 40 | }; | ||
| 41 | const http = HttpClient.make((request) => | ||
| 42 | Effect.succeed( | ||
| 43 | HttpClientResponse.fromWeb( | ||
| 44 | request, | ||
| 45 | Response.json({ | ||
| 46 | simulators: [], | ||
| 47 | emulators: [ | ||
| 48 | { | ||
| 49 | id: "emulator-5554", | ||
| 50 | name: "Pixel", | ||
| 51 | version: "36", | ||
| 52 | platform: "android", | ||
| 53 | booted: true, | ||
| 54 | physical: false, | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | }), | ||
| 58 | ), | ||
| 59 | ), | ||
| 60 | ); | ||
| 61 | const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); | ||
| 62 | const service = yield* makeWithHosts(hosts).pipe( | ||
| 63 | Effect.provideService(HttpClient.HttpClient, http), | ||
| 64 | ); | ||
| 65 | const listed = yield* service.list; | ||
| 66 | expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); | ||
| 67 | expect(listed.hostStatuses.offline?.status).toBe("failed"); | ||
| 68 | const threadId = ThreadId.make("thread"); | ||
| 69 | for (const hostId of ["a", "b"]) | ||
| 70 | yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" }); | ||
| 71 | yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" }); | ||
| 72 | const state = yield* service.state; | ||
| 73 | expect(state.devices).toHaveLength(2); | ||
| 74 | expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); | ||
| 75 | expect(state.hostStatuses.a?.status).toBe("ready"); | ||
| 76 | expect(state.hostStatuses.offline?.status).toBe("failed"); | ||
| 77 | yield* service.agentReadinessIfSupported("b"); | ||
| 78 | expect((yield* service.state).hostStatuses.b?.status).toBe("ready"); | ||
| 79 | yield* service.configure({ enabled: false }); | ||
| 80 | expect((yield* service.state).hostStatuses).toEqual({}); | ||
| 81 | }).pipe( | ||
| 82 | Effect.provide( | ||
| 83 | ServerSettingsService.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), | ||
| 84 | ), | ||
| 85 | ), | ||
| 86 | ); | ||
apps/server/src/device/DeviceService.test.ts+1| ... | @@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts"; | ... | @@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts"; |
| 21 | const baseState: DeviceServiceState = { | 21 | const 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 { |
| 136 | const vendorPrefix = (platform: DevicePlatform) => | 136 | const vendorPrefix = (platform: DevicePlatform) => |
| 137 | platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; | 137 | platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; |
| 138 | 138 | ||
| 139 | export const make = Effect.gen(function* () { | 139 | export 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 | }); |
| 189 | 189 | ||
| 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* host | 212 | 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.void | 225 | } |
| 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* host | 248 | 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); |
| 273 | 263 | ||
| ... | @@ -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* () { |
| 366 | 363 | ||
| 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 | ? state | 372 | }).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")); | ||
| 381 | 381 | ||
| 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 | }); |
| 751 | 753 | ||
| 754 | export const make = Effect.gen(function* () { | ||
| 755 | const host = yield* DeviceHost.DeviceHost; | ||
| 756 | return yield* makeWithHosts(new Map([[host.id, host]])); | ||
| 757 | }); | ||
| 758 | |||
| 752 | export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); | 759 | export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); |
| 753 | 760 | ||
| 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 !== undefined | 162 | 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( |
| 1669 | 1669 | ||
| 1670 | const EMPTY_DEVICE_STATE: DeviceServiceState = { | 1670 | const 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 a | 4158 | // 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 panel | 4159 | // 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 the | 4160 | // 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 | <DevicePanel | 8184 | <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"; |
| 2 | import { cn } from "~/lib/utils"; | 2 | import { cn } from "~/lib/utils"; |
| 3 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; | 3 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; |
| 4 | 4 | ||
| 5 | // Apple brand mark from Simple Icons (CC0). | ||
| 6 | export const AppleIcon: Icon = (props) => ( | ||
| 7 | <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}> | ||
| 8 | <path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" /> | ||
| 9 | </svg> | ||
| 10 | ); | ||
| 11 | |||
| 12 | export const AndroidIcon: Icon = (props) => ( | ||
| 13 | <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}> | ||
| 14 | <path | ||
| 15 | d="m8 3-1-2m9 2 1-2" | ||
| 16 | fill="none" | ||
| 17 | stroke="currentColor" | ||
| 18 | strokeWidth="1.4" | ||
| 19 | strokeLinecap="round" | ||
| 20 | /> | ||
| 21 | <path | ||
| 22 | fillRule="evenodd" | ||
| 23 | d="M5 10a7 7 0 0 1 14 0H5Zm4-4a.8.8 0 1 0 0 1.6A.8.8 0 0 0 9 6Zm6 0a.8.8 0 1 0 0 1.6A.8.8 0 0 0 15 6Z" | ||
| 24 | /> | ||
| 25 | <path d="M5 11h14v7a2 2 0 0 1-2 2v2a1.5 1.5 0 0 1-3 0v-2h-4v2a1.5 1.5 0 0 1-3 0v-2a2 2 0 0 1-2-2v-7Z" /> | ||
| 26 | <rect x="1" y="11" width="3" height="8" rx="1.5" /> | ||
| 27 | <rect x="20" y="11" width="3" height="8" rx="1.5" /> | ||
| 28 | </svg> | ||
| 29 | ); | ||
| 30 | |||
| 5 | export const LinuxIcon: Icon = ({ className, ...props }) => ( | 31 | export 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"; |
| 46 | import { cn } from "~/lib/utils"; | 46 | import { cn } from "~/lib/utils"; |
| 47 | import { readLocalApi } from "~/localApi"; | 47 | import { readLocalApi } from "~/localApi"; |
| 48 | import { Button } from "~/components/ui/button"; | 48 | import { Button } from "~/components/ui/button"; |
| 49 | import { AndroidIcon, AppleIcon } from "~/components/Icons"; | ||
| 49 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 50 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 50 | import { Kbd } from "~/components/ui/kbd"; | 51 | import { Kbd } from "~/components/ui/kbd"; |
| 51 | import { | 52 | import { |
| ... | @@ -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; |
| 183 | 185 | ||
| 184 | type TabContextMenuAction = | 186 | type 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 | } |
| 739 | 748 | ||
| ... | @@ -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; |
| 959 | 969 | ||
| 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) { |
| 1001 | 1013 | ||
| 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 | <TooltipTrigger | 1221 | <input |
| 1207 | render={ | 1222 | aria-label="Device tab name" |
| 1208 | <button | 1223 | 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 @@ | ||
| 1 | import { Smartphone } from "lucide-react"; | ||
| 2 | |||
| 3 | import { Spinner } from "~/components/ui/spinner"; | ||
| 4 | |||
| 5 | export function DeviceLoadingView(props: { | ||
| 6 | readonly name: string; | ||
| 7 | readonly description?: string; | ||
| 8 | readonly stage: "opening" | "stream"; | ||
| 9 | readonly message: string; | ||
| 10 | readonly error?: boolean; | ||
| 11 | }) { | ||
| 12 | return ( | ||
| 13 | <div | ||
| 14 | role={props.error ? "alert" : "status"} | ||
| 15 | className="flex size-full items-center justify-center bg-background px-6 py-10" | ||
| 16 | > | ||
| 17 | <div className="flex max-w-sm flex-col items-center gap-3 text-center"> | ||
| 18 | <div className="grid size-12 place-items-center rounded-xl border bg-muted/30"> | ||
| 19 | <Smartphone className="size-6 text-muted-foreground" /> | ||
| 20 | </div> | ||
| 21 | <div className="space-y-1"> | ||
| 22 | <p className="text-sm font-medium">{props.name}</p> | ||
| 23 | {props.description ? ( | ||
| 24 | <p className="text-xs text-muted-foreground">{props.description}</p> | ||
| 25 | ) : null} | ||
| 26 | </div> | ||
| 27 | <div className="flex items-center gap-2 text-xs text-muted-foreground"> | ||
| 28 | {!props.error ? <Spinner className="size-3" /> : null} | ||
| 29 | <span>{props.message}</span> | ||
| 30 | </div> | ||
| 31 | {!props.error ? ( | ||
| 32 | <div | ||
| 33 | className="flex w-24 gap-1" | ||
| 34 | aria-label={ | ||
| 35 | props.stage === "opening" ? "Step 1 of 2: open device" : "Step 2 of 2: connect video" | ||
| 36 | } | ||
| 37 | > | ||
| 38 | <span className="h-1 flex-1 rounded-full bg-foreground/60" /> | ||
| 39 | <span | ||
| 40 | className={`h-1 flex-1 rounded-full ${props.stage === "stream" ? "bg-foreground/60" : "bg-muted"}`} | ||
| 41 | /> | ||
| 42 | </div> | ||
| 43 | ) : null} | ||
| 44 | </div> | ||
| 45 | </div> | ||
| 46 | ); | ||
| 47 | } | ||
apps/web/src/components/device/DevicePanel.tsx+100-148| ... | @@ -6,7 +6,6 @@ import type { | ... | @@ -6,7 +6,6 @@ import type { |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 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"; |
| 18 | import { useCallback, useEffect, useMemo, useState } from "react"; | 17 | import { useEffect, useMemo, useState } from "react"; |
| 19 | 18 | ||
| 19 | import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; | ||
| 20 | import { Button } from "~/components/ui/button"; | 20 | import { Button } from "~/components/ui/button"; |
| 21 | import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; | 21 | import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; |
| 22 | import { Dialog } from "~/components/ui/dialog"; | 22 | import { Dialog } from "~/components/ui/dialog"; |
| 23 | import { WizardPopup } from "~/components/ui/wizard"; | 23 | import { WizardPopup } from "~/components/ui/wizard"; |
| 24 | import { | ||
| 25 | Select, | ||
| 26 | SelectGroup, | ||
| 27 | SelectGroupLabel, | ||
| 28 | SelectItem, | ||
| 29 | SelectPopup, | ||
| 30 | SelectTrigger, | ||
| 31 | SelectValue, | ||
| 32 | } from "~/components/ui/select"; | ||
| 33 | import { Spinner } from "~/components/ui/spinner"; | 24 | import { Spinner } from "~/components/ui/spinner"; |
| 34 | import { Toggle } from "~/components/ui/toggle"; | 25 | import { Toggle } from "~/components/ui/toggle"; |
| 35 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 26 | import { 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 |
| 38 | import { formatEnvironmentQueryError } from "~/state/query"; | 29 | import { formatEnvironmentQueryError } from "~/state/query"; |
| 39 | import { useAtomCommand } from "~/state/use-atom-command"; | 30 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 40 | import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView"; | 31 | import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView"; |
| 32 | import { DeviceLoadingView } from "./DeviceLoadingView"; | ||
| 41 | import { DeviceSetup } from "./DeviceSetup"; | 33 | import { DeviceSetup } from "./DeviceSetup"; |
| 42 | import { DeviceToolsPanel } from "./DeviceToolsPanel"; | 34 | import { DeviceToolsPanel } from "./DeviceToolsPanel"; |
| 43 | import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; | 35 | import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; |
| 44 | 36 | ||
| 45 | const NEW_DEVICE_VALUE = "__new__"; | ||
| 46 | |||
| 47 | const platformLabel = (platform: DevicePlatform) => | 37 | const platformLabel = (platform: DevicePlatform) => |
| 48 | platform === "ios" ? "iOS Simulators" : "Android Emulators"; | 38 | platform === "ios" ? "iOS Simulators" : "Android Emulators"; |
| 49 | 39 | ||
| 50 | const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) => | 40 | const deviceKey = (device: Pick<DeviceSummary, "hostId" | "id">) => |
| 51 | `${device.hostId}\u0000${device.id}`; | 41 | `${device.hostId}\u0000${device.id}`; |
| 52 | 42 | ||
| 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 | */ | ||
| 58 | export function DevicePanel(props: { | 44 | export 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.deviceId | 77 | ? 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 = activeSession | 83 | 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: { |
| 99 | 88 | ||
| 100 | const grouped = useMemo(() => groupDevices(state), [state]); | 89 | const grouped = useMemo(() => groupDevices(state), [state]); |
| 101 | 90 | ||
| 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 | }; | ||
| 140 | 139 | ||
| 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 | ); |
| 148 | 153 | ||
| 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 | <Select | 172 | <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 | <DeviceButton | 179 | <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 | <div | 310 | <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 | : pendingDeviceKey | 322 | : "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 devices | 379 | 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 | <DeviceStreamView | 38 | <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"; |
| 3 | 3 | ||
| 4 | import { cn } from "~/lib/utils"; | 4 | import { cn } from "~/lib/utils"; |
| 5 | import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device"; | 5 | import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device"; |
| 6 | import { Spinner } from "~/components/ui/spinner"; | 6 | import { DeviceLoadingView } from "./DeviceLoadingView"; |
| 7 | import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi"; | 7 | import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi"; |
| 8 | import { | 8 | import { |
| 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 | }); |
| 22 | 22 | ||
| 23 | describe("rightPanelStore", () => { | 23 | describe("rightPanelStore", () => { |
| 24 | it("gives each host/device its own tab and preserves renamed tabs", () => { | ||
| 25 | const store = useRightPanelStore.getState(); | ||
| 26 | const android = { | ||
| 27 | hostId: "nucbox", | ||
| 28 | deviceId: "emulator-5580", | ||
| 29 | name: "Pixel", | ||
| 30 | platform: "android", | ||
| 31 | } as const; | ||
| 32 | const ios = { hostId: "macmini", deviceId: "ios-1", name: "iPhone", platform: "ios" } as const; | ||
| 33 | store.open(refA, "device"); | ||
| 34 | store.openDevice(refA, android); | ||
| 35 | store.open(refA, "device"); | ||
| 36 | expect( | ||
| 37 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, | ||
| 38 | ).toHaveLength(2); | ||
| 39 | store.openDevice(refA, ios); | ||
| 40 | let state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | ||
| 41 | expect(state.surfaces.map((surface) => surface.id)).toEqual([ | ||
| 42 | "device:nucbox:emulator-5580", | ||
| 43 | "device:macmini:ios-1", | ||
| 44 | ]); | ||
| 45 | store.renameDevice(refA, "device:nucbox:emulator-5580", "Android test"); | ||
| 46 | store.openDevice(refA, android); | ||
| 47 | state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); | ||
| 48 | expect(state.surfaces).toHaveLength(2); | ||
| 49 | expect(state.surfaces[0]).toMatchObject({ title: "Android test", target: android }); | ||
| 50 | expect(state.activeSurfaceId).toBe("device:nucbox:emulator-5580"); | ||
| 51 | store.closeSurface(refA, state.activeSurfaceId!); | ||
| 52 | expect( | ||
| 53 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, | ||
| 54 | ).toEqual([expect.objectContaining({ target: ios })]); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("does not collide when two hosts expose the same device id", () => { | ||
| 58 | const store = useRightPanelStore.getState(); | ||
| 59 | const device = { deviceId: "emulator-5554", name: "Pixel", platform: "android" } as const; | ||
| 60 | store.openDevice(refA, { ...device, hostId: "a:b" }); | ||
| 61 | store.openDevice(refA, { ...device, hostId: "a" }); | ||
| 62 | expect( | ||
| 63 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, | ||
| 64 | ).toHaveLength(2); | ||
| 65 | expect( | ||
| 66 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces, | ||
| 67 | ).toHaveLength(0); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it.each(["one", "all", "others", "right"])( | ||
| 71 | "keeps device tabs dismissed across reload after closing %s", | ||
| 72 | (mode) => { | ||
| 73 | const store = useRightPanelStore.getState(); | ||
| 74 | const target = { | ||
| 75 | hostId: "nucbox", | ||
| 76 | deviceId: "emulator-5580", | ||
| 77 | name: "Pixel", | ||
| 78 | platform: "android", | ||
| 79 | } as const; | ||
| 80 | store.open(refA, "files"); | ||
| 81 | store.openDevice(refA, target); | ||
| 82 | if (mode === "one") store.closeSurface(refA, "device:nucbox:emulator-5580"); | ||
| 83 | if (mode === "all") store.closeAllSurfaces(refA); | ||
| 84 | if (mode === "others") store.closeOtherSurfaces(refA, "files"); | ||
| 85 | if (mode === "right") store.closeSurfacesToRight(refA, "files"); | ||
| 86 | const persisted = JSON.parse( | ||
| 87 | JSON.stringify({ byThreadKey: useRightPanelStore.getState().byThreadKey }), | ||
| 88 | ); | ||
| 89 | useRightPanelStore.setState(migratePersistedRightPanelState(persisted)); | ||
| 90 | store.openDevice(refA, target, true); | ||
| 91 | expect( | ||
| 92 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some( | ||
| 93 | (surface) => surface.kind === "device", | ||
| 94 | ), | ||
| 95 | ).toBe(false); | ||
| 96 | store.openDevice(refA, target); | ||
| 97 | expect( | ||
| 98 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some( | ||
| 99 | (surface) => surface.kind === "device", | ||
| 100 | ), | ||
| 101 | ).toBe(true); | ||
| 102 | }, | ||
| 103 | ); | ||
| 104 | |||
| 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; |
| 33 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; | 33 | export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; |
| 34 | 34 | ||
| 35 | export interface DeviceTabTarget { | ||
| 36 | hostId: string; | ||
| 37 | deviceId: string; | ||
| 38 | platform: "ios" | "android"; | ||
| 39 | name: string; | ||
| 40 | } | ||
| 41 | |||
| 35 | export type RightPanelSurface = | 42 | export 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. |
| 93 | const RIGHT_PANEL_STORAGE_VERSION = 12; | 95 | const RIGHT_PANEL_STORAGE_VERSION = 13; |
| 94 | 96 | ||
| 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. */ |
| 96 | export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( | 98 | export 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 | } |
| 112 | 115 | ||
| 113 | interface RightPanelStoreState { | 116 | interface 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 @@ |
| 1 | import { useMemo } from "react"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device"; | 3 | import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device"; |
| 3 | import { | 4 | import { |
| ... | @@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt | ... | @@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt |
| 18 | 19 | ||
| 19 | const EMPTY_DEVICE_STATE: DeviceServiceState = { | 20 | const 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 | ); |
| 56 | 58 | ||
| 57 | export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null { | 59 | export 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 | } |
| 63 | 74 | ||
| 64 | const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial<DeviceHubAccess, never>()).pipe( | 75 | const 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({ |
| 129 | export type DeviceOpenInput = typeof DeviceOpenInput.Type; | 136 | export type DeviceOpenInput = typeof DeviceOpenInput.Type; |
| 130 | 137 | ||
| 131 | export const DeviceCloseInput = Schema.Struct({ | 138 | export 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), |