diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index f8685727c882..d2cb618fc488 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -196,7 +196,7 @@ const handler = Effect.gen(function* () { (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath)); yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); const devices = yield* DeviceService.DeviceService; - const ready = yield* devices.currentReadiness(); + const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); if (!ready) { return HttpServerResponse.text("Device hub is not running", { status: 503 }); } @@ -206,6 +206,7 @@ const handler = Effect.gen(function* () { // The ticket authenticates here and must not travel on to the hub. const upstreamSearch = new URLSearchParams(url.value.search); upstreamSearch.delete("wsTicket"); + upstreamSearch.delete("hostId"); const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : ""; const upstreamPath = `${hubPath}${search}`; if (upgrade) { diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts new file mode 100644 index 000000000000..d586dffa1567 --- /dev/null +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { DeviceHostError, DeviceHost } from "./DeviceHost.ts"; +import { makeWithHosts } from "./DeviceService.ts"; + +it.effect("keeps hosts independent when serials collide and another host fails", () => + Effect.gen(function* () { + const host = (id: string, failed = false): DeviceHost["Service"] => { + const ready = { + hub: { origin: `http://${id}` }, + agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, + run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }; + return { + id, + summary: Effect.succeed({ + id, + label: id, + kind: "local", + hubInstalled: true, + agentDeviceInstalled: true, + platforms: [{ platform: "android", available: true }], + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => + failed + ? Effect.fail( + new DeviceHostError({ hostId: id, step: "connect", cause: new Error("offline") }), + ) + : Effect.succeed(ready), + ensureAgentReady: () => Effect.succeed(ready), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + }; + const http = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + simulators: [], + emulators: [ + { + id: "emulator-5554", + name: "Pixel", + version: "36", + platform: "android", + booted: true, + physical: false, + }, + ], + }), + ), + ), + ); + const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); + const service = yield* makeWithHosts(hosts).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + const listed = yield* service.list; + expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); + expect(listed.hostStatuses.offline?.status).toBe("failed"); + const threadId = ThreadId.make("thread"); + for (const hostId of ["a", "b"]) + yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" }); + yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" }); + const state = yield* service.state; + expect(state.devices).toHaveLength(2); + expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); + expect(state.hostStatuses.a?.status).toBe("ready"); + expect(state.hostStatuses.offline?.status).toBe("failed"); + yield* service.agentReadinessIfSupported("b"); + expect((yield* service.state).hostStatuses.b?.status).toBe("ready"); + yield* service.configure({ enabled: false }); + expect((yield* service.state).hostStatuses).toEqual({}); + }).pipe( + Effect.provide( + ServerSettingsService.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), + ), + ), +); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index ed42be64eb02..49a8545bfd6d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts"; const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 6cca6631bd7e..597e1a5d2b10 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -136,8 +136,9 @@ interface ServiceState { const vendorPrefix = (platform: DevicePlatform) => platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; -export const make = Effect.gen(function* () { - const localHost = yield* DeviceHost.DeviceHost; +export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( + hosts: ReadonlyMap, +) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); const readDeviceSettings = settings.getSettings.pipe( @@ -152,9 +153,7 @@ export const make = Effect.gen(function* () { ), ); const initialSettings = yield* readDeviceSettings; - const hosts: ReadonlyMap = new Map([ - [localHost.id, localHost], - ]); + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const statePubSub = yield* PubSub.unbounded(); const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); @@ -162,6 +161,7 @@ export const make = Effect.gen(function* () { state: { hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: initialSettings.onboardingCompleted, @@ -187,6 +187,18 @@ export const make = Effect.gen(function* () { return host; }); + const setHostStatus = ( + hostId: DeviceHostId, + status: DeviceServiceState["hostStatuses"][string], + ) => + publish((state) => ({ + ...state, + ...(hostId === LOCAL_DEVICE_HOST_ID + ? { hostStatus: status.status, hostStatusDetail: status.detail } + : {}), + hostStatuses: { ...state.hostStatuses, [hostId]: status }, + })); + const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( function* (hostId) { const host = yield* resolveHost(hostId); @@ -198,34 +210,19 @@ export const make = Effect.gen(function* () { }); } const ready = yield* host - .ensureReady((phase) => - publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( - Effect.asVoid, - ), - ) + .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) .pipe( Effect.tapError((error) => - publish((state) => ({ - ...state, - hostStatus: "failed", - hostStatusDetail: error.message, - })), + setHostStatus(host.id, { status: "failed", detail: error.message }), ), Effect.mapError( (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), ), ); - yield* SynchronizedRef.get(stateRef).pipe( - Effect.flatMap(({ state }) => - state.hostStatus === "ready" - ? Effect.void - : publish((current) => ({ - ...current, - hostStatus: "ready", - hostStatusDetail: undefined, - })), - ), - ); + const { state } = yield* SynchronizedRef.get(stateRef); + if (state.hostStatuses[host.id]?.status !== "ready") { + yield* setHostStatus(host.id, { status: "ready" }); + } return { hostId: host.id, ...ready }; }, lifecycleLock.withPermit, @@ -249,25 +246,18 @@ export const make = Effect.gen(function* () { const summary = yield* host.summary; if (!summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => - publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( - Effect.asVoid, - ), - ) + .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) .pipe( Effect.tapError((error) => - publish((state) => ({ - ...state, - hostStatus: "failed", - hostStatusDetail: error.message, - })), + setHostStatus(host.id, { status: "failed", detail: error.message }), ), Effect.mapError( (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), ), ); const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); - yield* publish((state) => ({ ...state, hosts: hostSummaries, hostStatus: "ready" })); + yield* publish((state) => ({ ...state, hosts: hostSummaries })); + yield* setHostStatus(host.id, { status: "ready" }); return { hostId: host.id, ...ready }; }, lifecycleLock.withPermit); @@ -357,8 +347,15 @@ export const make = Effect.gen(function* () { return yield* publish((state) => ({ ...state, hosts: hostSummaries, - devices, - hostStatusDetail: detail, + ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}), + devices: [ + ...state.devices.filter((device) => device.hostId !== ready.hostId), + ...devices, + ], + hostStatuses: { + ...state.hostStatuses, + [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) }, + }, })); }), ); @@ -366,18 +363,21 @@ export const make = Effect.gen(function* () { const list: DeviceService["Service"]["list"] = Effect.gen(function* () { if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; - const ready = yield* readiness(); - return yield* refresh(ready); - }).pipe( - Effect.tapError((error) => - publish((state) => - state.hostStatus === "disabled" - ? state - : { ...state, hostStatus: "failed", hostStatusDetail: error.message }, - ), - ), - Effect.withSpan("DeviceService.list"), - ); + yield* Effect.forEach( + hosts.values(), + (host) => + Effect.gen(function* () { + const ready = yield* readinessIfSupported(host.id); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(host.id, { status: "failed", detail: error.message }), + ), + ), + { concurrency: 4 }, + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }).pipe(Effect.withSpan("DeviceService.list")); const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( function* (input) { @@ -415,6 +415,7 @@ export const make = Effect.gen(function* () { ...state, hostStatus: nextEnabled ? "idle" : "disabled", hostStatusDetail: undefined, + hostStatuses: {}, devices: nextEnabled ? state.devices : [], sessions: nextEnabled ? state.sessions : [], bootingDevices: nextEnabled ? state.bootingDevices : [], @@ -636,6 +637,7 @@ export const make = Effect.gen(function* () { const closing = state.sessions.filter( (session) => session.threadId === input.threadId && + (input.hostId === undefined || session.hostId === input.hostId) && (input.deviceId === undefined || session.deviceId === input.deviceId), ); if (closing.length === 0) return; @@ -749,6 +751,11 @@ export const make = Effect.gen(function* () { }); }); +export const make = Effect.gen(function* () { + const host = yield* DeviceHost.DeviceHost; + return yield* makeWithHosts(new Map([[host.id, host]])); +}); + export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); /** State stream for WS subscribers: current snapshot first, then every change. */ diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 083ac0c034fe..32f919cbb682 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -56,6 +56,7 @@ const state = { }, ], hostStatus: "ready" as const, + hostStatuses: { local: { status: "ready" as const } }, devices: [device], sessions: [], onboardingCompleted: true, diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index bab7391af24d..5ad57031306e 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -161,7 +161,9 @@ const handlers = { const target = input.deviceId !== undefined ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId } - : sessions.at(-1); + : sessions + .filter((session) => input.hostId === undefined || session.hostId === input.hostId) + .at(-1); if (!target) { return yield* new DeviceToolUnavailableError({ reason: "No device is open in this thread. Call device_open first.", @@ -183,6 +185,7 @@ const handlers = { const devices = yield* DeviceService.DeviceService; yield* devices.close({ threadId: scope.threadId, + ...(input.hostId === undefined ? {} : { hostId: input.hostId }), ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 62412ac25853..afb4adfef79c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( const EMPTY_DEVICE_STATE: DeviceServiceState = { hosts: [], - hostStatus: "idle", + hostStatus: "disabled", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 225d91310014..283e85365667 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4143,7 +4143,9 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !supportsThreadPullRequests) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); }, [activeThreadRef, supportsThreadPullRequests]); - const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null); + const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( + activeThreadRef?.environmentId ?? null, + ); const [deviceSetupThread, setDeviceSetupThread] = useState(null); const addDeviceSurface = useCallback(() => { if (!activeThreadRef) return; @@ -4153,24 +4155,53 @@ export default function ChatView(props: ChatViewProps) { } useRightPanelStore.getState().open(activeThreadRef, "device"); }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]); - // An agent's `device_open` surfaces in every client the same way a - // `preview_open` does: the thread starts a device or gains a session and the panel - // opens on it. Closing the last session leaves the tab in place so the - // user keeps their picker; only new sessions raise the panel. - const threadDeviceSessionCount = activeThreadRef - ? deviceState.sessions.filter((session) => session.threadId === activeThreadRef.threadId) - .length + - (deviceState.bootingDevices?.filter((device) => device.threadId === activeThreadRef.threadId) - .length ?? 0) - : 0; - const previousDeviceSessionCount = useRef(threadDeviceSessionCount); + // Reconcile new server sessions into separate tabs, including sessions opened + // by an agent or another client. The first snapshot is a baseline: persisted + // tabs restore themselves, and existing sessions must not resurrect closed tabs. + const previousDeviceSessions = useRef(new Map>()); useEffect(() => { - const previous = previousDeviceSessionCount.current; - previousDeviceSessionCount.current = threadDeviceSessionCount; - if (!activeThreadRef || threadDeviceSessionCount <= previous) return; - if (shouldUseRightPanelSheet) return; - useRightPanelStore.getState().open(activeThreadRef, "device"); - }, [activeThreadRef, shouldUseRightPanelSheet, threadDeviceSessionCount]); + if (!activeThreadRef || !deviceStateLoaded) return; + const threadKey = `${activeThreadRef.environmentId}:${activeThreadRef.threadId}`; + const sessions = deviceState.sessions.filter( + (session) => session.threadId === activeThreadRef.threadId, + ); + const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`; + const previous = previousDeviceSessions.current.get(threadKey); + previousDeviceSessions.current.set(threadKey, new Set(sessions.map(key))); + if (!previous || shouldUseRightPanelSheet) return; + for (const session of sessions) { + if (previous?.has(key(session))) continue; + const existing = useRightPanelStore + .getState() + .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some( + (surface) => + surface.kind === "device" && + surface.target?.hostId === session.hostId && + surface.target.deviceId === session.deviceId, + ); + if (existing) continue; + const device = deviceState.devices.find( + (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, + ); + if (!device) continue; + useRightPanelStore.getState().openDevice( + activeThreadRef, + { + hostId: session.hostId, + deviceId: session.deviceId, + platform: device.platform, + name: device.name, + }, + true, + ); + } + }, [ + activeThreadRef, + deviceStateLoaded, + shouldUseRightPanelSheet, + deviceState.sessions, + deviceState.devices, + ]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -8153,7 +8184,8 @@ export default function ChatView(props: ChatViewProps) { { closeRightPanelSurface(renderedRightPanelSurface); @@ -8725,6 +8757,10 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} + onRenameDevice={(surfaceId, title) => { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }} onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} @@ -8779,6 +8815,10 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} + onRenameDevice={(surfaceId, title) => { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }} onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 199d0ba834d0..b13040152e18 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,6 +2,32 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; +// Apple brand mark from Simple Icons (CC0). +export const AppleIcon: Icon = (props) => ( + +); + +export const AndroidIcon: Icon = (props) => ( + +); + export const LinuxIcon: Icon = ({ className, ...props }) => ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 546c7c5ad45a..79caadf90e06 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -46,6 +46,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; +import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { @@ -97,6 +98,7 @@ interface RightPanelTabsProps { previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; + onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; @@ -182,6 +184,7 @@ const SURFACE_UNAVAILABLE_HINTS = { } as const; type TabContextMenuAction = + | "rename" | "copy-path" | "toggle-mute" | "close" @@ -648,7 +651,7 @@ function surfaceTitle( case "agents": return "Agents"; case "device": - return "Device"; + return surface.title ?? surface.target?.name ?? "Device"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -733,7 +736,13 @@ function SurfaceIcon({ case "agents": return ; case "device": - return ; + return surface.target?.platform === "ios" ? ( + + ) : surface.target?.platform === "android" ? ( + + ) : ( + + ); } } @@ -832,6 +841,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ hasOverflow: false, @@ -958,6 +968,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (surfaceIndex < 0) return; const items: ContextMenuItem[] = []; + if (surface.kind === "device" && props.onRenameDevice) + items.push({ id: "rename", label: "Rename" }); if (surface.kind === "file" && surface.attachment === undefined) { items.push({ id: "copy-path", label: "Copy path" }); } @@ -1001,6 +1013,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); switch (action) { + case "rename": + setRenamingDevice(surface.id); + break; case "copy-path": if (surface.kind === "file" && surface.attachment === undefined) { props.onCopyFilePath(surface.relativePath); @@ -1202,20 +1217,48 @@ export function RightPanelTabs(props: RightPanelTabsProps) { {audio === "muted" ? "Unmute tab" : "Mute tab"} )} - - props.onActivate(surface)} - > - {title} - - } + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - {title} - + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + {title} + + )} ); })} diff --git a/apps/web/src/components/device/DeviceLoadingView.tsx b/apps/web/src/components/device/DeviceLoadingView.tsx new file mode 100644 index 000000000000..70e848c3ce7e --- /dev/null +++ b/apps/web/src/components/device/DeviceLoadingView.tsx @@ -0,0 +1,47 @@ +import { Smartphone } from "lucide-react"; + +import { Spinner } from "~/components/ui/spinner"; + +export function DeviceLoadingView(props: { + readonly name: string; + readonly description?: string; + readonly stage: "opening" | "stream"; + readonly message: string; + readonly error?: boolean; +}) { + return ( +
+
+
+ +
+
+

{props.name}

+ {props.description ? ( +

{props.description}

+ ) : null} +
+
+ {!props.error ? : null} + {props.message} +
+ {!props.error ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index d94913b03dff..5b312f7fc4f4 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -6,7 +6,6 @@ import type { } from "@t3tools/contracts"; import { ChevronLeft, - Circle, Home, Power, RotateCcw, @@ -15,21 +14,13 @@ import { Square, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; import { Button } from "~/components/ui/button"; import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; import { Dialog } from "~/components/ui/dialog"; import { WizardPopup } from "~/components/ui/wizard"; -import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { Spinner } from "~/components/ui/spinner"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; @@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView"; +import { DeviceLoadingView } from "./DeviceLoadingView"; import { DeviceSetup } from "./DeviceSetup"; import { DeviceToolsPanel } from "./DeviceToolsPanel"; import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; -const NEW_DEVICE_VALUE = "__new__"; - const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -/** - * The Device right-panel surface: one open device (from the thread's device - * sessions) with a picker to switch or boot another. Booting and streaming are - * server-owned; this panel only asks and renders. - */ +/** Each surface owns one host/device; only the visible surface streams. */ export function DevicePanel(props: { readonly mode: PreviewPanelMode; readonly threadRef: ScopedThreadRef; - /** `null` renders the picker with nothing open. */ - readonly deviceId: string | null; + readonly surface: Extract; readonly visible: boolean; readonly onDismissSetup: () => void; }) { @@ -69,7 +54,8 @@ export function DevicePanel(props: { const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); - const [pendingDeviceKey, setPendingDeviceKey] = useState(null); + const [pendingDevice, setPendingDevice] = useState(null); + const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const [handle, setHandle] = useState(null); const [toolsOpen, setToolsOpen] = useState(false); const [axOverlay, setAxOverlay] = useState(false); @@ -87,10 +73,13 @@ export function DevicePanel(props: { () => state.sessions.filter((session) => session.threadId === threadId), [state.sessions, threadId], ); - const activeSession = - (props.deviceId - ? sessions.find((session) => session.deviceId === props.deviceId) - : undefined) ?? sessions.at(-1); + const activeSession = props.surface.target + ? sessions.find( + (session) => + session.deviceId === props.surface.target?.deviceId && + session.hostId === props.surface.target.hostId, + ) + : undefined; const activeDevice = activeSession ? state.devices.find( (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, @@ -99,51 +88,67 @@ export function DevicePanel(props: { const grouped = useMemo(() => groupDevices(state), [state]); - const selectDevice = useCallback( - async (value: string) => { - if (value === NEW_DEVICE_VALUE) return; - const device = state.devices.find((candidate) => deviceKey(candidate) === value); - if (!device) return; - setOperationError(null); - setPendingDeviceKey(value); - try { - const result = await open({ - environmentId, - input: { - threadId, - hostId: device.hostId, - deviceId: device.id, - platform: device.platform, - }, - }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - } finally { - setPendingDeviceKey(null); - } - }, - [environmentId, open, state.devices, threadId], - ); - - const closeActive = useCallback( - (powerOff: boolean) => { - if (!activeSession) return; - setOperationError(null); - void close({ + const selectDevice = async (value: string) => { + const device = state.devices.find((candidate) => deviceKey(candidate) === value); + if (!device) return; + setOperationError(null); + setPendingDevice(device); + try { + const result = await open({ environmentId, - input: { threadId, deviceId: activeSession.deviceId, shutdown: powerOff }, - }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + input: { + threadId, + hostId: device.hostId, + deviceId: device.id, + platform: device.platform, + }, }); - }, - [activeSession, close, environmentId, threadId], - ); + if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + else + useRightPanelStore.getState().openDevice(props.threadRef, { + hostId: result.value.hostId, + deviceId: result.value.deviceId, + platform: device.platform, + name: device.name, + }); + } finally { + setPendingDevice(null); + } + }; + + const closeActive = (powerOff: boolean) => { + if (!powerOff) { + useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + return; + } + if (!activeSession) return; + setOperationError(null); + void close({ + environmentId, + input: { + threadId, + hostId: activeSession.hostId, + deviceId: activeSession.deviceId, + shutdown: powerOff, + }, + }).then((result) => { + if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + }); + }; const bootingDevices = state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; - const hostReady = state.hostStatus === "ready"; - const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting"; + const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready"); + const hostBusy = + !hostReady && + Object.values(state.hostStatuses).some( + (host) => host.status === "installing" || host.status === "starting", + ); const unavailablePlatforms = state.hosts.flatMap((host) => - host.platforms.filter((platform) => !platform.available), + host.platforms + .filter((platform) => !platform.available) + .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })), ); if (loaded && (!state.onboardingCompleted || hostDisabled)) { @@ -164,63 +169,11 @@ export function DevicePanel(props: { return (
- + + {props.surface.target + ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}` + : (pendingDevice?.name ?? "Choose a device")} + {activeDevice ? ( <> host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`} deviceId={activeDevice.id} + hostId={activeDevice.hostId} visible={props.visible} axOverlay={axOverlay} onHandle={setHandle} @@ -330,6 +286,25 @@ export function DevicePanel(props: { /> ) : null} + ) : pendingDevice || hostBusy || !loaded ? ( + host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}` + : "" + } + stage="opening" + message={ + pendingDevice + ? pendingDevice.booted + ? "Opening device…" + : "Starting device…" + : state.hostStatus === "installing" + ? "Installing device support…" + : "Finding devices…" + } + /> ) : (
- {grouped.length === 0 || hostBusy || pendingDeviceKey ? ( + {grouped.length === 0 ? ( <> - {hostBusy || pendingDeviceKey ? ( - - ) : ( - - )} +

{state.hostStatus === "failed" ? (state.hostStatusDetail ?? "The device hub failed to start.") - : pendingDeviceKey - ? "Booting device… this can take a minute." - : hostBusy - ? state.hostStatus === "installing" - ? "Installing device tools…" - : "Starting the device hub…" - : !loaded - ? "Connecting…" - : grouped.length === 0 - ? "No simulators or emulators were found on this environment." - : "Choose a device to open."} + : "No simulators or emulators were found on this environment."}

) : null} @@ -380,7 +341,7 @@ export function DevicePanel(props: { } title={device.name} - description={`${device.version} · ${device.booted ? "Running" : "Stopped"}`} + description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`} disabled={pendingDeviceKey !== null} aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`} onClick={() => void selectDevice(deviceKey(device))} @@ -418,15 +379,6 @@ export function DevicePanel(props: { Refresh devices ) : null} - {unavailablePlatforms.length > 0 && hostReady ? ( -
    - {unavailablePlatforms.map((platform) => ( -
  • - {platform.platform === "ios" ? "iOS" : "Android"}: {platform.reason} -
  • - ))} -
- ) : null}
)} diff --git a/apps/web/src/components/device/DeviceStreamView.test.tsx b/apps/web/src/components/device/DeviceStreamView.test.tsx index a46e5ed7b7b8..9c8a1197926a 100644 --- a/apps/web/src/components/device/DeviceStreamView.test.tsx +++ b/apps/web/src/components/device/DeviceStreamView.test.tsx @@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () => ); const view = (visible: boolean) => ( void; readonly onScreen?: (screen: DeviceScreenSize | null) => void; }) { - const access = useDeviceHubAccess(props.environmentId); + const access = useDeviceHubAccess(props.environmentId, props.hostId); const canvasRef = useRef(null); const clientRef = useRef(null); const [status, setStatus] = useState("connecting"); @@ -314,12 +317,14 @@ export function DeviceStreamView(props: {
) : null} {status !== "streaming" ? ( -
- {status === "connecting" ? : null} - {status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"} - {status === "connecting" && detail ? ( - {detail} - ) : null} +
+
) : null}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 9ce3b2126c0e..4ef218430ae1 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -105,6 +105,7 @@ const deviceState = (overrides: Partial = {}): DeviceService }, ], hostStatus: "ready", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index ad1788f4cfe9..e2299511b87c 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -21,6 +21,87 @@ beforeEach(() => { }); describe("rightPanelStore", () => { + it("gives each host/device its own tab and preserves renamed tabs", () => { + const store = useRightPanelStore.getState(); + const android = { + hostId: "nucbox", + deviceId: "emulator-5580", + name: "Pixel", + platform: "android", + } as const; + const ios = { hostId: "macmini", deviceId: "ios-1", name: "iPhone", platform: "ios" } as const; + store.open(refA, "device"); + store.openDevice(refA, android); + store.open(refA, "device"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toHaveLength(2); + store.openDevice(refA, ios); + let state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual([ + "device:nucbox:emulator-5580", + "device:macmini:ios-1", + ]); + store.renameDevice(refA, "device:nucbox:emulator-5580", "Android test"); + store.openDevice(refA, android); + state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces).toHaveLength(2); + expect(state.surfaces[0]).toMatchObject({ title: "Android test", target: android }); + expect(state.activeSurfaceId).toBe("device:nucbox:emulator-5580"); + store.closeSurface(refA, state.activeSurfaceId!); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toEqual([expect.objectContaining({ target: ios })]); + }); + + it("does not collide when two hosts expose the same device id", () => { + const store = useRightPanelStore.getState(); + const device = { deviceId: "emulator-5554", name: "Pixel", platform: "android" } as const; + store.openDevice(refA, { ...device, hostId: "a:b" }); + store.openDevice(refA, { ...device, hostId: "a" }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toHaveLength(2); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces, + ).toHaveLength(0); + }); + + it.each(["one", "all", "others", "right"])( + "keeps device tabs dismissed across reload after closing %s", + (mode) => { + const store = useRightPanelStore.getState(); + const target = { + hostId: "nucbox", + deviceId: "emulator-5580", + name: "Pixel", + platform: "android", + } as const; + store.open(refA, "files"); + store.openDevice(refA, target); + if (mode === "one") store.closeSurface(refA, "device:nucbox:emulator-5580"); + if (mode === "all") store.closeAllSurfaces(refA); + if (mode === "others") store.closeOtherSurfaces(refA, "files"); + if (mode === "right") store.closeSurfacesToRight(refA, "files"); + const persisted = JSON.parse( + JSON.stringify({ byThreadKey: useRightPanelStore.getState().byThreadKey }), + ); + useRightPanelStore.setState(migratePersistedRightPanelState(persisted)); + store.openDevice(refA, target, true); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some( + (surface) => surface.kind === "device", + ), + ).toBe(false); + store.openDevice(refA, target); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some( + (surface) => surface.kind === "device", + ), + ).toBe(true); + }, + ); + const completedDiff = { id: "diff", kind: "diff" } as const; const linkedPullRequest = pullRequestSurface({ projectId: "project-a", diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 293733b5b3d9..c44c106c68c8 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -32,15 +32,17 @@ const RIGHT_PANEL_KINDS = [ ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; +export interface DeviceTabTarget { + hostId: string; + deviceId: string; + platform: "ios" | "android"; + name: string; +} + export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } - /** - * One Device tab per thread. The tab is the surface; which device it shows - * comes from the thread's server-side device sessions, so an agent opening a - * device from another client lands in the same tab. - */ - | { id: "device"; kind: "device" } + | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string } | { id: `terminal:${string}`; kind: "terminal"; @@ -90,7 +92,7 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the device surface. -const RIGHT_PANEL_STORAGE_VERSION = 12; +const RIGHT_PANEL_STORAGE_VERSION = 13; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -108,6 +110,7 @@ export interface ThreadRightPanelState { isOpen: boolean; activeSurfaceId: string | null; surfaces: RightPanelSurface[]; + dismissedDeviceSurfaceIds?: string[]; } interface RightPanelStoreState { @@ -128,6 +131,8 @@ interface RightPanelStoreState { ref: ScopedThreadRef, kind: Exclude, ) => void; + openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; + renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; @@ -281,7 +286,12 @@ const updateThread = ( ): Record => { const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; const next = updater(current); - if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) { + if ( + !next.isOpen && + next.activeSurfaceId === null && + next.surfaces.length === 0 && + !next.dismissedDeviceSurfaceIds?.length + ) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; @@ -306,7 +316,25 @@ const userAction = ( threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState, ): Partial => ({ - byThreadKey: updateThread(state.byThreadKey, threadKey, updater), + byThreadKey: updateThread(state.byThreadKey, threadKey, (current) => { + const next = updater(current); + const removed = current.surfaces.filter( + (surface) => + surface.kind === "device" && + surface.target && + !next.surfaces.some((entry) => entry.id === surface.id), + ); + if (removed.length === 0) return next; + return { + ...next, + dismissedDeviceSurfaceIds: [ + ...new Set([ + ...(next.dismissedDeviceSurfaceIds ?? []), + ...removed.map((surface) => surface.id), + ]), + ], + }; + }), userActionRevisionByThreadKey: { ...state.userActionRevisionByThreadKey, [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, @@ -426,7 +454,22 @@ export function migratePersistedRightPanelState(persistedState: unknown): { // first survivor instead of rendering an open empty panel. const activeSurfaceId = persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); - return [threadKey, { isOpen, surfaces, activeSurfaceId }]; + return [ + threadKey, + { + isOpen, + surfaces, + activeSurfaceId, + ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds) + ? { + dismissedDeviceSurfaceIds: + validThreadState.dismissedDeviceSurfaceIds.filter( + (id): id is string => typeof id === "string", + ), + } + : {}), + }, + ]; }), ) : {}; @@ -472,6 +515,40 @@ export const useRightPanelStore = create()( return upsertSurface(current, singletonSurface(kind)); }), ), + openDevice: (ref, target, automatic = false) => + set((state) => + (automatic ? automaticUpdate : userAction)(state, scopedThreadKey(ref), (current) => { + const id = + `device:${encodeURIComponent(target.hostId)}:${encodeURIComponent(target.deviceId)}` as const; + if (automatic && current.dismissedDeviceSurfaceIds?.includes(id)) return current; + const surface: RightPanelSurface = { id, kind: "device", target }; + const existing = current.surfaces.find((entry) => entry.id === id); + const surfaces = existing + ? current.surfaces.filter((entry) => entry.id !== "device") + : current.surfaces.map((entry) => (entry.id === "device" ? surface : entry)); + return upsertSurface( + { + ...current, + surfaces, + dismissedDeviceSurfaceIds: (current.dismissedDeviceSurfaceIds ?? []).filter( + (entry) => entry !== id, + ), + }, + existing ?? surface, + ); + }), + ), + renameDevice: (ref, surfaceId, title) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.id === surfaceId && surface.kind === "device" + ? { ...surface, title: title.trim() || surface.target?.name || "Device" } + : surface, + ), + })), + ), openBrowser: (ref, tabId) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index b9c1adf86cdd..9a42cb0b1305 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useAtomValue } from "@effect/atom-react"; import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device"; import { @@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt const EMPTY_DEVICE_STATE: DeviceServiceState = { hosts: [], - hostStatus: "idle", + hostStatus: "disabled", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, @@ -54,11 +56,20 @@ const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)), ); -export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null { +export function useDeviceHubAccess( + environmentId: EnvironmentId | null, + hostId = "local", +): DeviceHubAccess | null { const result = useAtomValue( environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId), ); - return AsyncResult.isSuccess(result) ? result.value : null; + return useMemo( + () => + AsyncResult.isSuccess(result) + ? { ...result.value, query: { ...result.value.query, hostId } } + : null, + [result, hostId], + ); } const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial()).pipe( diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 309d156430fa..895a954e2e40 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -95,6 +95,13 @@ export const DeviceServiceState = Schema.Struct({ hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), + hostStatuses: Schema.Record( + DeviceHostId, + Schema.Struct({ + status: DeviceHostStatus, + detail: Schema.optional(Schema.String), + }), + ), devices: Schema.Array(DeviceSummary), sessions: Schema.Array(DeviceSession), bootingDevices: Schema.optional( @@ -129,6 +136,7 @@ export const DeviceOpenInput = Schema.Struct({ export type DeviceOpenInput = typeof DeviceOpenInput.Type; export const DeviceCloseInput = Schema.Struct({ + hostId: Schema.optional(DeviceHostId), threadId: ThreadId, /** Omit to close every device session for the thread. */ deviceId: Schema.optional(DeviceId),