From f4da8ced52b362d0f736fd7135fa283eff568579 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 19:26:54 +0000 Subject: [PATCH 1/2] fix(web): device tools keep the typed text when a command fails The device tools' Open URL, Launch app and iOS push fields cleared on every settlement of their command, so a failed command showed the error and also wiped the text the user typed. useDeviceControls' act now resolves true only when the action succeeds, and false on failure or when it skips a busy or unavailable device. SubmitRow clears only on true, and the push row's no-app fallback resolves false. The other rows ignore the result, so their prop types widen to Promise. Co-Authored-By: Claude Opus 5.5 --- .../src/components/device/DeviceToolsPanel.tsx | 18 ++++++++++-------- .../device/useDeviceControls.browser.test.tsx | 14 ++++++++------ .../src/components/device/useDeviceControls.ts | 9 +++++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/device/DeviceToolsPanel.tsx b/apps/web/src/components/device/DeviceToolsPanel.tsx index 23c5d0097082c..0d7c5b9f763e8 100644 --- a/apps/web/src/components/device/DeviceToolsPanel.tsx +++ b/apps/web/src/components/device/DeviceToolsPanel.tsx @@ -312,7 +312,7 @@ export function DeviceToolsPanel(props: { onSubmit={(payload) => foregroundApp ? act({ type: "sendPush", appId: foregroundApp.id, payload }) - : Promise.resolve() + : Promise.resolve(false) } /> {!foregroundApp ? ( @@ -349,7 +349,7 @@ function SwitchRow(props: { readonly label: string; readonly checked: boolean | undefined; readonly disabled: boolean; - readonly onChange: (value: boolean) => Promise; + readonly onChange: (value: boolean) => Promise; }) { return ( @@ -370,7 +370,7 @@ function ChoiceSelect(props: { readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; readonly disabled: boolean; readonly placeholder?: string; - readonly onChange: (value: V) => Promise; + readonly onChange: (value: V) => Promise; }) { const current = props.options.find((option) => option.value === props.value); return ( @@ -405,13 +405,15 @@ function SubmitRow(props: { readonly placeholder: string; readonly action: string; readonly disabled: boolean; - readonly onSubmit: (value: string) => Promise; + readonly onSubmit: (value: string) => Promise; }) { const [value, setValue] = useState(""); const submit = () => { const trimmed = value.trim(); if (!trimmed) return; - void props.onSubmit(trimmed).then(() => setValue("")); + void props.onSubmit(trimmed).then((sent) => { + if (sent) setValue(""); + }); }; return (
Promise; - readonly onClear: () => Promise; + readonly onSet: (latitude: number, longitude: number) => Promise; + readonly onClear: () => Promise; }) { const [latitude, setLatitude] = useState(""); const [longitude, setLongitude] = useState(""); @@ -541,7 +543,7 @@ function PermissionsSection(props: { appId: string, permission: DevicePermission, decision: "grant" | "revoke" | "reset", - ) => Promise; + ) => Promise; }) { const [appId, setAppId] = useState(""); const [permission, setPermission] = useState("camera"); diff --git a/apps/web/src/components/device/useDeviceControls.browser.test.tsx b/apps/web/src/components/device/useDeviceControls.browser.test.tsx index 5633369dc1cb2..85c484bfd520c 100644 --- a/apps/web/src/components/device/useDeviceControls.browser.test.tsx +++ b/apps/web/src/components/device/useDeviceControls.browser.test.tsx @@ -116,21 +116,23 @@ it("serializes rapid actions and retains confirmed settings while pending or aft await vi.waitFor(() => expect(controls.detail?.settings.appearance).toBe("light")); const response = deferred(); action.mockReturnValueOnce(response.promise); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); - await controls.act({ type: "setAppearance", value: "light" }); + expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false); }); expect(action).toHaveBeenCalledOnce(); expect(controls.disabled).toBe(true); expect(controls.detail?.settings.appearance).toBe("light"); await act(async () => { response.resolve(snapshot("dark")); - await pending; + expect(await pending).toBe(true); }); expect(controls.detail?.settings.appearance).toBe("dark"); action.mockResolvedValueOnce({ _tag: "Failure", cause: offline }); - await act(async () => controls.act({ type: "setAppearance", value: "light" })); + await act(async () => { + expect(await controls.act({ type: "setAppearance", value: "light" })).toBe(false); + }); expect(controls.detail?.settings.appearance).toBe("dark"); expect(controls.error).toBe("Device offline"); expect(controls.disabled).toBe(false); @@ -142,7 +144,7 @@ it("confirms settings again after a command sent before the panel reopened", asy await vi.waitFor(() => expect(controls.detail).not.toBeNull()); const response = deferred(); action.mockReturnValueOnce(response.promise); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); @@ -174,7 +176,7 @@ it("does not let a read sent before an action overwrite the action's answer", as await screen.rerender(view(true)); await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)); action.mockResolvedValueOnce(snapshot("dark")); - let pending!: Promise; + let pending!: Promise; await act(async () => { pending = controls.act({ type: "setAppearance", value: "dark" }); }); diff --git a/apps/web/src/components/device/useDeviceControls.ts b/apps/web/src/components/device/useDeviceControls.ts index 7b17bc4a624e1..31e67b8f3824f 100644 --- a/apps/web/src/components/device/useDeviceControls.ts +++ b/apps/web/src/components/device/useDeviceControls.ts @@ -39,7 +39,7 @@ export function useDeviceControls(options: { const available = detail !== null && visible; const act = async (body: ActionBody) => { - if (busy.current || !available) return; + if (busy.current || !available) return false; busy.current = true; setPending(true); setView((current) => ({ ...current, error: null })); @@ -47,13 +47,14 @@ export function useDeviceControls(options: { environmentId, input: { hostId, deviceId, ...body } as DeviceActionInput, }) - .then((result) => + .then((result) => { setView((current) => result._tag === "Success" ? { detail: result.value, error: null } : { ...current, error: formatEnvironmentQueryError(result.cause) }, - ), - ) + ); + return result._tag === "Success"; + }) .finally(() => { busy.current = false; setPending(false); From 467f1ed62fd20b5b36e7763060b7a1fe7335e647 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 20:56:35 +0000 Subject: [PATCH 2/2] test(web): device tools keep the typed URL until a command succeeds Cover SubmitRow clearing the Open URL field only when the device command reports success, so a failed command keeps what the user typed. Co-Authored-By: Claude Opus 5.5 --- .../device/DeviceToolsPanel.browser.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx diff --git a/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx b/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx new file mode 100644 index 0000000000000..d1ca0d781e26a --- /dev/null +++ b/apps/web/src/components/device/DeviceToolsPanel.browser.test.tsx @@ -0,0 +1,54 @@ +import type { DeviceSummary } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { act, render } from "../../../test/render"; +import { DeviceToolsPanel } from "./DeviceToolsPanel"; +import type { DeviceControls } from "./useDeviceControls"; + +const device: DeviceSummary = { + hostId: "local", + id: "emulator", + name: "Pixel", + platform: "android", + version: "Android 15", + booted: true, + physical: false, +}; + +it("keeps a typed URL until opening it succeeds", async () => { + const run = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const controls: DeviceControls = { + detail: null, + pending: false, + error: null, + act: run, + disabled: false, + foregroundApp: null, + }; + await using screen = await render( + {}} + onClose={() => {}} + />, + ); + const url = "myapp://settings"; + const field = screen.getByPlaceholder("https://… or myapp://"); + const open = screen.getByRole("button", { name: "Open" }); + + await userEvent.fill(field, url); + await userEvent.click(open); + await act(() => run.mock.results[0]?.value); + await expect.element(field).toHaveValue(url); + + await userEvent.click(open); + await expect.element(field).toHaveValue(""); +});