From 496cdc443af79d86e20af23133209d5c9cb43625 Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:15:12 -0400 Subject: [PATCH 1/9] Show a transient zoom indicator in the desktop app Page zoom changes now surface a small toolbar under the top-right chrome with the current percent, zoom out/in, and reset. It hides two seconds after the last change and stays while hovered. The preload reads and sets zoom through webFrame, so there is no new IPC or main-process code. The contract adds optional onZoomChange and zoom members for older desktop builds. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/app/src/App.tsx | 2 + .../layout/DesktopZoomIndicator.test.tsx | 90 ++++++++++++ .../layout/DesktopZoomIndicator.tsx | 132 ++++++++++++++++++ apps/desktop/src/preload.ts | 37 +++++ packages/desktop-contract/src/info.ts | 7 + 5 files changed, 268 insertions(+) create mode 100644 apps/app/src/components/layout/DesktopZoomIndicator.test.tsx create mode 100644 apps/app/src/components/layout/DesktopZoomIndicator.tsx diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index fd8098a9ad5..4a24a5cb969 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -66,6 +66,7 @@ import { } from "./lib/route-paths"; import { AppCommandProvider } from "./components/commands/AppCommandProvider"; import { WindowFindHost } from "./components/layout/WindowFindHost"; +import { DesktopZoomIndicator } from "./components/layout/DesktopZoomIndicator"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay"; import { AppUpdateHost } from "./components/app-update/AppUpdateHost"; @@ -443,6 +444,7 @@ export function App() { } /> + diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx new file mode 100644 index 00000000000..b0146c9cdea --- /dev/null +++ b/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DesktopZoomIndicator } from "./DesktopZoomIndicator"; + +describe("DesktopZoomIndicator", () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + delete window.bbDesktop; + }); + + function setup() { + const changeListener = { current: (_factor: number) => undefined }; + const unsubscribe = vi.fn(); + const zoom = vi.fn(); + window.bbDesktop = { + onZoomChange: vi.fn((callback) => { + changeListener.current = callback; + return unsubscribe; + }), + zoom, + } as unknown as Window["bbDesktop"]; + const view = render(); + return { + emitZoomChange: (factor: number) => changeListener.current(factor), + unsubscribe, + view, + zoom, + }; + } + + it("stays unrendered until a zoom change", () => { + setup(); + + expect(screen.queryByRole("toolbar", { name: "Zoom" })).toBeNull(); + }); + + it("shows the changed percentage and dispatches zoom commands", () => { + const { emitZoomChange, zoom } = setup(); + act(() => emitZoomChange(1.25)); + + expect(screen.getByText("125%")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Zoom out" })); + fireEvent.click(screen.getByRole("button", { name: "Zoom in" })); + fireEvent.click(screen.getByRole("button", { name: "Reset zoom" })); + expect(zoom.mock.calls).toEqual([["out"], ["in"], ["reset"]]); + }); + + it("disables Reset at 100 percent", () => { + const { emitZoomChange } = setup(); + act(() => emitZoomChange(1)); + + expect( + (screen.getByRole("button", { name: "Reset zoom" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("auto-hides after two seconds", () => { + vi.useFakeTimers(); + const { emitZoomChange } = setup(); + act(() => emitZoomChange(1.25)); + act(() => vi.advanceTimersByTime(2000)); + + expect(screen.queryByRole("toolbar", { name: "Zoom" })).toBeNull(); + }); + + it("stays visible while hovered", () => { + vi.useFakeTimers(); + const { emitZoomChange } = setup(); + act(() => emitZoomChange(1.25)); + fireEvent.pointerEnter(screen.getByRole("toolbar", { name: "Zoom" })); + act(() => vi.advanceTimersByTime(2500)); + + expect(screen.getByRole("toolbar", { name: "Zoom" })).toBeTruthy(); + }); + + it("renders nothing without zoom notifications and unsubscribes", () => { + window.bbDesktop = {} as Window["bbDesktop"]; + const view = render(); + expect(view.container.childElementCount).toBe(0); + view.unmount(); + + const { unsubscribe, view: subscribedView } = setup(); + subscribedView.unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.tsx new file mode 100644 index 00000000000..8c93426d850 --- /dev/null +++ b/apps/app/src/components/layout/DesktopZoomIndicator.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers"; +import { + getBbDesktopInfo, + MACOS_APP_REGION_NO_DRAG_CLASS, +} from "@/lib/bb-desktop"; + +const HIDE_DELAY_MS = 2000; + +export function DesktopZoomIndicator() { + const desktop = getBbDesktopInfo(); + const onZoomChange = desktop?.onZoomChange; + const zoom = desktop?.zoom; + const [zoomFactor, setZoomFactor] = useState(null); + const hideTimer = useRef | null>(null); + const interaction = useRef({ focused: false, hovered: false }); + + useEffect(() => { + if (onZoomChange === undefined) { + return; + } + return onZoomChange((factor) => { + setZoomFactor(factor); + if (hideTimer.current !== null) { + clearTimeout(hideTimer.current); + } + if (!interaction.current.focused && !interaction.current.hovered) { + hideTimer.current = setTimeout(() => setZoomFactor(null), HIDE_DELAY_MS); + } + }); + }, [onZoomChange]); + + useEffect( + () => () => { + if (hideTimer.current !== null) { + clearTimeout(hideTimer.current); + } + }, + [], + ); + + if (zoomFactor === null || onZoomChange === undefined) { + return null; + } + + const scheduleHide = () => { + if (hideTimer.current !== null) { + clearTimeout(hideTimer.current); + } + hideTimer.current = setTimeout(() => setZoomFactor(null), HIDE_DELAY_MS); + }; + + return ( +
+
{ + interaction.current.hovered = true; + if (hideTimer.current !== null) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }} + onPointerLeave={() => { + interaction.current.hovered = false; + if (!interaction.current.focused) { + scheduleHide(); + } + }} + onFocus={() => { + interaction.current.focused = true; + if (hideTimer.current !== null) { + clearTimeout(hideTimer.current); + hideTimer.current = null; + } + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + interaction.current.focused = false; + if (!interaction.current.hovered) { + scheduleHide(); + } + } + }} + > + + {Math.round(zoomFactor * 100)}% + + + +
+
+ ); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 503a2876431..66255e2a5c0 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -20,6 +20,7 @@ import { type BbDesktopBrowserControlState, type BbDesktopBrowserRevealRequest, bbDesktopInfoSchema, + bbDesktopZoomCommandSchema, bbDesktopWindowStateSchema, type BbDesktopApi, type BbDesktopAppCommandHandler, @@ -42,6 +43,7 @@ import { type BbDesktopTheme, type BbDesktopWindowState, type BbDesktopWindowStateChangeHandler, + type BbDesktopZoomCommand, } from "@bb/desktop-contract"; import { BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, @@ -205,6 +207,25 @@ const browserFindResultListeners = new Set(); const closeWindowRequestListeners = new Set(); const openNewTabListeners = new Set(); +const zoomListeners = new Set<(zoomFactor: number) => void>(); +let lastZoomFactor = webFrame.getZoomFactor(); + +function notifyZoomChangeIfChanged(): void { + const zoomFactor = webFrame.getZoomFactor(); + if (zoomFactor === lastZoomFactor) { + return; + } + lastZoomFactor = zoomFactor; + for (const listener of zoomListeners) { + listener(zoomFactor); + } +} + +if (typeof window !== "undefined" && typeof document !== "undefined") { + window.addEventListener("resize", notifyZoomChangeIfChanged, { + passive: true, + }); +} function addListener(listeners: Set, listener: T): () => void { listeners.add(listener); @@ -407,6 +428,22 @@ const bbDesktopApi: BbDesktopApi = { ): BbDesktopInfoUnsubscribe { return addListener(windowStateListeners, listener); }, + onZoomChange(listener): BbDesktopInfoUnsubscribe { + return addListener(zoomListeners, listener); + }, + zoom(command: BbDesktopZoomCommand): void { + const parsed = bbDesktopZoomCommandSchema.safeParse(command); + if (!parsed.success) { + return; + } + if (parsed.data === "reset") { + webFrame.setZoomLevel(0); + return; + } + webFrame.setZoomLevel( + webFrame.getZoomLevel() + (parsed.data === "in" ? 0.5 : -0.5), + ); + }, onOpenNewTab(listener): BbDesktopInfoUnsubscribe { return addListener(openNewTabListeners, listener); }, diff --git a/packages/desktop-contract/src/info.ts b/packages/desktop-contract/src/info.ts index b44c7755bea..a80bbd05a7e 100644 --- a/packages/desktop-contract/src/info.ts +++ b/packages/desktop-contract/src/info.ts @@ -36,6 +36,9 @@ export type BbDesktopWindowState = z.infer; export const bbDesktopThemeSchema = z.enum(["system", "light", "dark"]); export type BbDesktopTheme = z.infer; +export const bbDesktopZoomCommandSchema = z.enum(["in", "out", "reset"]); +export type BbDesktopZoomCommand = z.infer; + export type BbDesktopInfoChangeHandler = (info: BbDesktopInfo) => void; export type BbDesktopInfoUnsubscribe = () => void; export type BbDesktopWindowStateChangeHandler = ( @@ -55,6 +58,10 @@ export interface BbDesktopApi extends BbDesktopInfo { onWindowStateChange?( listener: BbDesktopWindowStateChangeHandler, ): BbDesktopInfoUnsubscribe; + onZoomChange?( + listener: (zoomFactor: number) => void, + ): BbDesktopInfoUnsubscribe; + zoom?(command: BbDesktopZoomCommand): void; onOpenNewTab?(listener: BbDesktopOpenNewTabHandler): BbDesktopInfoUnsubscribe; onAppCommand?(listener: BbDesktopAppCommandHandler): BbDesktopInfoUnsubscribe; onCloseWindowRequest?( From 3e28f0ca8e9bba394c3de767256e233b4024339d Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:16:37 -0400 Subject: [PATCH 2/9] Pause the zoom indicator's auto-hide on hover only Clicking a zoom button left it focused, and the focus pause then held the toolbar open after the pointer left. Hover is the only pause now, behind a single restartHideTimer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../layout/DesktopZoomIndicator.tsx | 52 ++++++------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.tsx index 8c93426d850..d85c9d13ddf 100644 --- a/apps/app/src/components/layout/DesktopZoomIndicator.tsx +++ b/apps/app/src/components/layout/DesktopZoomIndicator.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers"; @@ -15,7 +15,16 @@ export function DesktopZoomIndicator() { const zoom = desktop?.zoom; const [zoomFactor, setZoomFactor] = useState(null); const hideTimer = useRef | null>(null); - const interaction = useRef({ focused: false, hovered: false }); + const hovered = useRef(false); + + const restartHideTimer = useCallback(() => { + if (hideTimer.current !== null) { + clearTimeout(hideTimer.current); + } + if (!hovered.current) { + hideTimer.current = setTimeout(() => setZoomFactor(null), HIDE_DELAY_MS); + } + }, []); useEffect(() => { if (onZoomChange === undefined) { @@ -23,14 +32,9 @@ export function DesktopZoomIndicator() { } return onZoomChange((factor) => { setZoomFactor(factor); - if (hideTimer.current !== null) { - clearTimeout(hideTimer.current); - } - if (!interaction.current.focused && !interaction.current.hovered) { - hideTimer.current = setTimeout(() => setZoomFactor(null), HIDE_DELAY_MS); - } + restartHideTimer(); }); - }, [onZoomChange]); + }, [onZoomChange, restartHideTimer]); useEffect( () => () => { @@ -45,13 +49,6 @@ export function DesktopZoomIndicator() { return null; } - const scheduleHide = () => { - if (hideTimer.current !== null) { - clearTimeout(hideTimer.current); - } - hideTimer.current = setTimeout(() => setZoomFactor(null), HIDE_DELAY_MS); - }; - return (
{ - interaction.current.hovered = true; + hovered.current = true; if (hideTimer.current !== null) { clearTimeout(hideTimer.current); hideTimer.current = null; } }} onPointerLeave={() => { - interaction.current.hovered = false; - if (!interaction.current.focused) { - scheduleHide(); - } - }} - onFocus={() => { - interaction.current.focused = true; - if (hideTimer.current !== null) { - clearTimeout(hideTimer.current); - hideTimer.current = null; - } - }} - onBlur={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - interaction.current.focused = false; - if (!interaction.current.hovered) { - scheduleHide(); - } - } + hovered.current = false; + restartHideTimer(); }} > From 3a5caf5692ef867b501cec87f2d2683a377bc110 Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:41:17 -0400 Subject: [PATCH 3/9] Wait for the lock read in the server-moved confirmation test The test asserted confirmMove and onMove after a fixed 20ms settle, but both follow a real file read of the lock. On a loaded CI runner the read can outlast the settle, failing with "expected vi.fn() to be called once, but got 0 times". Wait for those calls instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/test/server-moved.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/test/server-moved.test.ts b/apps/desktop/test/server-moved.test.ts index 3f7c230ff72..31dfd8abb82 100644 --- a/apps/desktop/test/server-moved.test.ts +++ b/apps/desktop/test/server-moved.test.ts @@ -579,7 +579,9 @@ describe("createServerMovedWatcher", () => { await writeServerMovedFile(harness.dataDir, movedFile()); harness.watcher.start(); await harness.timers.flush(); - expect(harness.confirmMove).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(harness.confirmMove).toHaveBeenCalledOnce(); + }); await writeServerMovedFile( harness.dataDir, @@ -596,6 +598,9 @@ describe("createServerMovedWatcher", () => { expect(harness.timers.pendingCount()).toBe(1); await harness.timers.flush(); + await vi.waitFor(() => { + expect(harness.onMove).toHaveBeenCalledOnce(); + }); expect(harness.confirmMove).toHaveBeenCalledTimes(2); expect(harness.onMove).toHaveBeenCalledExactlyOnceWith({ ...CONNECT_MOVE, From 0c05b7babe960623d47773fb6da5de05f52389f0 Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:41:50 -0400 Subject: [PATCH 4/9] Route zoom indicator commands through the main process webFrame.setZoomLevel sets a temporary per-frame zoom, while the View menu roles set the per-origin level on webContents. A reload drops the temporary level, so zooming with the indicator then pressing Cmd+R fell back to the stale level (1.2x -> 1.0x). The preload now sends the validated command over IPC, and main applies it to webContents.zoomLevel exactly like the built-in roles. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/desktop-update-ipc.ts | 1 + apps/desktop/src/main.ts | 13 +++++++++++++ apps/desktop/src/preload.ts | 9 ++------- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/desktop-update-ipc.ts b/apps/desktop/src/desktop-update-ipc.ts index 900ce6f24ff..19ac09391f1 100644 --- a/apps/desktop/src/desktop-update-ipc.ts +++ b/apps/desktop/src/desktop-update-ipc.ts @@ -3,6 +3,7 @@ export const BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL = export const BB_DESKTOP_GET_INFO_CHANNEL = "bb-desktop:get-info"; export const BB_DESKTOP_INFO_CHANGED_CHANNEL = "bb-desktop:info-changed"; export const BB_DESKTOP_INSTALL_UPDATE_CHANNEL = "bb-desktop:install-update"; +export const BB_DESKTOP_ZOOM_COMMAND_CHANNEL = "bb-desktop:zoom-command"; export const BB_DESKTOP_SET_THEME_CHANNEL = "bb-desktop:set-theme"; export const BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL = "bb-desktop:open-external-url"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0649bcd955b..cb4a4578379 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -184,6 +184,7 @@ import { BB_DESKTOP_INSTALL_UPDATE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, + BB_DESKTOP_ZOOM_COMMAND_CHANNEL, } from "./desktop-update-ipc.js"; import { BB_DESKTOP_APP_COMMAND_CHANNEL, @@ -217,6 +218,7 @@ import { import { createDesktopBrowserBrokerClient } from "./desktop-browser-broker-client.js"; import { bbDesktopBrowserTabRefSchema, + bbDesktopZoomCommandSchema, bbDesktopWindowFindRequestSchema, } from "@bb/desktop-contract"; import { @@ -1973,6 +1975,17 @@ async function finishQuit(): Promise { } function registerDesktopUpdateIpc(): void { + ipcMain.on(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, (event, payload: unknown) => { + const parsed = bbDesktopZoomCommandSchema.safeParse(payload); + const webContents = resolveApplicationWindow(event.sender)?.webContents; + if (!parsed.success || webContents === undefined) { + return; + } + webContents.zoomLevel = + parsed.data === "reset" + ? 0 + : webContents.zoomLevel + (parsed.data === "in" ? 0.5 : -0.5); + }); ipcMain.handle(BB_DESKTOP_GET_INFO_CHANNEL, () => { return getCurrentDesktopInfo(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 66255e2a5c0..44a90494544 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -52,6 +52,7 @@ import { BB_DESKTOP_INSTALL_UPDATE_CHANNEL, BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, BB_DESKTOP_SET_THEME_CHANNEL, + BB_DESKTOP_ZOOM_COMMAND_CHANNEL, } from "./desktop-update-ipc.js"; import { BB_DESKTOP_BROWSER_ATTACH_CHANNEL, @@ -436,13 +437,7 @@ const bbDesktopApi: BbDesktopApi = { if (!parsed.success) { return; } - if (parsed.data === "reset") { - webFrame.setZoomLevel(0); - return; - } - webFrame.setZoomLevel( - webFrame.getZoomLevel() + (parsed.data === "in" ? 0.5 : -0.5), - ); + ipcRenderer.send(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, parsed.data); }, onOpenNewTab(listener): BbDesktopInfoUnsubscribe { return addListener(openNewTabListeners, listener); From 33bbc0f147566b8a8cf1e147559a6ce696c69017 Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:53:45 -0400 Subject: [PATCH 5/9] Trim redundant zoom indicator code Main already validates the zoom command at the IPC boundary, so the preload forwards it as-is. The component drops null-guarded timer clears and folds unmount cleanup into the subscription effect. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../layout/DesktopZoomIndicator.tsx | 31 ++++++------------- apps/desktop/src/preload.ts | 10 ++---- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.tsx index d85c9d13ddf..6045c9e46f6 100644 --- a/apps/app/src/components/layout/DesktopZoomIndicator.tsx +++ b/apps/app/src/components/layout/DesktopZoomIndicator.tsx @@ -14,38 +14,28 @@ export function DesktopZoomIndicator() { const onZoomChange = desktop?.onZoomChange; const zoom = desktop?.zoom; const [zoomFactor, setZoomFactor] = useState(null); - const hideTimer = useRef | null>(null); + const hideTimer = useRef>(undefined); const hovered = useRef(false); const restartHideTimer = useCallback(() => { - if (hideTimer.current !== null) { - clearTimeout(hideTimer.current); - } + clearTimeout(hideTimer.current); if (!hovered.current) { hideTimer.current = setTimeout(() => setZoomFactor(null), HIDE_DELAY_MS); } }, []); useEffect(() => { - if (onZoomChange === undefined) { - return; - } - return onZoomChange((factor) => { + const unsubscribe = onZoomChange?.((factor) => { setZoomFactor(factor); restartHideTimer(); }); + return () => { + unsubscribe?.(); + clearTimeout(hideTimer.current); + }; }, [onZoomChange, restartHideTimer]); - useEffect( - () => () => { - if (hideTimer.current !== null) { - clearTimeout(hideTimer.current); - } - }, - [], - ); - - if (zoomFactor === null || onZoomChange === undefined) { + if (zoomFactor === null) { return null; } @@ -61,10 +51,7 @@ export function DesktopZoomIndicator() { style={{ zoom: 1 / zoomFactor }} onPointerEnter={() => { hovered.current = true; - if (hideTimer.current !== null) { - clearTimeout(hideTimer.current); - hideTimer.current = null; - } + clearTimeout(hideTimer.current); }} onPointerLeave={() => { hovered.current = false; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 44a90494544..a330ee34427 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -20,7 +20,6 @@ import { type BbDesktopBrowserControlState, type BbDesktopBrowserRevealRequest, bbDesktopInfoSchema, - bbDesktopZoomCommandSchema, bbDesktopWindowStateSchema, type BbDesktopApi, type BbDesktopAppCommandHandler, @@ -43,7 +42,6 @@ import { type BbDesktopTheme, type BbDesktopWindowState, type BbDesktopWindowStateChangeHandler, - type BbDesktopZoomCommand, } from "@bb/desktop-contract"; import { BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, @@ -432,12 +430,8 @@ const bbDesktopApi: BbDesktopApi = { onZoomChange(listener): BbDesktopInfoUnsubscribe { return addListener(zoomListeners, listener); }, - zoom(command: BbDesktopZoomCommand): void { - const parsed = bbDesktopZoomCommandSchema.safeParse(command); - if (!parsed.success) { - return; - } - ipcRenderer.send(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, parsed.data); + zoom(command): void { + ipcRenderer.send(BB_DESKTOP_ZOOM_COMMAND_CHANNEL, command); }, onOpenNewTab(listener): BbDesktopInfoUnsubscribe { return addListener(openNewTabListeners, listener); From 34c8f48d4adbe25eb1dc4c7282069ba72c2c60b8 Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:53:45 -0400 Subject: [PATCH 6/9] Wait for fake Pi processes to exit before deleting the test workspace closeGracefully ends the child's stdin without waiting for exit, so teardown's recursive rm raced the fake Pi appending its exit line to process.log in the workspace, failing CI with ENOTEMPTY. A probe showed a child still running at rm time in 7 of 8 extension-ui tests. Teardown now waits until every spawned pid is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/provider-pi/src/bridge/test-support.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugins/provider-pi/src/bridge/test-support.ts b/plugins/provider-pi/src/bridge/test-support.ts index 7081a2c6afe..fcb274600d9 100644 --- a/plugins/provider-pi/src/bridge/test-support.ts +++ b/plugins/provider-pi/src/bridge/test-support.ts @@ -34,6 +34,15 @@ const threadDeltaParamsSchema = z.object({ deltas: z.array(z.record(z.string(), z.unknown())), }); +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + export interface StartFakePiBridgeOptions { prefix: string; sessionDir?: (workspaceDir: string) => string; @@ -165,6 +174,10 @@ export async function startFakePiBridge( }, async teardown() { await experimental_closeAllForTests(); + await bridge.waitFor( + () => bridge.readProcessLog().spawned.every((pid) => !isRunning(pid)), + "fake Pi processes to exit", + ); harness.restore(); vi.unstubAllEnvs(); rmSync(workspaceDir, { recursive: true, force: true }); From 4506cf9c555f21d001ade13de23386792ddfe161 Mon Sep 17 00:00:00 2001 From: Jason Son <65467162+vznh@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:04:38 -0400 Subject: [PATCH 7/9] Drop no-op and duplicate lines from the zoom indicator diff - resize listener: drop the no-op passive option and reuse the existing window guard in the preload - Reset button: drop text-xs, which size="sm" already sets - test: check the disabled attribute instead of casting - server-moved test: wait on the onMove assertion itself instead of a duplicate once-check - run oxfmt on the two new files Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/layout/DesktopZoomIndicator.test.tsx | 13 ++++++++++--- .../src/components/layout/DesktopZoomIndicator.tsx | 7 +++++-- apps/desktop/src/preload.ts | 7 +------ apps/desktop/test/server-moved.test.ts | 9 ++++----- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx index b0146c9cdea..9c6bc309709 100644 --- a/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx +++ b/apps/app/src/components/layout/DesktopZoomIndicator.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DesktopZoomIndicator } from "./DesktopZoomIndicator"; @@ -53,8 +59,9 @@ describe("DesktopZoomIndicator", () => { act(() => emitZoomChange(1)); expect( - (screen.getByRole("button", { name: "Reset zoom" }) as HTMLButtonElement) - .disabled, + screen + .getByRole("button", { name: "Reset zoom" }) + .hasAttribute("disabled"), ).toBe(true); }); diff --git a/apps/app/src/components/layout/DesktopZoomIndicator.tsx b/apps/app/src/components/layout/DesktopZoomIndicator.tsx index 6045c9e46f6..1c15e6f8570 100644 --- a/apps/app/src/components/layout/DesktopZoomIndicator.tsx +++ b/apps/app/src/components/layout/DesktopZoomIndicator.tsx @@ -58,7 +58,10 @@ export function DesktopZoomIndicator() { restartHideTimer(); }} > - + {Math.round(zoomFactor * 100)}%