diff --git a/apps/web/src/components/device/DevicePanel.test.tsx b/apps/web/src/components/device/DevicePanel.test.tsx new file mode 100644 index 000000000000..9a52a938b22c --- /dev/null +++ b/apps/web/src/components/device/DevicePanel.test.tsx @@ -0,0 +1,268 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { 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: () => null })); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => children, +})); + +// Same thread id in another environment: the surface id collides, so the panel +// stays mounted across the switch, as it does under ChatView's surface key. +const picking = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +const next = { environmentId: EnvironmentId.make("environment-b"), threadId: picking.threadId }; +// Another thread in the same environment, as when ChatView keeps the panel for a shared surface. +const sibling = { environmentId: picking.environmentId, threadId: ThreadId.make("thread-2") }; +const switches = [ + ["the same thread id in another environment", next], + ["another thread in the same environment", sibling], +] as const; +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 render = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined} + /> +); +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (renderer: ReactTestRenderer) => + act(async () => { + renderer.root.findByProps({ "aria-label": "Start Phone" }).props.onClick(); + }); +const errors = (renderer: ReactTestRenderer) => renderer.root.findAllByProps({ role: "alert" }); +const startButtons = (renderer: ReactTestRenderer) => + renderer.root.findAllByProps({ "aria-label": "Start Phone" }); +const pressPowerOff = (renderer: ReactTestRenderer) => + act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("DevicePanel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + expect(mocks.open).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", platform: "ios" }, + }); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + }); + + it.each(switches)( + "keeps the picker usable after switching to %s while a pick is pending", + async (_, target) => { + mocks.open.mockReturnValue(deferred().promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(target))); + + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }, + ); + + it("keeps the next thread's own pick pending when an earlier pick settles", async () => { + const first = deferred(); + const second = deferred(); + mocks.open.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(sibling))); + await startPhone(renderer); + await act(async () => first.settle(opened)); + + expect(startButtons(renderer)).toHaveLength(0); + await act(async () => second.settle(opened)); + expect(startButtons(renderer).length).toBeGreaterThan(0); + expect(mocks.openDevice.mock.calls.map(([threadRef]) => threadRef)).toEqual([picking, sibling]); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it.each(switches)( + "hides a failed pick that settles after switching to %s and back", + async (_, target) => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(target))); + await act(async () => renderer.update(render(picking))); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(renderer)).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }, + ); + + it.each(switches)("does not carry an operation error to %s", async (_, target) => { + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + expect(errors(renderer)).toHaveLength(1); + await act(async () => renderer.update(render(target))); + + expect(errors(renderer)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + const renderer = await act(async () => create(render(picking, streaming))); + + await pressPowerOff(renderer); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await act(async () => renderer.update(render(next, streaming))); + await act(async () => powerOff.settle(opened)); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); + + it("shows a failed power-off only in the thread that started it", async () => { + const late = deferred(); + mocks.close + .mockResolvedValueOnce({ _tag: "Failure", cause: new Error("boom") }) + .mockReturnValueOnce(late.promise); + const renderer = await act(async () => create(render(picking, streaming))); + + await pressPowerOff(renderer); + expect(errors(renderer)).toHaveLength(1); + + await pressPowerOff(renderer); + await act(async () => renderer.update(render(sibling, streaming))); + await act(async () => late.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(renderer)).toHaveLength(0); + expect(mocks.closeSurface).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 7e6804043f44..bee404728c96 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -6,7 +6,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { Smartphone, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; @@ -45,6 +45,15 @@ export function DevicePanel(props: { const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); const [pendingDevice, setPendingDevice] = useState(null); + // Operation state belongs to one scoped thread; drop it when the panel moves on. + const scopeKey = `${environmentId}\u0000${threadId}`; + const [operationScopeKey, setOperationScopeKey] = useState(scopeKey); + if (operationScopeKey !== scopeKey) { + setOperationScopeKey(scopeKey); + setOperationError(null); + setPendingDevice(null); + } + const isCurrentScope = useScopeGuard(scopeKey); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; @@ -79,6 +88,7 @@ export function DevicePanel(props: { if (!device) return; setOperationError(null); setPendingDevice(device); + const stillCurrent = isCurrentScope(); try { const result = await open({ environmentId, @@ -89,16 +99,20 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else + // The server opened the device for the starting thread, so its tab opens + // there even after a switch; only this panel's own state is scope-guarded. + if (result._tag === "Failure") { + if (stillCurrent()) 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); + if (stillCurrent()) setPendingDevice(null); } }; @@ -122,6 +136,7 @@ export function DevicePanel(props: { } if (!activeSession) return; setOperationError(null); + const stillCurrent = isCurrentScope(); void close({ environmentId, input: { @@ -131,8 +146,11 @@ export function DevicePanel(props: { shutdown: powerOff, }, }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + } }); }; @@ -313,6 +331,27 @@ export function DevicePanel(props: { ); } +/** + * Binds async work to the committed scope. Call the returned function when the + * work starts; the check it returns is false once the panel moved to another + * thread (even back again) or unmounted, so late results are dropped. + */ +function useScopeGuard(scopeKey: string) { + const scopeRef = useRef<{ readonly key: string } | null>(null); + useLayoutEffect(() => { + // A fresh token per commit of a scope, so returning to a thread is a new scope. + const scope = { key: scopeKey }; + scopeRef.current = scope; + return () => { + scopeRef.current = null; + }; + }, [scopeKey]); + return () => { + const started = scopeRef.current; + return () => started !== null && scopeRef.current === started; + }; +} + function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) {