diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx new file mode 100644 index 000000000000..1c3ba32e9eec --- /dev/null +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -0,0 +1,260 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { DevicePanel } from "./DevicePanel"; + +type CommandResult = + | { _tag: "Success"; value: { hostId: string; deviceId: string } } + | { _tag: "Failure"; cause: unknown }; +const mocks = vi.hoisted(() => ({ + open: vi.fn<(request: unknown) => Promise>(), + close: vi.fn<(request: unknown) => Promise>(), + openDevice: vi.fn(), + closeSurface: vi.fn(), +})); +const phone = { + hostId: "local", + id: "phone", + name: "Phone", + platform: "ios", + version: "iOS 19", + booted: false, + physical: false, +} as const; +const deviceState: DeviceServiceState = { + hosts: [ + { + id: "local", + kind: "local", + label: "This Mac", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatus: "ready", + hostStatuses: { local: { status: "ready" } }, + devices: [phone], + sessions: [ + { + threadId: ThreadId.make("thread-1"), + hostId: "local", + deviceId: "phone", + platform: "ios", + openedAt: "2026-01-01T00:00:00.000Z", + }, + ], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "/api/device-hub", + revision: 1, +}; +vi.mock("~/state/device", () => ({ + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: deviceState, loaded: true }), +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "open" + ? mocks.open + : command === "close" + ? mocks.close + : () => Promise.resolve({ _tag: "Success", value: {} }), +})); +vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ + DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( + + ), +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => children, +})); + +const threadA = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +// The panel stays mounted across both kinds of switch. +const switches = [ + ["another thread", { environmentId: threadA.environmentId, threadId: ThreadId.make("thread-2") }], + [ + "the same thread id in another environment", + { ...threadA, environmentId: EnvironmentId.make("environment-b") }, + ], +] as const; +const threadB = switches[0][1]; +const otherEnvironment = switches[1][1]; +const picker: Extract = { id: "device", kind: "device" }; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; +const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }; +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; +const failed = { _tag: "Failure", cause: new Error("boom") } as const; + +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; + +let root: Root; +let container: HTMLDivElement; +// Fixed key and a changing threadRef, as RightPanelSurfaceContent mounts it. +const show = (threadRef: ScopedThreadRef, surface = picker) => + act(async () => + root.render( + undefined} + />, + ), + ); +const startButton = () => container.querySelector('[aria-label="Start Phone"]'); +const click = (button: HTMLButtonElement | null) => + act(async () => { + button!.click(); + }); +const errors = () => container.querySelectorAll('[role="alert"]'); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe("DevicePanel thread switches", () => { + it.each(switches)("does not carry a pending or failed pick to %s", async (_, target) => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await show(threadA); + await click(startButton()); + expect(startButton()).toBeNull(); + + await show(target); + expect(startButton()?.disabled).toBe(false); + + await act(async () => pick.settle(failed)); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + + await show(threadA); + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(1); + + await show(target); + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(0); + }); + + it.each([ + [ + "pick", + picker, + () => mocks.open, + () => click(startButton()), + () => expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(threadA, phoneTarget), + ], + [ + "power-off", + streaming, + () => mocks.close, + () => click(container.querySelector("button")), + () => expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(threadA, streaming.id), + ], + ] as const)( + "applies a late %s to its starting thread after switching environments with the same thread id", + async (_, surface, command, start, expectApplied) => { + const late = deferred(); + command().mockReturnValue(late.promise); + await show(threadA, surface); + await start(); + + await show(otherEnvironment, surface); + await act(async () => late.settle(opened)); + + expectApplied(); + }, + ); + + it("keeps a late power-off error in the environment that started it", async () => { + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + await show(threadA, streaming); + await click(container.querySelector("button")); + + await show(otherEnvironment, streaming); + await act(async () => powerOff.settle(failed)); + + expect(errors()).toHaveLength(0); + expect(mocks.closeSurface).not.toHaveBeenCalled(); + await show(threadA, streaming); + expect(errors()).toHaveLength(1); + }); + + it("keeps the latest pick pending across switches away and back", async () => { + const first = deferred(); + const second = deferred(); + const latest = deferred(); + mocks.open + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(latest.promise); + await show(threadA); + await click(startButton()); + await show(threadB); + await click(startButton()); + await show(threadA); + await click(startButton()); + + await act(async () => first.settle(failed)); + + expect(startButton()).toBeNull(); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + + await act(async () => second.settle(opened)); + + expect(startButton()).toBeNull(); + expect(mocks.openDevice.mock.calls).toEqual([[threadB, phoneTarget]]); + + await act(async () => latest.settle(opened)); + + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice.mock.calls).toEqual([ + [threadB, phoneTarget], + [threadA, phoneTarget], + ]); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 05668509ea30..4e9cc7873e15 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -42,8 +42,16 @@ export function DevicePanel(props: { const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); - const [operationError, setOperationError] = useState(null); - const [pendingDevice, setPendingDevice] = useState(null); + // The panel stays mounted across thread switches, so an in-flight open or + // power-off is tagged with the thread it belongs to and only shown there. + const scope = `${environmentId}\u0000${threadId}`; + const [operation, setOperation] = useState<{ + readonly scope: string; + readonly pending: DeviceSummary | null; + readonly error: string | null; + } | null>(null); + const pendingDevice = operation?.scope === scope ? operation.pending : null; + const operationError = operation?.scope === scope ? operation.error : null; const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; @@ -70,11 +78,20 @@ export function DevicePanel(props: { const grouped = groupDevices(state); + const startOperation = (pending: DeviceSummary | null) => { + const started = { scope, pending, error: null }; + setOperation(started); + // Returning to the same thread can start a newer operation before this one settles. + return (error: string | null) => + setOperation((current) => + current === started ? { ...started, pending: null, error } : current, + ); + }; + const selectDevice = async (value: string) => { const device = state.devices.find((candidate) => deviceKey(candidate) === value); if (!device) return; - setOperationError(null); - setPendingDevice(device); + const settleOperation = startOperation(device); const result = await open({ environmentId, input: { @@ -84,15 +101,17 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else + if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause)); + else { + // The server opened the device for the starting thread, so its tab opens there. useRightPanelStore.getState().openDevice(props.threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, }); - setPendingDevice(null); + settleOperation(null); + } }; const floatActive = () => { @@ -113,7 +132,7 @@ export function DevicePanel(props: { return; } if (!activeSession) return; - setOperationError(null); + const settleOperation = startOperation(null); void close({ environmentId, input: { @@ -123,7 +142,7 @@ export function DevicePanel(props: { shutdown: powerOff, }, }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause)); else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); }); }; @@ -180,7 +199,7 @@ export function DevicePanel(props: { size="icon-xs" variant="ghost" aria-label="Dismiss device error" - onClick={() => setOperationError(null)} + onClick={() => setOperation(null)} >