Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
920770b
refactor(web): register the Diff side panel
saphid Oct 2, 2026
e1ae808
refactor(web): register the Preview side panel
github-actions[bot] Oct 4, 2026
4851ae8
perf(web): keep the panel host stable across chat view renders
github-actions[bot] Oct 7, 2026
30972c6
fix(web): send a late annotation only to the thread it was picked in
github-actions[bot] Oct 7, 2026
52a78e7
fix(web): lend the annotation sender only after the chat view commits
github-actions[bot] Oct 7, 2026
7be7fb5
refactor(web): move the thread terminal panel and drawer out of ChatView
github-actions[bot] Oct 4, 2026
a0ea164
refactor(web): open the right-panel terminal through the panel host
github-actions[bot] Oct 4, 2026
02d7fe8
fix(web): keep a local-checkout terminal launch off the thread's work…
github-actions[bot] Oct 6, 2026
ca03345
fix(web): keep a terminal the server opened on the checkout off the t…
github-actions[bot] Oct 7, 2026
4b7aa2e
fix(web): keep device results in the thread that started them
github-actions[bot] Oct 4, 2026
6d97acc
Merge upstream main into stack/04a-device-late-result
github-actions[bot] Oct 14, 2026
579ca53
chore: drop the stacked panel-host commits from the device fix
github-actions[bot] Oct 14, 2026
24adce2
test(web): cover same-environment switches and overlapping device picks
github-actions[bot] Oct 14, 2026
40b8c25
merge: keep late device results scoped with current main
saphid Oct 17, 2026
efc84aa
fix(web): keep device scope tests compatible with browser runs
github-actions[bot] Oct 17, 2026
81d4682
Merge remote-tracking branch 'origin/main' into stack/04a-device-late…
juliusmarminge Oct 19, 2026
9c7d5b2
fix(web): device operations stay with the thread that started them
juliusmarminge Oct 19, 2026
2532cbd
test(web): cover scoped device results with six focused cases
juliusmarminge Oct 19, 2026
b8ccfd0
fix(web): keep stale device results from settling newer operations
juliusmarminge Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 268 additions & 0 deletions apps/web/src/components/device/DevicePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Check failure on line 8 in apps/web/src/components/device/DevicePanel.test.tsx

View workflow job for this annotation

GitHub Actions / Test Web

[unit] src/components/device/DevicePanel.test.tsx

Error: Cannot find package 'react-test-renderer' imported from /home/runner/_work/t3code/t3code/apps/web/src/components/device/DevicePanel.test.tsx ❯ reviveInvokeError ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:542:14 ❯ Object.invoke ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:558:33 ❯ VitestModuleRunner.getModuleInformation ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1212:7 ❯ request ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1229:83 ❯ src/components/device/DevicePanel.test.tsx:8:1 ❯ VitestModuleRunner.directRequest ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1272:59 ❯ VitestModuleRunner.cachedRequest ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1179:73 ❯ VitestModuleRunner.import ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1134:10 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }
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<CommandResult>>(),
close: vi.fn<(request: unknown) => Promise<CommandResult>>(),
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<RightPanelSurface, { kind: "device" }> = { id: "device", kind: "device" };
const streaming: Extract<RightPanelSurface, { kind: "device" }> = {
id: "device:phone",
kind: "device",
target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" },
};

const render = (threadRef: ScopedThreadRef, surface = picker) => (
<DevicePanel
key={surface.id}
mode="embedded"
threadRef={threadRef}
surface={surface}
visible
onDismissSetup={() => undefined}
/>
);
const deferred = () => {
let settle!: (result: CommandResult) => void;
const promise = new Promise<CommandResult>((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();
});
});
82 changes: 62 additions & 20 deletions apps/web/src/components/device/DevicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
ScopedThreadRef,
} from "@t3tools/contracts";
import { Smartphone, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";

import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore";
Expand Down Expand Up @@ -44,6 +44,15 @@ export function DevicePanel(props: {
const close = useAtomCommand(deviceEnvironment.close);
const [operationError, setOperationError] = useState<string | null>(null);
const [pendingDevice, setPendingDevice] = useState<DeviceSummary | null>(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";
Expand Down Expand Up @@ -75,24 +84,32 @@ export function DevicePanel(props: {
if (!device) return;
setOperationError(null);
setPendingDevice(device);
const result = await open({
environmentId,
input: {
threadId,
hostId: device.hostId,
deviceId: device.id,
platform: device.platform,
},
});
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,
const stillCurrent = isCurrentScope();
try {
const result = await open({
environmentId,
input: {
threadId,
hostId: device.hostId,
deviceId: device.id,
platform: device.platform,
},
});
setPendingDevice(null);
// 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, {
Comment thread
saphid marked this conversation as resolved.
Outdated
hostId: result.value.hostId,
deviceId: result.value.deviceId,
platform: device.platform,
name: device.name,
});
}
} finally {
if (stillCurrent()) setPendingDevice(null);
}
};

const floatActive = () => {
Expand All @@ -114,6 +131,7 @@ export function DevicePanel(props: {
}
if (!activeSession) return;
setOperationError(null);
const stillCurrent = isCurrentScope();
void close({
environmentId,
input: {
Expand All @@ -123,8 +141,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);
}
});
};

Expand Down Expand Up @@ -300,6 +321,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) {
Expand Down
Loading