Skip to content

Commit 91fb672

Browse files
fix(web): keep device results in the thread that started them
One Device panel instance stays mounted across a thread switch, so a device start or power-off that settled after the switch left its "Starting device…" spinner or its error in the next thread, and still opened or closed a tab in the thread it started from. The panel now resets its operation state when its thread changes and drops a pick or power-off that settles after it moved to another thread (even back again) or unmounted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent c568d16 commit 91fb672

2 files changed

Lines changed: 258 additions & 2 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import {
2+
EnvironmentId,
3+
ThreadId,
4+
type DeviceServiceState,
5+
type ScopedThreadRef,
6+
} from "@t3tools/contracts";
7+
import { act, type ReactNode } from "react";
8+
import { create, type ReactTestRenderer } from "react-test-renderer";
9+
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
10+
11+
import type { RightPanelSurface } from "~/rightPanelStore";
12+
13+
import { DevicePanel } from "./DevicePanel";
14+
15+
type CommandResult =
16+
| { _tag: "Success"; value: { hostId: string; deviceId: string } }
17+
| { _tag: "Failure"; cause: unknown };
18+
const mocks = vi.hoisted(() => ({
19+
open: vi.fn<(request: unknown) => Promise<CommandResult>>(),
20+
close: vi.fn<(request: unknown) => Promise<CommandResult>>(),
21+
openDevice: vi.fn(),
22+
closeSurface: vi.fn(),
23+
}));
24+
const phone = {
25+
hostId: "local",
26+
id: "phone",
27+
name: "Phone",
28+
platform: "ios",
29+
version: "iOS 19",
30+
booted: false,
31+
physical: false,
32+
} as const;
33+
const deviceState: DeviceServiceState = {
34+
hosts: [
35+
{
36+
id: "local",
37+
kind: "local",
38+
label: "This Mac",
39+
platforms: [{ platform: "ios", available: true }],
40+
hubInstalled: true,
41+
agentDeviceInstalled: true,
42+
},
43+
],
44+
hostStatus: "ready",
45+
hostStatuses: { local: { status: "ready" } },
46+
devices: [phone],
47+
sessions: [
48+
{
49+
threadId: ThreadId.make("thread-1"),
50+
hostId: "local",
51+
deviceId: "phone",
52+
platform: "ios",
53+
openedAt: "2026-01-01T00:00:00.000Z",
54+
},
55+
],
56+
onboardingCompleted: true,
57+
agentAccessEnabled: true,
58+
hubBasePath: "/api/device-hub",
59+
revision: 1,
60+
};
61+
vi.mock("~/state/device", () => ({
62+
deviceEnvironment: { list: "list", open: "open", close: "close" },
63+
useDeviceState: () => ({ state: deviceState, loaded: true }),
64+
}));
65+
vi.mock("~/state/use-atom-command", () => ({
66+
useAtomCommand: (command: string) =>
67+
command === "open"
68+
? mocks.open
69+
: command === "close"
70+
? mocks.close
71+
: () => Promise.resolve({ _tag: "Success", value: {} }),
72+
}));
73+
vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" }));
74+
vi.mock("~/rightPanelStore", () => ({
75+
useRightPanelStore: {
76+
getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }),
77+
},
78+
}));
79+
vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null }));
80+
vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null }));
81+
vi.mock("~/components/device/DeviceWorkspace", () => ({ DeviceWorkspace: () => null }));
82+
vi.mock("~/components/preview/PreviewPanelShell", () => ({
83+
PreviewPanelShell: ({ children }: { children: ReactNode }) => children,
84+
}));
85+
86+
// Same thread id in another environment: the surface id collides, so the panel
87+
// stays mounted across the switch, as it does under ChatView's surface key.
88+
const picking = {
89+
environmentId: EnvironmentId.make("environment-a"),
90+
threadId: ThreadId.make("thread-1"),
91+
};
92+
const next = { environmentId: EnvironmentId.make("environment-b"), threadId: picking.threadId };
93+
const picker: Extract<RightPanelSurface, { kind: "device" }> = { id: "device", kind: "device" };
94+
const streaming: Extract<RightPanelSurface, { kind: "device" }> = {
95+
id: "device:phone",
96+
kind: "device",
97+
target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" },
98+
};
99+
100+
const render = (threadRef: ScopedThreadRef, surface = picker) => (
101+
<DevicePanel
102+
key={surface.id}
103+
mode="embedded"
104+
threadRef={threadRef}
105+
surface={surface}
106+
visible
107+
onDismissSetup={() => undefined}
108+
/>
109+
);
110+
const deferred = () => {
111+
let settle!: (result: CommandResult) => void;
112+
const promise = new Promise<CommandResult>((resolve) => (settle = resolve));
113+
return { promise, settle };
114+
};
115+
const startPhone = (renderer: ReactTestRenderer) =>
116+
act(async () => {
117+
renderer.root.findByProps({ "aria-label": "Start Phone" }).props.onClick();
118+
});
119+
const errors = (renderer: ReactTestRenderer) => renderer.root.findAllByProps({ role: "alert" });
120+
const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const;
121+
122+
beforeEach(() => {
123+
vi.clearAllMocks();
124+
});
125+
126+
describe("DevicePanel", () => {
127+
it("opens a picked device when the pick settles in the same thread", async () => {
128+
const pick = deferred();
129+
mocks.open.mockReturnValue(pick.promise);
130+
const renderer = await act(async () => create(render(picking)));
131+
132+
await startPhone(renderer);
133+
expect(mocks.open).toHaveBeenCalledWith({
134+
environmentId: picking.environmentId,
135+
input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", platform: "ios" },
136+
});
137+
await act(async () => pick.settle(opened));
138+
139+
expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, {
140+
hostId: "local",
141+
deviceId: "phone",
142+
platform: "ios",
143+
name: "Phone",
144+
});
145+
});
146+
147+
it("keeps the next thread's picker usable while an earlier pick is pending", async () => {
148+
mocks.open.mockReturnValue(deferred().promise);
149+
const renderer = await act(async () => create(render(picking)));
150+
151+
await startPhone(renderer);
152+
await act(async () => renderer.update(render(next)));
153+
154+
expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false);
155+
});
156+
157+
it("drops a pick that settles after the panel moved to a colliding thread", async () => {
158+
const pick = deferred();
159+
mocks.open.mockReturnValue(pick.promise);
160+
const renderer = await act(async () => create(render(picking)));
161+
162+
await startPhone(renderer);
163+
await act(async () => renderer.update(render(next)));
164+
await act(async () => pick.settle(opened));
165+
166+
expect(mocks.openDevice).not.toHaveBeenCalled();
167+
});
168+
169+
it("drops a failed pick that settles after the panel moved, even back again", async () => {
170+
const pick = deferred();
171+
mocks.open.mockReturnValue(pick.promise);
172+
const renderer = await act(async () => create(render(picking)));
173+
174+
await startPhone(renderer);
175+
await act(async () => renderer.update(render(next)));
176+
await act(async () => renderer.update(render(picking)));
177+
await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") }));
178+
179+
expect(errors(renderer)).toHaveLength(0);
180+
expect(mocks.openDevice).not.toHaveBeenCalled();
181+
});
182+
183+
it("does not carry an operation error into another thread", async () => {
184+
mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") });
185+
const renderer = await act(async () => create(render(picking)));
186+
187+
await startPhone(renderer);
188+
expect(errors(renderer)).toHaveLength(1);
189+
await act(async () => renderer.update(render(next)));
190+
191+
expect(errors(renderer)).toHaveLength(0);
192+
});
193+
194+
it("drops a power-off that settles after the panel moved to a colliding thread", async () => {
195+
const powerOff = deferred();
196+
mocks.close.mockReturnValue(powerOff.promise);
197+
const renderer = await act(async () => create(render(picking, streaming)));
198+
199+
await act(async () => {
200+
renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff();
201+
});
202+
expect(mocks.close).toHaveBeenCalledWith({
203+
environmentId: picking.environmentId,
204+
input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true },
205+
});
206+
await act(async () => renderer.update(render(next, streaming)));
207+
await act(async () => powerOff.settle(opened));
208+
209+
expect(mocks.closeSurface).not.toHaveBeenCalled();
210+
});
211+
212+
it("closes the surface when a power-off settles in the same thread", async () => {
213+
mocks.close.mockResolvedValue(opened);
214+
const renderer = await act(async () => create(render(picking, streaming)));
215+
216+
await act(async () => {
217+
renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff();
218+
});
219+
220+
expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id);
221+
});
222+
});

‎apps/web/src/components/device/DevicePanel.tsx‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
ScopedThreadRef,
77
} from "@t3tools/contracts";
88
import { Smartphone, X } from "lucide-react";
9-
import { useEffect, useMemo, useState } from "react";
9+
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1010

1111
import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
1212
import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
@@ -45,6 +45,15 @@ export function DevicePanel(props: {
4545
const close = useAtomCommand(deviceEnvironment.close);
4646
const [operationError, setOperationError] = useState<string | null>(null);
4747
const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(null);
48+
// Operation state belongs to one scoped thread; drop it when the panel moves on.
49+
const scopeKey = `${environmentId}\u0000${threadId}`;
50+
const [operationScopeKey, setOperationScopeKey] = useState(scopeKey);
51+
if (operationScopeKey !== scopeKey) {
52+
setOperationScopeKey(scopeKey);
53+
setOperationError(null);
54+
setPendingDevice(null);
55+
}
56+
const isCurrentScope = useScopeGuard(scopeKey);
4857
const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null;
4958

5059
const hostDisabled = state.hostStatus === "disabled";
@@ -79,6 +88,7 @@ export function DevicePanel(props: {
7988
if (!device) return;
8089
setOperationError(null);
8190
setPendingDevice(device);
91+
const stillCurrent = isCurrentScope();
8292
try {
8393
const result = await open({
8494
environmentId,
@@ -89,6 +99,7 @@ export function DevicePanel(props: {
8999
platform: device.platform,
90100
},
91101
});
102+
if (!stillCurrent()) return;
92103
if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
93104
else
94105
useRightPanelStore.getState().openDevice(props.threadRef, {
@@ -98,7 +109,7 @@ export function DevicePanel(props: {
98109
name: device.name,
99110
});
100111
} finally {
101-
setPendingDevice(null);
112+
if (stillCurrent()) setPendingDevice(null);
102113
}
103114
};
104115

@@ -122,6 +133,7 @@ export function DevicePanel(props: {
122133
}
123134
if (!activeSession) return;
124135
setOperationError(null);
136+
const stillCurrent = isCurrentScope();
125137
void close({
126138
environmentId,
127139
input: {
@@ -131,6 +143,7 @@ export function DevicePanel(props: {
131143
shutdown: powerOff,
132144
},
133145
}).then((result) => {
146+
if (!stillCurrent()) return;
134147
if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause));
135148
else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id);
136149
});
@@ -313,6 +326,27 @@ export function DevicePanel(props: {
313326
);
314327
}
315328

329+
/**
330+
* Binds async work to the committed scope. Call the returned function when the
331+
* work starts; the check it returns is false once the panel moved to another
332+
* thread (even back again) or unmounted, so late results are dropped.
333+
*/
334+
function useScopeGuard(scopeKey: string) {
335+
const scopeRef = useRef<{ readonly key: string } | null>(null);
336+
useLayoutEffect(() => {
337+
// A fresh token per commit of a scope, so returning to a thread is a new scope.
338+
const scope = { key: scopeKey };
339+
scopeRef.current = scope;
340+
return () => {
341+
scopeRef.current = null;
342+
};
343+
}, [scopeKey]);
344+
return () => {
345+
const started = scopeRef.current;
346+
return () => started !== null && scopeRef.current === started;
347+
};
348+
}
349+
316350
function groupDevices(state: DeviceServiceState) {
317351
const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = [];
318352
for (const platform of ["ios", "android"] as const) {

0 commit comments

Comments
 (0)