From ce15afe655ab1f3f614ebe4313ee146d8ef89f57 Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Sun, 8 Nov 2026 08:50:40 -0500 Subject: [PATCH 1/9] Fix pane focus loops and stale magnification backdrops --- frontend/app/block/block.tsx | 78 +----- frontend/app/block/blockfocus.test.tsx | 183 +++++++++++++ frontend/app/block/blockfocus.ts | 63 +++++ frontend/layout/lib/TileLayout.tsx | 59 +--- frontend/layout/lib/nodebackdrops.test.tsx | 145 ++++++++++ frontend/layout/lib/nodebackdrops.tsx | 55 ++++ package-lock.json | 300 +++++++++++++++++++-- package.json | 1 + 8 files changed, 744 insertions(+), 140 deletions(-) create mode 100644 frontend/app/block/blockfocus.test.tsx create mode 100644 frontend/app/block/blockfocus.ts create mode 100644 frontend/layout/lib/nodebackdrops.test.tsx create mode 100644 frontend/layout/lib/nodebackdrops.tsx diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index f199dc5e9c..5575c33986 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -16,13 +16,14 @@ import { useDebouncedNodeInnerRect } from "@/layout/index"; import { counterInc } from "@/store/counters"; import { getBlockComponentModel, registerBlockComponentModel, unregisterBlockComponentModel } from "@/store/global"; import { makeORef } from "@/store/wos"; -import { focusedBlockId, getElemAsStr } from "@/util/focusutil"; +import { focusedBlockId } from "@/util/focusutil"; import { isBlank, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; -import { memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import "./block.scss"; import { BlockEnv } from "./blockenv"; +import { useBlockFocus } from "./blockfocus"; import { BlockFrame } from "./blockframe"; import { makeViewModel } from "./blockregistry"; @@ -86,11 +87,8 @@ const BlockSubBlock = memo(({ nodeModel, viewModel }: FullSubBlockProps) => { const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); const waveEnv = useWaveEnv(); - const focusElemRef = useRef(null); const blockRef = useRef(null); const contentRef = useRef(null); - const pendingFocusRafRef = useRef(null); - const [blockClicked, setBlockClicked] = useState(false); const blockView = useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")) ?? ""; const isFocused = useAtomValue(nodeModel.isFocused); const disablePointerEvents = useAtomValue(nodeModel.disablePointerEvents); @@ -101,36 +99,11 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const focusFollowsCursorMode = useAtomValue(waveEnv.getSettingsKeyAtom("app:focusfollowscursor")) ?? "off"; const innerRect = useDebouncedNodeInnerRect(nodeModel); const noPadding = useAtomValueSafe(viewModel.noPadding); - - useEffect(() => { - return () => { - if (pendingFocusRafRef.current != null) { - cancelAnimationFrame(pendingFocusRafRef.current); - } - }; - }, []); - - useLayoutEffect(() => { - setBlockClicked(isFocused); - }, [isFocused]); - - useLayoutEffect(() => { - if (!blockClicked) { - return; - } - setBlockClicked(false); - const focusWithin = focusedBlockId() == nodeModel.blockId; - if (!focusWithin) { - setFocusTarget(); - } - if (!isFocused) { - nodeModel.focusNode(); - } - }, [blockClicked, isFocused]); - - const setBlockClickedTrue = useCallback(() => { - setBlockClicked(true); - }, []); + const { focusElemRef, setFocusTarget, handleClick, handleFocusCapture } = useBlockFocus( + nodeModel, + viewModel, + isFocused + ); const [blockContentOffset, setBlockContentOffset] = useState(); @@ -163,35 +136,6 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { [nodeModel.blockId, blockView, viewModel] ); - const handleChildFocus = useCallback( - (event: React.FocusEvent) => { - console.log("setFocusedChild", nodeModel.blockId, getElemAsStr(event.target)); - if (!isFocused) { - console.log("focusedChild focus", nodeModel.blockId); - nodeModel.focusNode(); - } - }, - [isFocused] - ); - - const setFocusTarget = useCallback(() => { - if (pendingFocusRafRef.current != null) { - cancelAnimationFrame(pendingFocusRafRef.current); - pendingFocusRafRef.current = null; - } - const ok = viewModel?.giveFocus?.(); - if (ok) { - return; - } - focusElemRef.current?.focus({ preventScroll: true }); - pendingFocusRafRef.current = requestAnimationFrame(() => { - pendingFocusRafRef.current = null; - if (blockRef.current?.contains(document.activeElement)) { - viewModel?.giveFocus?.(); - } - }); - }, [viewModel]); - const focusFromPointerEnter = useCallback( (event: React.PointerEvent) => { const focusFollowsCursorEnabled = @@ -226,12 +170,12 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const blockModel = useMemo( () => ({ - onClick: setBlockClickedTrue, + onClick: handleClick, onPointerEnter: focusFromPointerEnter, - onFocusCapture: handleChildFocus, + onFocusCapture: handleFocusCapture, blockRef: blockRef, }), - [setBlockClickedTrue, focusFromPointerEnter, handleChildFocus, blockRef] + [handleClick, focusFromPointerEnter, handleFocusCapture, blockRef] ); return ( diff --git a/frontend/app/block/blockfocus.test.tsx b/frontend/app/block/blockfocus.test.tsx new file mode 100644 index 0000000000..38ffa11b26 --- /dev/null +++ b/frontend/app/block/blockfocus.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { act, useLayoutEffect, useMemo, useState } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useBlockFocus } from "./blockfocus"; + +type TestPaneProps = { + blockId: string; + selected: string; + select: (blockId: string) => void; + giveFocus: () => boolean; +}; + +function TestPane({ blockId, selected, select, giveFocus }: TestPaneProps) { + const nodeModel = useMemo(() => ({ blockId, focusNode: () => select(blockId) }), [blockId, select]); + const viewModel = useMemo(() => ({ giveFocus }), [giveFocus]); + const { focusElemRef, handleClick, handleFocusCapture } = useBlockFocus(nodeModel, viewModel, selected === blockId); + return ( +
+
+ + +
+ + +
+ ); +} + +function FocusAfterMount() { + useLayoutEffect(() => { + document.querySelector('[aria-label="b address"]').focus(); + }, []); + return null; +} + +describe("block focus ownership", () => { + let container: HTMLDivElement; + let root: Root; + let selectPane: (blockId: string) => void; + let frames: Map; + let nextFrame: number; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + frames = new Map(); + nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + function contentFocus(blockId: string) { + return vi.fn(() => { + container.querySelector(`[data-testid="${blockId}-content"]`).focus(); + return true; + }); + } + + async function renderPanes( + giveFocusA = contentFocus("a"), + giveFocusB = contentFocus("b"), + focusAfterMount = false + ) { + function TestLayout() { + const [selected, setSelected] = useState("a"); + selectPane = setSelected; + return ( + <> + + + {focusAfterMount && } + + ); + } + await act(async () => root.render()); + } + + async function flushFrames() { + const queued = [...frames.values()]; + frames.clear(); + await act(async () => queued.forEach((callback) => callback(0))); + } + + function address(blockId: string) { + return container.querySelector(`[aria-label="${blockId} address"]`); + } + + it("does not reclaim focus when another pane receives focus during the same commit", async () => { + await renderPanes(contentFocus("a"), contentFocus("b"), true); + + expect(document.activeElement).toBe(address("b")); + expect(container.querySelector('[data-blockid="b"]').getAttribute("data-selected")).toBe("true"); + }); + + it("preserves address-bar focus while clicking between browser panes", async () => { + await renderPanes(); + + for (const blockId of ["b", "a", "b"]) { + await act(async () => { + address(blockId).focus(); + address(blockId).click(); + }); + expect(document.activeElement).toBe(address(blockId)); + expect(container.querySelector(`[data-blockid="${blockId}"]`).getAttribute("data-selected")).toBe("true"); + } + }); + + it("selects an unfocused pane when its non-focusable header is clicked", async () => { + await renderPanes(); + + await act(async () => container.querySelector('[data-testid="b-header"]').click()); + + expect(container.querySelector('[data-blockid="b"]').getAttribute("data-selected")).toBe("true"); + expect(document.activeElement).toBe(container.querySelector('[data-testid="b-content"]')); + }); + + it("moves DOM focus to the pane selected by a keyboard action", async () => { + const giveFocusA = contentFocus("a"); + await renderPanes(giveFocusA); + giveFocusA.mockClear(); + + await act(async () => selectPane("b")); + + expect(document.activeElement).toBe(container.querySelector('[data-testid="b-content"]')); + expect(giveFocusA).not.toHaveBeenCalled(); + }); + + it("does not let a deferred focus retry take focus from the address bar", async () => { + const giveFocusA = vi.fn(() => false); + await renderPanes(giveFocusA); + expect(frames.size).toBe(1); + expect(document.activeElement).toBe(container.querySelector('[data-testid="a-fallback"]')); + + await act(async () => address("a").focus()); + await flushFrames(); + + expect(document.activeElement).toBe(address("a")); + expect(giveFocusA).toHaveBeenCalledTimes(1); + }); + + it("cancels a deferred focus retry when its pane loses selection", async () => { + const giveFocusA = vi.fn(() => false); + await renderPanes(giveFocusA); + + await act(async () => selectPane("b")); + await flushFrames(); + + expect(frames.size).toBe(0); + expect(document.activeElement).toBe(container.querySelector('[data-testid="b-content"]')); + expect(giveFocusA).toHaveBeenCalledTimes(1); + }); + + it("retries view focus when the fallback input still owns focus", async () => { + const giveFocusA = vi.fn(() => false); + await renderPanes(giveFocusA); + + await flushFrames(); + + expect(giveFocusA).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/app/block/blockfocus.ts b/frontend/app/block/blockfocus.ts new file mode 100644 index 0000000000..274066a914 --- /dev/null +++ b/frontend/app/block/blockfocus.ts @@ -0,0 +1,63 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { focusedBlockId } from "@/util/focusutil"; +import { useCallback, useLayoutEffect, useRef } from "react"; +import { BlockNodeModel } from "./blocktypes"; + +export function useBlockFocus( + nodeModel: Pick, + viewModel: Pick, + isFocused: boolean +) { + const focusElemRef = useRef(null); + const pendingFocusRafRef = useRef(null); + const isFocusedRef = useRef(isFocused); + + const cancelPendingFocus = useCallback(() => { + if (pendingFocusRafRef.current != null) { + cancelAnimationFrame(pendingFocusRafRef.current); + pendingFocusRafRef.current = null; + } + }, []); + + const setFocusTarget = useCallback(() => { + cancelPendingFocus(); + if (viewModel?.giveFocus?.()) { + return; + } + focusElemRef.current?.focus({ preventScroll: true }); + pendingFocusRafRef.current = requestAnimationFrame(() => { + pendingFocusRafRef.current = null; + // A toolbar control may have gained focus while the view was waiting for a frame. + if (isFocusedRef.current && document.activeElement === focusElemRef.current) { + viewModel?.giveFocus?.(); + } + }); + }, [cancelPendingFocus, viewModel]); + + useLayoutEffect(() => { + isFocusedRef.current = isFocused; + if (isFocused && focusedBlockId() != nodeModel.blockId) { + setFocusTarget(); + } + return cancelPendingFocus; + }, [isFocused, nodeModel.blockId, setFocusTarget, cancelPendingFocus]); + + const handleClick = useCallback(() => { + if (!isFocused) { + nodeModel.focusNode(); + } + if (focusedBlockId() != nodeModel.blockId) { + setFocusTarget(); + } + }, [isFocused, nodeModel, setFocusTarget]); + + const handleFocusCapture = useCallback(() => { + if (!isFocused) { + nodeModel.focusNode(); + } + }, [isFocused, nodeModel]); + + return { focusElemRef, setFocusTarget, handleClick, handleFocusCapture }; +} diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index fa4ec9a030..76501a2306 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getSettingsKeyAtom } from "@/app/store/global"; @@ -21,6 +21,7 @@ import { debounce, throttle } from "throttle-debounce"; import { useDevicePixelRatio } from "use-device-pixel-ratio"; import { LayoutModel } from "./layoutModel"; import { useNodeModel, useTileLayout } from "./layoutModelHooks"; +import { NodeBackdrops } from "./nodebackdrops"; import "./tilelayout.scss"; import { LayoutNode, @@ -126,7 +127,7 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr
- +
@@ -136,58 +137,20 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr } export const TileLayout = memo(TileLayoutComponent) as typeof TileLayoutComponent; -function NodeBackdrops({ layoutModel }: { layoutModel: LayoutModel }) { +function NodeBackdropsWrapper({ layoutModel }: { layoutModel: LayoutModel }) { const [blockBlurAtom] = useState(() => getSettingsKeyAtom("window:magnifiedblockblursecondarypx")); const blockBlur = useAtomValue(blockBlurAtom); const ephemeralNode = useAtomValue(layoutModel.ephemeralNode); const magnifiedNodeId = useAtomValue(layoutModel.magnifiedNodeIdAtom); - const [showMagnifiedBackdrop, setShowMagnifiedBackdrop] = useState(!!ephemeralNode); - const [showEphemeralBackdrop, setShowEphemeralBackdrop] = useState(!!magnifiedNodeId); - - const debouncedSetMagnifyBackdrop = useCallback( - debounce(100, () => setShowMagnifiedBackdrop(true)), - [] - ); - - useEffect(() => { - if (magnifiedNodeId && !showMagnifiedBackdrop) { - debouncedSetMagnifyBackdrop(); - } - if (!magnifiedNodeId) { - setShowMagnifiedBackdrop(false); - } - if (ephemeralNode && !showEphemeralBackdrop) { - setShowEphemeralBackdrop(true); - } - if (!ephemeralNode) { - setShowEphemeralBackdrop(false); - } - }, [ephemeralNode, magnifiedNodeId]); - - const blockBlurStr = `${blockBlur}px`; - return ( - <> - {showMagnifiedBackdrop && ( -
{ - layoutModel.magnifyNodeToggle(magnifiedNodeId); - }} - style={{ "--block-blur": blockBlurStr } as CSSProperties} - /> - )} - {showEphemeralBackdrop && ( -
{ - layoutModel.closeNode(ephemeralNode?.id); - }} - style={{ "--block-blur": blockBlurStr } as CSSProperties} - /> - )} - + layoutModel.magnifyNodeToggle(nodeId)} + onCloseEphemeral={(nodeId) => layoutModel.closeNode(nodeId)} + /> ); } diff --git a/frontend/layout/lib/nodebackdrops.test.tsx b/frontend/layout/lib/nodebackdrops.test.tsx new file mode 100644 index 0000000000..01ea386074 --- /dev/null +++ b/frontend/layout/lib/nodebackdrops.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NodeBackdrops } from "./nodebackdrops"; + +describe("NodeBackdrops", () => { + let container: HTMLDivElement; + let root: Root; + let onMagnifyToggle: ReturnType; + let onCloseEphemeral: ReturnType; + + function render(magnifiedNodeId?: string, ephemeralNodeId?: string) { + act(() => { + root.render( + + ); + }); + } + + function advanceTime(ms: number) { + act(() => vi.advanceTimersByTime(ms)); + } + + function magnifiedBackdrop() { + return container.querySelector(".magnified-node-backdrop"); + } + + function ephemeralBackdrop() { + return container.querySelector(".ephemeral-node-backdrop"); + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + onMagnifyToggle = vi.fn(); + onCloseEphemeral = vi.fn(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("delays newly magnified backdrops by 100ms and dismisses the matching node", () => { + render(); + render("browser"); + + advanceTime(99); + expect(magnifiedBackdrop()).toBeNull(); + advanceTime(1); + expect(magnifiedBackdrop()?.style.getPropertyValue("--block-blur")).toBe("2px"); + act(() => magnifiedBackdrop().click()); + expect(onMagnifyToggle).toHaveBeenCalledExactlyOnceWith("browser"); + expect(ephemeralBackdrop()).toBeNull(); + }); + + it("does not leave a click-blocking backdrop after rapid unmagnification or closing", () => { + render(); + render("browser"); + advanceTime(40); + render(); + + advanceTime(100); + expect(magnifiedBackdrop()).toBeNull(); + expect(ephemeralBackdrop()).toBeNull(); + }); + + it("does not let a canceled timer reveal a newly magnified node early", () => { + render(); + render("browser"); + advanceTime(40); + render(); + advanceTime(10); + render("terminal"); + + advanceTime(50); + expect(magnifiedBackdrop()).toBeNull(); + advanceTime(50); + expect(magnifiedBackdrop()).not.toBeNull(); + act(() => magnifiedBackdrop().click()); + expect(onMagnifyToggle).toHaveBeenCalledExactlyOnceWith("terminal"); + }); + + it("cancels the pending timer when magnification clears", () => { + render(); + render("browser"); + expect(vi.getTimerCount()).toBe(1); + render(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels the pending timer on unmount", () => { + render(); + render("browser"); + expect(vi.getTimerCount()).toBe(1); + act(() => root.unmount()); + expect(vi.getTimerCount()).toBe(0); + root = createRoot(container); + }); + + it("shows restored magnification immediately without an ephemeral backdrop", () => { + render("browser"); + + expect(magnifiedBackdrop()).not.toBeNull(); + expect(ephemeralBackdrop()).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + render(); + expect(magnifiedBackdrop()).toBeNull(); + }); + + it("keeps ephemeral backdrops independent of the magnification delay", () => { + render(); + render("browser", "preview"); + + expect(magnifiedBackdrop()).toBeNull(); + expect(ephemeralBackdrop()).not.toBeNull(); + act(() => ephemeralBackdrop().click()); + expect(onCloseEphemeral).toHaveBeenCalledExactlyOnceWith("preview"); + render("browser"); + expect(ephemeralBackdrop()).toBeNull(); + + advanceTime(100); + expect(magnifiedBackdrop()).not.toBeNull(); + render(undefined, "preview"); + expect(magnifiedBackdrop()).toBeNull(); + expect(ephemeralBackdrop()).not.toBeNull(); + render(); + expect(ephemeralBackdrop()).toBeNull(); + }); +}); diff --git a/frontend/layout/lib/nodebackdrops.tsx b/frontend/layout/lib/nodebackdrops.tsx new file mode 100644 index 0000000000..5721f04033 --- /dev/null +++ b/frontend/layout/lib/nodebackdrops.tsx @@ -0,0 +1,55 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { CSSProperties, useEffect, useState } from "react"; + +type NodeBackdropsProps = { + blockBlur: number; + ephemeralNodeId?: string; + magnifiedNodeId?: string; + onMagnifyToggle: (nodeId: string) => void; + onCloseEphemeral: (nodeId: string) => void; +}; + +export function NodeBackdrops({ + blockBlur, + ephemeralNodeId, + magnifiedNodeId, + onMagnifyToggle, + onCloseEphemeral, +}: NodeBackdropsProps) { + const [showMagnifiedBackdrop, setShowMagnifiedBackdrop] = useState(!!magnifiedNodeId); + + useEffect(() => { + if (!magnifiedNodeId) { + setShowMagnifiedBackdrop(false); + return; + } + if (showMagnifiedBackdrop) { + return; + } + const timer = setTimeout(() => setShowMagnifiedBackdrop(true), 100); + return () => clearTimeout(timer); + }, [magnifiedNodeId, showMagnifiedBackdrop]); + + const backdropStyle = { "--block-blur": `${blockBlur}px` } as CSSProperties; + + return ( + <> + {magnifiedNodeId && showMagnifiedBackdrop && ( +
onMagnifyToggle(magnifiedNodeId)} + style={backdropStyle} + /> + )} + {ephemeralNodeId && ( +
onCloseEphemeral(ephemeralNodeId)} + style={backdropStyle} + /> + )} + + ); +} diff --git a/package-lock.json b/package-lock.json index 6e7b66d0fa..9d06558b3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,7 @@ "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", + "jsdom": "^26.1.0", "node-abi": "^4.31.0", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", @@ -577,6 +578,27 @@ "node": ">=18" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -4814,7 +4836,6 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5551,7 +5572,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5574,7 +5594,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5597,7 +5616,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5614,7 +5632,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5631,7 +5648,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5648,7 +5664,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5665,7 +5680,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5682,7 +5696,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5699,7 +5712,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5716,7 +5728,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5733,7 +5744,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5750,7 +5760,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -5767,7 +5776,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5790,7 +5798,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5813,7 +5820,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5836,7 +5842,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5859,7 +5864,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5882,7 +5886,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5905,7 +5908,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5928,7 +5930,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -5951,7 +5952,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -5971,7 +5971,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5991,7 +5990,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -6011,7 +6009,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -13831,6 +13828,20 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -14345,6 +14356,20 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -14374,6 +14399,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -17756,6 +17788,19 @@ "node": ">=12" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -18595,6 +18640,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -18931,6 +18983,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -23232,6 +23324,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -28322,6 +28421,13 @@ "points-on-path": "^0.2.1" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/rtlcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", @@ -28522,6 +28628,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -29896,6 +30015,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -30322,6 +30448,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -30372,6 +30518,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tree-dump": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", @@ -32429,6 +32601,19 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-up-path": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", @@ -32482,6 +32667,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.101.3", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", @@ -32948,6 +33143,44 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -33232,6 +33465,16 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -33242,6 +33485,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 865692617a..50d00c316f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "eslint": "^9.39", "eslint-config-prettier": "^10.1.8", "globals": "^17.6.0", + "jsdom": "^26.1.0", "node-abi": "^4.31.0", "prettier": "^3.8.1", "prettier-plugin-jsdoc": "^1.8.0", From fd0941374c670e7935f67778d098b179cfdc1908 Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Sun, 8 Nov 2026 09:51:09 -0500 Subject: [PATCH 2/9] Fix macOS toolbar clicks after embedded browser focus --- emain/emain-ipc.ts | 8 +- emain/emain-window.ts | 2 + emain/preload.ts | 3 +- frontend/app/app.tsx | 76 +----- frontend/app/macos-firstclick.test.ts | 228 ++++++++++++++++++ frontend/app/macos-firstclick.ts | 107 ++++++++ frontend/preview/mock/preview-electron-api.ts | 1 + frontend/types/custom.d.ts | 1 + 8 files changed, 355 insertions(+), 71 deletions(-) create mode 100644 frontend/app/macos-firstclick.test.ts create mode 100644 frontend/app/macos-firstclick.ts diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 5e5f15b302..6e64deaf4d 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; @@ -288,6 +288,12 @@ export function initIpcHandlers() { event.returnValue = event.sender.getZoomFactor(); }); + electron.ipcMain.on("get-last-native-window-focus-time", (event) => { + const window = getWaveWindowByWebContentsId(event.sender.id); + event.returnValue = + window != null && !window.isDestroyed() && window.isFocused() ? window.lastNativeFocusTime : null; + }); + const hasBeforeInputRegisteredMap = new Map(); electron.ipcMain.on("webview-focus", (event: Electron.IpcMainEvent, focusedId: number) => { diff --git a/emain/emain-window.ts b/emain/emain-window.ts index c5620ae39d..ae714b6f68 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -149,6 +149,7 @@ export class WaveBrowserWindow extends BaseWindow { workspaceId: string; allLoadedTabViews: Map; activeTabView: WaveTabView; + lastNativeFocusTime: number = null; private canClose: boolean; private deleteAllowed: boolean; private actionQueue: WindowActionQueueEntry[]; @@ -279,6 +280,7 @@ export class WaveBrowserWindow extends BaseWindow { if (this.isDestroyed()) { return; } + this.lastNativeFocusTime = Date.now(); if (getGlobalIsRelaunching()) { return; } diff --git a/emain/preload.ts b/emain/preload.ts index 8d2b18a308..4a8c5fc9fc 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { contextBridge, ipcRenderer, Rectangle, webUtils, WebviewTag } from "electron"; @@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld("api", { getAboutModalDetails: () => ipcRenderer.sendSync("get-about-modal-details"), getWebviewPreload: () => ipcRenderer.sendSync("get-webview-preload"), getZoomFactor: () => ipcRenderer.sendSync("get-zoom-factor"), + getLastNativeWindowFocusTime: () => ipcRenderer.sendSync("get-last-native-window-focus-time"), openNewWindow: () => ipcRenderer.send("open-new-window"), showWorkspaceAppMenu: (workspaceId) => ipcRenderer.send("workspace-appmenu-show", workspaceId), showBuilderAppMenu: (builderId) => ipcRenderer.send("builder-appmenu-show", builderId), diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index e9c70a35df..4901a81722 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -17,7 +17,7 @@ import { makeWaveEnvImpl } from "@/app/waveenv/waveenvimpl"; import { Workspace } from "@/app/workspace/workspace"; import { getLayoutModelForStaticTab } from "@/layout/index"; import { ContextMenuModel } from "@/store/contextmenu"; -import { atoms, createBlock, getSettingsPrefixAtom, refocusNode } from "@/store/global"; +import { atoms, createBlock, getApi, getSettingsPrefixAtom, refocusNode } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; @@ -32,6 +32,7 @@ import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { AppBackground } from "./app-bg"; import { CenteredDiv } from "./element/quickelems"; +import { installMacOSFirstClickHandler } from "./macos-firstclick"; import "./app.scss"; @@ -209,74 +210,11 @@ const MacOSFirstClickHandler = () => { if (PLATFORM !== "darwin") { return; } - let windowFocusTime: number = null; - let cancelNextClick = false; - const handleWindowFocus = (e: FocusEvent) => { - windowFocusTime = Date.now(); - }; - const getBlockIdFromTarget = (target: EventTarget): string => { - let elem = target as HTMLElement; - while (elem != null) { - const blockId = elem.dataset?.blockid; - if (blockId) { - return blockId; - } - elem = elem.parentElement; - } - return null; - }; - const isAIPanelTarget = (target: EventTarget): boolean => { - let elem = target as HTMLElement; - while (elem != null) { - if (elem.dataset?.aipanel) { - return true; - } - elem = elem.parentElement; - } - return false; - }; - const handleMouseDown = (e: MouseEvent) => { - const timeDiff = Date.now() - windowFocusTime; - if (windowFocusTime != null && timeDiff < 50) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - cancelNextClick = true; - const blockId = getBlockIdFromTarget(e.target); - if (blockId != null) { - setTimeout(() => { - console.log("macos first-click, focusing block", blockId); - refocusNode(blockId); - }, 10); - } else if (isAIPanelTarget(e.target)) { - setTimeout(() => { - console.log("macos first-click, focusing AI panel"); - FocusManager.getInstance().setWaveAIFocused(true); - }, 10); - } - console.log("macos first-click detected, canceled", timeDiff + "ms"); - return; - } - cancelNextClick = false; - }; - const handleClick = (e: MouseEvent) => { - if (!cancelNextClick) { - return; - } - cancelNextClick = false; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - console.log("macos first-click (click event) canceled"); - }; - window.addEventListener("focus", handleWindowFocus); - window.addEventListener("mousedown", handleMouseDown, true); - window.addEventListener("click", handleClick, true); - return () => { - window.removeEventListener("focus", handleWindowFocus); - window.removeEventListener("mousedown", handleMouseDown, true); - window.removeEventListener("click", handleClick, true); - }; + return installMacOSFirstClickHandler({ + getLastNativeWindowFocusTime: () => getApi().getLastNativeWindowFocusTime(), + focusBlock: refocusNode, + focusAIPanel: () => FocusManager.getInstance().setWaveAIFocused(true), + }); }, []); return null; }; diff --git a/frontend/app/macos-firstclick.test.ts b/frontend/app/macos-firstclick.test.ts new file mode 100644 index 0000000000..253729c8eb --- /dev/null +++ b/frontend/app/macos-firstclick.test.ts @@ -0,0 +1,228 @@ +// @vitest-environment jsdom +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { installMacOSFirstClickHandler } from "./macos-firstclick"; + +describe("macOS activation clicks", () => { + let lastNativeFocusTime: number; + let cleanup: () => void; + let button: HTMLButtonElement; + let onMouseDown: ReturnType; + let onClick: ReturnType; + let focusBlock: ReturnType; + let focusAIPanel: ReturnType; + let getLastNativeWindowFocusTime: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1000000); + lastNativeFocusTime = Date.now() - 1000; + document.body.innerHTML = '
'; + button = document.querySelector("button"); + onMouseDown = vi.fn(); + onClick = vi.fn(); + button.addEventListener("mousedown", onMouseDown); + button.addEventListener("click", onClick); + focusBlock = vi.fn(); + focusAIPanel = vi.fn(); + getLastNativeWindowFocusTime = vi.fn(() => lastNativeFocusTime); + cleanup = installMacOSFirstClickHandler({ + getLastNativeWindowFocusTime, + focusBlock, + focusAIPanel, + }); + }); + + afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + vi.useRealTimers(); + }); + + function click(target: Element = button) { + const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true, detail: 1 }); + const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }); + target.dispatchEvent(mouseDown); + target.dispatchEvent(clickEvent); + return { mouseDown, clickEvent }; + } + + function activateNativeWindow() { + lastNativeFocusTime = Date.now(); + window.dispatchEvent(new FocusEvent("focus")); + } + + it("allows toolbar clicks after renderer-only focus without native window activation", () => { + window.dispatchEvent(new FocusEvent("focus")); + + const { mouseDown, clickEvent } = click(); + vi.advanceTimersByTime(10); + + expect(mouseDown.defaultPrevented).toBe(false); + expect(clickEvent.defaultPrevented).toBe(false); + expect(onMouseDown).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + expect(focusBlock).not.toHaveBeenCalled(); + }); + + it("keeps toolbar clicks responsive through repeated renderer-only focus changes", () => { + for (let i = 0; i < 20; i++) { + window.dispatchEvent(new FocusEvent("blur")); + window.dispatchEvent(new FocusEvent("focus")); + click(); + vi.advanceTimersByTime(12); + } + + expect(onMouseDown).toHaveBeenCalledTimes(20); + expect(onClick).toHaveBeenCalledTimes(20); + expect(focusBlock).not.toHaveBeenCalled(); + expect(getLastNativeWindowFocusTime).toHaveBeenCalledTimes(20); + }); + + it("does not query the main process for clicks outside a renderer focus transition", () => { + click(); + window.dispatchEvent(new FocusEvent("focus")); + vi.advanceTimersByTime(50); + click(); + click(); + + expect(onClick).toHaveBeenCalledTimes(3); + expect(getLastNativeWindowFocusTime).not.toHaveBeenCalled(); + }); + + it("allows clicks when there is no native activation timestamp", () => { + lastNativeFocusTime = null; + window.dispatchEvent(new FocusEvent("focus")); + + click(); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(focusBlock).not.toHaveBeenCalled(); + expect(getLastNativeWindowFocusTime).toHaveBeenCalledTimes(1); + }); + + it("still suppresses the activation click after its delayed focus blurs the renderer", () => { + focusBlock.mockImplementation(() => window.dispatchEvent(new FocusEvent("blur"))); + activateNativeWindow(); + button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, detail: 1 })); + + vi.advanceTimersByTime(10); + const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }); + button.dispatchEvent(clickEvent); + + expect(focusBlock).toHaveBeenCalledExactlyOnceWith("browser"); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("cancels delayed focus when the renderer loses focus before the timer runs", () => { + activateNativeWindow(); + click(); + window.dispatchEvent(new FocusEvent("blur")); + vi.advanceTimersByTime(10); + + expect(focusBlock).not.toHaveBeenCalled(); + }); + + it("allows keyboard activation and cancels pending mouse focus", () => { + activateNativeWindow(); + button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, detail: 1 })); + button.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + button.click(); + vi.advanceTimersByTime(10); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(focusBlock).not.toHaveBeenCalled(); + }); + + it("allows a programmatic click without consuming suppression of the physical activation click", () => { + activateNativeWindow(); + button.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, detail: 1 })); + + button.click(); + const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }); + button.dispatchEvent(clickEvent); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(clickEvent.defaultPrevented).toBe(true); + }); + + it("suppresses the activation click and focuses its browser pane", () => { + activateNativeWindow(); + + const { mouseDown, clickEvent } = click(); + vi.advanceTimersByTime(10); + + expect(mouseDown.defaultPrevented).toBe(true); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onMouseDown).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + expect(focusBlock).toHaveBeenCalledExactlyOnceWith("browser"); + }); + + it("allows a second click during the same activation without later stealing its focus", () => { + activateNativeWindow(); + click(); + vi.advanceTimersByTime(1); + + const { mouseDown, clickEvent } = click(); + vi.advanceTimersByTime(10); + + expect(mouseDown.defaultPrevented).toBe(false); + expect(clickEvent.defaultPrevented).toBe(false); + expect(onClick).toHaveBeenCalledTimes(1); + expect(focusBlock).not.toHaveBeenCalled(); + }); + + it("suppresses a new activation click after the window is reactivated", () => { + activateNativeWindow(); + click(); + vi.advanceTimersByTime(100); + + activateNativeWindow(); + const { clickEvent } = click(); + vi.advanceTimersByTime(10); + + expect(clickEvent.defaultPrevented).toBe(true); + expect(onClick).not.toHaveBeenCalled(); + expect(focusBlock).toHaveBeenCalledTimes(2); + }); + + it("does not extend the activation window when renderer focus changes", () => { + activateNativeWindow(); + vi.advanceTimersByTime(50); + window.dispatchEvent(new FocusEvent("focus")); + + const { clickEvent } = click(); + + expect(clickEvent.defaultPrevented).toBe(false); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("preserves activation-click handling for the AI panel", () => { + document.querySelector("section").removeAttribute("data-blockid"); + document.querySelector("section").setAttribute("data-aipanel", "true"); + activateNativeWindow(); + + click(); + vi.advanceTimersByTime(10); + + expect(focusAIPanel).toHaveBeenCalledTimes(1); + expect(focusBlock).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("removes listeners and cancels delayed focus when disposed", () => { + activateNativeWindow(); + click(); + + cleanup(); + vi.advanceTimersByTime(10); + click(); + + expect(focusBlock).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/app/macos-firstclick.ts b/frontend/app/macos-firstclick.ts new file mode 100644 index 0000000000..1509a024d9 --- /dev/null +++ b/frontend/app/macos-firstclick.ts @@ -0,0 +1,107 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +type FirstClickOptions = { + getLastNativeWindowFocusTime: () => number; + focusBlock: (blockId: string) => void; + focusAIPanel: () => void; +}; + +export function installMacOSFirstClickHandler({ + getLastNativeWindowFocusTime, + focusBlock, + focusAIPanel, +}: FirstClickOptions): () => void { + let rendererFocusTime: number = null; + let handledWindowFocusTime: number = null; + let cancelNextClick = false; + let pendingFocus: ReturnType = null; + + const cancelPendingFocus = () => { + if (pendingFocus != null) { + clearTimeout(pendingFocus); + pendingFocus = null; + } + }; + + const handleFocus = () => { + rendererFocusTime = Date.now(); + }; + + const handleBlur = () => { + rendererFocusTime = null; + cancelPendingFocus(); + // Focusing a webview also blurs this renderer; its activation click still needs to be suppressed. + }; + + const handleKeyDown = () => { + rendererFocusTime = null; + cancelNextClick = false; + cancelPendingFocus(); + }; + + const handleMouseDown = (event: MouseEvent) => { + cancelPendingFocus(); + cancelNextClick = false; + const rendererFocusAge = Date.now() - rendererFocusTime; + if (rendererFocusTime == null || rendererFocusAge < 0 || rendererFocusAge >= 50) { + return; + } + rendererFocusTime = null; + + // A webview can focus this renderer without activating its native window. + const windowFocusTime = getLastNativeWindowFocusTime(); + const timeDiff = Date.now() - windowFocusTime; + if (windowFocusTime == null || windowFocusTime === handledWindowFocusTime || timeDiff < 0 || timeDiff >= 50) { + return; + } + + handledWindowFocusTime = windowFocusTime; + cancelNextClick = true; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + const target = event.target instanceof Element ? event.target : null; + const blockId = target?.closest("[data-blockid]")?.getAttribute("data-blockid"); + const aiPanel = target?.closest("[data-aipanel]"); + if (blockId != null || aiPanel != null) { + pendingFocus = setTimeout(() => { + pendingFocus = null; + if (blockId != null) { + console.log("macos first-click, focusing block", blockId); + focusBlock(blockId); + } else { + console.log("macos first-click, focusing AI panel"); + focusAIPanel(); + } + }, 10); + } + console.log("macos first-click detected, canceled", timeDiff + "ms"); + }; + + const handleClick = (event: MouseEvent) => { + if (!cancelNextClick || event.detail === 0) { + return; + } + cancelNextClick = false; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + console.log("macos first-click (click event) canceled"); + }; + + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("mousedown", handleMouseDown, true); + window.addEventListener("click", handleClick, true); + return () => { + cancelPendingFocus(); + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("mousedown", handleMouseDown, true); + window.removeEventListener("click", handleClick, true); + }; +} diff --git a/frontend/preview/mock/preview-electron-api.ts b/frontend/preview/mock/preview-electron-api.ts index 36c82f26da..4b694421f0 100644 --- a/frontend/preview/mock/preview-electron-api.ts +++ b/frontend/preview/mock/preview-electron-api.ts @@ -15,6 +15,7 @@ const previewElectronApi: ElectronApi = { getWebviewPreload: () => "", getAboutModalDetails: () => ({}) as AboutModalDetails, getZoomFactor: () => 1.0, + getLastNativeWindowFocusTime: () => null, showWorkspaceAppMenu: (_workspaceId: string) => {}, showBuilderAppMenu: (_builderId: string) => {}, showContextMenu: (_workspaceId: string, _menu: ElectronContextMenuItem[]) => {}, diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..83e00dbb25 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -91,6 +91,7 @@ declare global { getWebviewPreload: () => string; // get-webview-preload getAboutModalDetails: () => AboutModalDetails; // get-about-modal-details getZoomFactor: () => number; // get-zoom-factor + getLastNativeWindowFocusTime: () => number; // get-last-native-window-focus-time showWorkspaceAppMenu: (workspaceId: string) => void; // workspace-appmenu-show showBuilderAppMenu: (builderId: string) => void; // builder-appmenu-show showContextMenu: (workspaceId: string, menu: ElectronContextMenuItem[]) => void; // contextmenu-show From 9fe034bd579964188145c72a899c1026cb43ae5a Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Sun, 8 Nov 2026 11:50:37 -0500 Subject: [PATCH 3/9] Document macOS browser control investigation and verified fix --- docs/browser-controls-macos.md | 125 +++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/browser-controls-macos.md diff --git a/docs/browser-controls-macos.md b/docs/browser-controls-macos.md new file mode 100644 index 0000000000..8f085599f5 --- /dev/null +++ b/docs/browser-controls-macos.md @@ -0,0 +1,125 @@ +# macOS browser controls: investigation and fix + +**Status: user-confirmed fixed in an isolated Wave (Dev) build.** On November 8, 2026, at 11:43 EST, the reporting user confirmed: + +> testing is complete. It's fixed. + +This report records the browser-control failure, two corrective commits, verification, and reproduction steps. The confirmation applies to the tested development build on the reporting Mac. It does not establish that an upstream release or a packaged installer contains these changes. + +## Environment and changes + +| Item | Investigated configuration | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Platform | macOS 27.0.1, build 26A434, Apple Silicon / ARM64 | +| Release base | Wave `v0.14.5`, upstream commit [`97e56002`](https://github.com/wavetermdev/waveterm/commit/97e560027f494d20fed347b2d6b72b6bcb3e50e0) | +| Fork and branch | [`cjosewski/waveterm`](https://github.com/cjosewski/waveterm), `fix/browser-focus` | +| Electron | 41.1.0 | +| Build tools used | Node.js 22.23.4, Go 1.25.6, Task 3.54.0 | +| Test tools | Vitest 3.2.4; jsdom 26.1.0 for DOM interaction tests | + +| Commit | Change and observed result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [`738896d0` — Fix pane focus loops and stale magnification backdrops](https://github.com/cjosewski/waveterm/commit/738896d0a07ccb159c09ccccb44997aa256d51f2) | Fixed reproducible pane-focus and backdrop regressions. The native browser-control problem remained. | +| [`d7a66119` — Fix macOS toolbar clicks after embedded browser focus](https://github.com/cjosewski/waveterm/commit/d7a66119d907bcafe55c19e7a4b9271326d03cc4) | Distinguished renderer focus from native window activation. After rebuilding and restarting, the user confirmed the reported issue fixed. | + +## Symptoms and reproduction + +The browser header controls behaved inconsistently: sometimes they highlighted or responded, sometimes actions were delayed, and eventually mouse clicks stopped working. Keyboard controls often continued working. `Cmd+L` worked in one browser pane, but switching to another could leave both unresponsive. Closing the second pane temporarily restored the first pane's controls. Clicking the magnify icon could make the browser or apparently the whole Wave window unresponsive. + +The practical reproduction sequence was: + +1. Open a browser pane and interact with its page, address bar, navigation buttons, and pane header. +2. Open a second browser pane, then alternate page interaction and toolbar clicks between the two. +3. Try `Cmd+L` and typing in each address bar after mouse controls become unreliable. +4. Magnify and un-magnify a pane using its header icon, including quick toggles; compare with `Cmd+M`. +5. Close the second browser pane and check whether the first recovers. Check terminal interaction as well. + +These were reports of lost interaction and apparent lockups; the investigation did not establish an operating-system deadlock. Process and log checks also mattered: an early test was still using the installed application. Once the first patched native development build was confirmed running, the user still reported dead controls, requiring further investigation. + +## Findings and implementation + +### Pane focus and magnification + +The first patch addressed two independently reproducible defects: + +- `BlockFull` retained click state and reacted to later focus changes through an effect. Two panes could repeatedly reclaim focus from each other. Pane selection now follows actual pointer events, preserves toolbar input focus, and cancels obsolete animation-frame focus retries. See [blockfocus.ts](../frontend/app/block/blockfocus.ts) and its [interaction tests](../frontend/app/block/blockfocus.test.tsx). +- Delayed magnification backdrops could survive a rapid exit from magnification. Initial magnified and ephemeral backdrop states were also swapped. Backdrop timers now cancel when no longer needed, and rendering requires the corresponding pane to exist. See [nodebackdrops.tsx](../frontend/layout/lib/nodebackdrops.tsx) and its [tests](../frontend/layout/lib/nodebackdrops.test.tsx). + +The original focus implementation failed the pane-transfer regression with repeated focus transfers and React's `Maximum update depth exceeded` error. The original backdrop implementation failed three checks involving rapid un-magnification, timer cancellation, and restored layout state. The corrected implementations passed these checks. They did not, by themselves, resolve the native toolbar failure. + +### macOS activation-click filtering + +The remaining cause was the application's macOS first-click handler. Its original sequence was: + +1. Record every renderer DOM `window.focus` event as if the native application window had just become active. +2. If a mouse-down arrived within 50 ms, cancel that mouse-down and its following click. +3. Refocus the clicked pane after 10 ms. + +Moving from an embedded web page to Wave's toolbar can return focus to the host renderer while the native window is already active. That transition wrongly qualified as an activation click, so the intended toolbar action was discarded and focus returned to the page. + +Electron's version-pinned documentation explains that macOS [`WebContents` focus](https://github.com/electron/electron/blob/v41.1.0/docs/api/web-contents.md#event-focus) concerns first-responder changes within a window. Native window focus is a separate [`BaseWindow` event](https://github.com/electron/electron/blob/v41.1.0/docs/api/base-window.md#event-focus). + +The native development log recorded an activation at 09:39:26.837, followed by repeated guest blur/refocus pairs roughly 11–15 ms apart through 09:39:54.523, without another native activation. That timing was consistent with the 10 ms first-click refocus path. The disk log did **not** directly record the renderer's click-cancellation messages; the event-handler reproduction supplied the direct cancellation evidence. + +The follow-up patch records native activation time in [WaveBrowserWindow](../emain/emain-window.ts) and exposes it through the [IPC handler](../emain/emain-ipc.ts), [preload bridge](../emain/preload.ts), API type, and preview mock. The getter returns `null` for an absent, destroyed, or unfocused native window. + +The renderer's [first-click handler](../frontend/app/macos-firstclick.ts) uses DOM focus only as a cheap 50 ms candidate check. Before suppressing a candidate click, it confirms a recent native activation and consumes that activation once. Ordinary clicks outside that transition perform no IPC lookup. The lookup is synchronous so the mouse event can be accepted or canceled before dispatch continues; it only reads existing window state. Electron documents that [`sendSync` blocks the renderer until its reply](https://github.com/electron/electron/blob/v41.1.0/docs/api/ipc-renderer.md#ipcrenderersendsyncchannel-args), which is why it is excluded from ordinary clicks. + +Pending refocus timers cancel on subsequent mouse-down, keyboard input, renderer blur, or disposal. Renderer blur deliberately retains suppression of the paired physical activation click: the handler's own delayed refocus can focus a webview and blur the renderer before mouse-up. Keyboard and programmatic clicks remain usable. The magnify icon's existing, separate 50 ms refocus callback was left unchanged. + +## Verification and limits + +| Check | Result | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Original first-click handler | Both renderer-only focus regressions failed. In 20 repeated focus/click cycles, all 20 mouse-downs were swallowed: **0/20 delivered**. | +| Corrected first-click handler | The same sequence delivered **20/20**, without unwanted refocus. All 14 first-click tests passed. | +| Combined regression coverage | **46 tests passed across six files**: the 14 activation-click checks plus 32 pane-focus, backdrop, and existing layout checks. | +| TypeScript | **17 pre-existing preview diagnostics remained**, also reproduced from a pristine release-base archive. No new diagnostics were introduced. This is not a clean full-project typecheck. | +| Development build | `npm run build:dev` successfully built the Electron main process, preload, and renderer. Native ARM64 backend and CLI compilation also succeeded. | +| Artifact/source checks | Seven renderer/preload source-map entries exactly matched the changed sources: `app.tsx`, `macos-firstclick.ts`, `block.tsx`, `blockfocus.ts`, `TileLayout.tsx`, `nodebackdrops.tsx`, and `preload.ts`. The main bundle contained the native timestamp and guarded IPC response. | +| Independent review | No blocking code-review findings; formatting and `git diff --check` passed. | +| Native confirmation | The user completed testing of the isolated Wave (Dev) build and supplied the confirmation quoted above. | + +The first-click tests cover genuine activation, repeated renderer-only focus, rapid second clicks, webview blur ordering, keyboard/programmatic activation, timer cleanup, and zero IPC calls for ordinary clicks. They exercise the same handler installed by the application. DOM tests cannot establish Electron's complete native event ordering. The user's subsequent native test confirmed that the reported interaction problem was resolved in that build; it did not independently verify every underlying event sequence. Other platforms and packaged distribution were outside this verification. + +The existing TypeScript diagnostics concern missing preview configuration fields, the preview API's missing `getPathForFile`, and process-viewer fixtures missing `numthreads`. The preview API file also changed in this patch, so the accurate result is unchanged baseline diagnostics, not an absence of errors in every edited file. + +## Build, test, and relaunch + +Follow [CONTRIBUTING.md](../CONTRIBUTING.md), its linked [BUILD.md](../BUILD.md), and the repository's [Taskfile.yml](../Taskfile.yml). The following uses versioned commands rather than the local investigation launcher. Install Node.js 22, Go, Task, and a compatible macOS compiler/SDK first. + +```sh +git clone --branch fix/browser-focus https://github.com/cjosewski/waveterm.git +cd waveterm +git show --no-patch --oneline 738896d0 d7a66119 +npm ci + +npx vitest run \ + frontend/app/macos-firstclick.test.ts \ + frontend/app/block/blockfocus.test.tsx \ + frontend/layout/lib/nodebackdrops.test.tsx \ + frontend/layout/tests + +npx tsc --noEmit +npm run build:dev +``` + +At the documented revision, the test command passes 46 tests and `tsc` reports the 17 baseline diagnostics above. `npm ci` uses the committed dependency lockfile. The upstream guide also provides `task init` for full development setup. + +Quit the existing development app completely before starting the rebuilt application. A page reload or renderer hot reload is insufficient because this patch changes the main process and preload API. To use separate Wave data/configuration for testing, create a temporary profile in the same shell: + +```sh +wave_focus_profile="$(mktemp -d "${TMPDIR:-/tmp}/wave-browser-focus.XXXXXX")" +mkdir -p "$wave_focus_profile/data/config" +env -u ELECTRON_RENDERER_URL -u ELECTRON_RUN_AS_NODE \ + WAVETERM_HOME="$wave_focus_profile/data" \ + WAVETERM_DATA_HOME="$wave_focus_profile/data" \ + WAVETERM_CONFIG_HOME="$wave_focus_profile/data/config" \ + task start +``` + +`task start` runs `electron:start`, builds its backend dependencies, and launches the standalone development application. Setting the legacy `WAVETERM_HOME` to the same data directory prevents fallback to an existing legacy profile; keeping configuration under that directory also preserves isolation on repeat launches. The command clears inherited Electron development-server and Node-only launch settings. Confirm the running application is **Wave (Dev)** from this checkout, then repeat the two-pane, address-bar, magnification, and terminal checks above. Retain the same profile path when repeating a test that depends on saved layout state. + +An initial custom launch failed because `WCLOUD_ENDPOINT` was unset. Development backend startup requires a valid endpoint. The launcher was corrected to supply the same three public development defaults as `electron:start`: `WCLOUD_ENDPOINT=https://api-dev.waveterm.dev/central`, `WCLOUD_PING_ENDPOINT=https://ping-dev.waveterm.dev/central`, and `WCLOUD_WS_ENDPOINT=wss://wsapi-dev.waveterm.dev`. A headless backend smoke test with those values reached `WAVESRV-ESTART` and shut down with exit code 0. Using `task start` supplies them automatically; the startup failure was separate from the mouse-control bug. + +On the reporting machine, an inherited `SDKROOT` selected a newer Command Line Tools SDK incompatible with the selected Xcode linker. Native compilation succeeded when that build invocation used the SDK shipped with the selected Xcode installation. Match the compiler and SDK on your machine rather than copying an absolute SDK path. No global SDK or system Node settings were changed. Linker deployment-version warnings remained; the resulting native binaries were used for development testing on that Mac, not validated as a redistributable release. From 8ba21c5e7b36624527786cb074b4a13cf5022650 Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Mon, 9 Nov 2026 08:03:35 -0500 Subject: [PATCH 4/9] Fix platform build workflow compatibility --- .github/workflows/build-helper.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml index a36467de71..f2a30a18c3 100644 --- a/.github/workflows/build-helper.yml +++ b/.github/workflows/build-helper.yml @@ -17,6 +17,7 @@ jobs: outputs: version: ${{ steps.set-version.outputs.WAVETERM_VERSION }} strategy: + fail-fast: false matrix: include: - platform: "darwin" @@ -37,10 +38,12 @@ jobs: run: | sudo apt-get update sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools - sudo snap install snapcraft --classic + # electron-builder still invokes "snapcraft snap", which Snapcraft 9 removed. + sudo snap install snapcraft --classic --channel=8.x/stable sudo snap install lxd sudo lxd init --auto sudo snap refresh + snapcraft --version - name: Install Zig (not Mac) if: matrix.platform != 'darwin' uses: mlugg/setup-zig@v2 From f97858916262a436aa4c169e7a6ee5e65cf467ae Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Mon, 9 Nov 2026 08:56:28 -0500 Subject: [PATCH 5/9] Fix manual macOS and ARM64 packaging Use the initialized LXD provider for Snapcraft so core22 builds run in a matching environment. Apply the runner's LXD group membership to the build. For manual macOS builds without a certificate, remove empty signing inputs and disable identity discovery. Preserve configured credentials and release signing behavior. --- .github/workflows/build-helper.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml index f2a30a18c3..932de36f82 100644 --- a/.github/workflows/build-helper.yml +++ b/.github/workflows/build-helper.yml @@ -42,6 +42,8 @@ jobs: sudo snap install snapcraft --classic --channel=8.x/stable sudo snap install lxd sudo lxd init --auto + sudo usermod -a -G lxd "$USER" + sg lxd -c "lxc list" sudo snap refresh snapcraft --version - name: Install Zig (not Mac) @@ -129,16 +131,21 @@ jobs: # Build and upload packages - name: Build (Linux) if: matrix.platform == 'linux' - run: task package + run: sg lxd -c "task package" env: USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - SNAPCRAFT_BUILD_ENVIRONMENT: host + SNAPCRAFT_BUILD_ENVIRONMENT: lxd # Retry Darwin build in case of notarization failures - uses: nick-fields/retry@v4 name: Build (Darwin) if: matrix.platform == 'darwin' with: - command: task package + command: | + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ -z "${CSC_LINK:-}" ]; then + unset CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID + export CSC_IDENTITY_AUTO_DISCOVERY=false + fi + task package timeout_minutes: 120 retry_on: error max_attempts: 3 From 754a520bd34fc14dba65c05dd36f63c505732d66 Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Mon, 9 Nov 2026 09:19:14 -0500 Subject: [PATCH 6/9] Restore LXD networking for Linux ARM64 packaging Allow traffic from the LXD bridge and established return traffic through Docker's forwarding chains without changing the global firewall policy. Log the runner network configuration and check guest DNS and HTTPS before compilation. Limit the preflight to ARM64, which requires native Snapcraft packaging rather than the x64 Snap template. --- .github/workflows/build-helper.yml | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml index 932de36f82..588f0d576c 100644 --- a/.github/workflows/build-helper.yml +++ b/.github/workflows/build-helper.yml @@ -46,6 +46,39 @@ jobs: sg lxd -c "lxc list" sudo snap refresh snapcraft --version + - name: Verify LXD networking (Linux ARM64 only) + if: matrix.runner == 'ubuntu-24.04-arm' + timeout-minutes: 5 + run: | + sg lxd -c "lxc network show lxdbr0" + ip -brief addr show dev lxdbr0 + # Docker's forwarding rules can block the ARM64 Snapcraft build container. + for firewall in iptables ip6tables; do + if command -v "$firewall" >/dev/null; then + sudo "$firewall" -w 5 -S FORWARD || true + if sudo "$firewall" -w 5 -S DOCKER-USER >/dev/null 2>&1; then + sudo "$firewall" -w 5 -I DOCKER-USER -i lxdbr0 -j ACCEPT + sudo "$firewall" -w 5 -I DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + sudo "$firewall" -w 5 -S DOCKER-USER + fi + fi + done + probe="wave-network-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + timeout 20s sg lxd -c "lxc delete --force '$probe'" || true + } + trap cleanup EXIT + timeout 120s sg lxd -c "lxc launch ubuntu:22.04 '$probe'" + network_ready=false + for attempt in 1 2 3; do + if timeout 15s sg lxd -c "lxc exec '$probe' -- timeout 10s getent hosts snapcraft.io" && + timeout 15s sg lxd -c "lxc exec '$probe' -- timeout 10s bash -c 'exec 3<> /dev/tcp/snapcraft.io/443'"; then + network_ready=true + break + fi + sleep 5 + done + [ "$network_ready" = true ] - name: Install Zig (not Mac) if: matrix.platform != 'darwin' uses: mlugg/setup-zig@v2 From 6e8e49962a94a24daa964a9d911b48c0b1855cd0 Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Mon, 9 Nov 2026 10:51:52 -0500 Subject: [PATCH 7/9] Add the hands-on Mac testing checklist Preserve the reviewed test sequence and results sheet in the shared documentation, with portable links to the completed Mac downloads. Explain how to open the intended test copy and keep hands-on results pending before submission to the main project. --- docs/browser-controls-macos-checklist.md | 105 +++++++++++++++++++++++ docs/browser-controls-macos.md | 2 + 2 files changed, 107 insertions(+) create mode 100644 docs/browser-controls-macos-checklist.md diff --git a/docs/browser-controls-macos-checklist.md b/docs/browser-controls-macos-checklist.md new file mode 100644 index 0000000000..3a75e4af10 --- /dev/null +++ b/docs/browser-controls-macos-checklist.md @@ -0,0 +1,105 @@ +# Hands-on Mac check + +Allow about 10–15 minutes for the checks, plus downloading and setup. This checks whether browser buttons and typing stay responsive in the newly built copy. + +**Not yet performed:** these hands-on checks on the new Mac packages. Earlier automated checks passed: 49 focused tests and checks of all 19 downloadable packages. The earlier development copy was also confirmed fixed on the reporting Mac; that does not replace testing this new copy. + +Use the November 9 test build linked below. Its [automated build completed successfully for all platforms](https://github.com/cjosewski/waveterm/actions/runs/42633567395). Submission to the main Wave Terminal project remains on hold until the hands-on results are reviewed. + +## 1. Open the correct test copy — about 2 minutes + +1. Open **Apple menu → About This Mac**. If it shows **Chip** with an Apple name, choose the Apple-chip download. If it shows **Processor** with Intel, choose Intel. [Apple’s identification guide](https://support.apple.com/en-us/116943). +2. Download the [Mac build bundle](https://github.com/cjosewski/waveterm/actions/runs/42633567395/artifacts/13683260804); sign-in may be required. Unpack that bundle, then choose the inner ZIP marked **arm64** for a Mac with an Apple chip or **x64** for a Mac with an Intel processor. If the download is no longer available, mark setup **Blocked** and request a current test copy. +3. Save your work and **Quit Wave completely**. Closing its window is not enough. Keep your installed copy. Unpack the chosen ZIP inside a new folder for this check; do not replace the installed app. +4. Open the Wave app inside that new folder directly in Finder. Do not use the Dock or search to launch it. Both old and new copies may show **0.14.5**, so the version number alone does not identify this test copy. +5. This separate app copy **still uses your existing Wave settings, history, and saved work**. Use a new, throwaway tab for the checks. Do not delete saved data or change your usual settings. +6. If Wave offers an update, postpone it until after this check so you keep testing the supplied copy. + +These test builds have not been approved through Apple’s distribution checks. If Apple says it cannot verify the developer or check the app, and this is the supplied test copy you intended to open: first attempt to open it, then use **System Settings → Privacy & Security → Open Anyway**, and confirm **Open**. Follow [Apple’s app-specific instructions](https://support.apple.com/en-us/102445). If the warning says the app is damaged or will harm your computer, or there is no app-specific option, **stop and report the exact message**. Do not turn off security protections. + +## 2. Set up a throwaway tab — about 1 minute + +1. Choose **New Tab**, or press **Command+T**. +2. In the side list, click the globe entry labeled **web** twice to create two browser panes. Call them **A** and **B** for this check. Add a **terminal** pane—the panel where you type commands—or reuse the new tab’s terminal if it already has one. +3. In each browser pane, use its address bar to visit **example.com**, then **example.org**, waiting for each page to load. This gives both panes a working Back and Forward history. A disabled arrow with no history is normal. +4. Bring Wave to the front by clicking an empty part of its top bar, away from buttons. + +**What counts as a missed click:** while Wave is already at the front, click inside a web page, then click a browser control once. That action should work without a second click. The first click when returning from a different Mac app may only bring Wave forward; do not count that normal activation click as the browser defect. + +## 3. Check the first toolbar click — about 3 minutes + +Do **five Back/Forward pairs in A, then five in B**. Keep Wave at the front throughout. Finish all of these pairs before the address-bar checks below. + +1. Click inside the page, away from links. Move straight to the **left-pointing Back arrow** and click once. The address should change to example.com. +2. After it loads, click inside the page again, then click the **right-pointing Forward arrow** once. It should return to example.org. + +Use single clicks; record a miss before trying again. Do not click some other part of the toolbar first. This gives **20 measured Back/Forward actions**. + +**After all 20 actions**, check the address bar **five times in A, then five times in B**: click inside the page, then click the address bar once. Press **Command+A**, type **example.org**, and press Return. Typing must stay in that address bar until you press Return. Keeping these ten submissions after the navigation checks prevents them from changing the history being tested. + +In each pane, also click the page and then click the **circular Reload arrow** once. The page should reload and remain usable. If loading is too fast to tell, mark that item **Not checked** and explain why. + +**Pass:** every measured first click works, no delayed action surprises you, and typing does not disappear or land in the other pane. + +## 4. Switch panes and keep typing — about 2 minutes + +Alternate between A and B for **ten switches**: + +1. Click inside the other pane’s page, then click its address bar once. +2. Press Command+A and type **example**. Wait one second, then type **.org**. Check that the complete address stays in that same field, then press Return. +3. Watch the other pane: it should not take over your typing or repeatedly become selected by itself. + +After the mouse checks, **click inside A’s page**, then press **Command+L**. Repeat by **clicking inside B’s page** before pressing Command+L. Each shortcut should select that pane’s address bar and allow typing. Record any earlier missed mouse click even if this keyboard shortcut works. + +**Pass:** the pane you chose keeps your typing; both panes remain responsive through all ten switches. + +## 5. Enlarge, restore, and return to the tab — about 2 minutes + +Complete **five enlarge/restore rounds in total**: four quick rounds with the controls, then one round that also checks returning to the tab. + +1. For each of **rounds 1–4**, click inside A’s page, then use its header control labeled **Magnify** when you pause the pointer over it. The pane should enlarge; the surrounding area may dim or blur. +2. Use the same pane’s control, now labeled **Minimize**, to restore it, as quickly as is comfortable. After each round, wait one second, then click B’s address bar and type an address. Count the first enlarge/restore pair as round 1. +3. For **round 5**, enlarge A. Create another throwaway tab with Command+T, then click the original test tab in the tab list to return. +4. A should still be enlarged. Click once in the dimmed area outside A to restore the normal layout, completing round 5. Check B’s address bar again. + +**Pass:** each pane enlarges and restores as expected. After restoring, no dim or invisible layer remains that prevents clicks. Returning to the tab does not dismiss the wrong pane or leave controls blocked. + +## 6. Return from another app, then close one test pane — about 2 minutes + +1. Switch to Finder. Return by clicking an empty part of Wave’s top bar, away from buttons. Then click A’s page and its address bar, and type an address. Repeat **three times**. The toolbar click being tested is the one after Wave is already in front. +2. Enlarge **B**, then close **only B** using the **X in B’s own pane header**. Do not close the tab or app window. A and the terminal should remain. +3. In A, click the page, then the address bar, and type again. Closing B should not be necessary to make A work; A should already have worked in the earlier checks. +4. Click the terminal, type a few harmless letters such as **check**, and remove them with Backspace. **Do not press Return.** Repeat after clicking A and returning to the terminal. + +**Pass:** returning from another app does not make controls stay unresponsive; closing B leaves no blocking layer; letters appear only in the pane you selected. + +## If anything fails + +Stop repeating the failed action once you have a clear example. Record the section, the last few clicks, whether Wave was already in front, and whether a second click or Command+L worked. A short recording is useful, but keep private pages and information out of it. + +If the app will not open, the needed controls are missing, or the example pages cannot load, mark the test **blocked**, not passed or failed. If Wave stops responding, stop the check and report what happened; do not delete its settings or saved data. + +An optional comparison with your usual installed copy can help: fully quit this test copy first, open the installed one directly, and repeat just the first-click sequence. Keep those results separate. Do not run both copies together. This comparison is not required to complete the checklist. + +## Send back these results + +Use **Pass**, **Fail**, **Blocked**, or **Not checked**. Mark each item **Not checked** until you perform it; do not treat an uncertain observation as a pass. + +| Check | Result / count | +| ---------------------------------------------------------- | ------------------ | +| Mac type and macOS version | | +| Test date; opened the newly unpacked copy directly | | +| Back/Forward first-click actions | \_\_\_ / 20 worked | +| Address-bar clicks and typing | | +| Reload in both panes | | +| Switching panes | \_\_\_ / 10 worked | +| Command+L in both panes | | +| Enlarge/restore rounds | \_\_\_ / 5 worked | +| Enlarged pane after leaving and returning to its tab | | +| Returning from Finder | \_\_\_ / 3 worked | +| Closing only B; A and terminal still work | | +| Any missed click, lost typing, leftover dimming, or freeze | | + +If something failed: **What did you click immediately before it? Was Wave already in front? Did a second click or Command+L help?** + +Optional Windows/Linux spot check: in a throwaway tab, repeat the mouse-only parts of sections 3–5 and the harmless terminal typing check using the matching new build. These do not replace the Mac checks; record them separately. diff --git a/docs/browser-controls-macos.md b/docs/browser-controls-macos.md index 8f085599f5..fe6bc66b6a 100644 --- a/docs/browser-controls-macos.md +++ b/docs/browser-controls-macos.md @@ -6,6 +6,8 @@ This report records the browser-control failure, two corrective commits, verification, and reproduction steps. The confirmation applies to the tested development build on the reporting Mac. It does not establish that an upstream release or a packaged installer contains these changes. +For the newly packaged builds, use the [hands-on Mac checklist](./browser-controls-macos-checklist.md). Those checks remain unperformed; the earlier development-build confirmation above does not establish a pass for the new packages. + ## Environment and changes | Item | Investigated configuration | From 33ddd5ed7c2f843d4fd2afa2e796b2d1405087da Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Mon, 9 Nov 2026 11:53:10 -0500 Subject: [PATCH 8/9] docs: record passing macOS browser controls checks --- docs/browser-controls-macos-checklist.md | 102 ++++++++++++++++------- 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/docs/browser-controls-macos-checklist.md b/docs/browser-controls-macos-checklist.md index 3a75e4af10..f1f2ad0aa6 100644 --- a/docs/browser-controls-macos-checklist.md +++ b/docs/browser-controls-macos-checklist.md @@ -1,105 +1,145 @@ # Hands-on Mac check + Allow about 10–15 minutes for the checks, plus downloading and setup. This checks whether browser buttons and typing stay responsive in the newly built copy. + **Not yet performed:** these hands-on checks on the new Mac packages. Earlier automated checks passed: 49 focused tests and checks of all 19 downloadable packages. The earlier development copy was also confirmed fixed on the reporting Mac; that does not replace testing this new copy. + Use the November 9 test build linked below. Its [automated build completed successfully for all platforms](https://github.com/cjosewski/waveterm/actions/runs/42633567395). Submission to the main Wave Terminal project remains on hold until the hands-on results are reviewed. + ## 1. Open the correct test copy — about 2 minutes -1. Open **Apple menu → About This Mac**. If it shows **Chip** with an Apple name, choose the Apple-chip download. If it shows **Processor** with Intel, choose Intel. [Apple’s identification guide](https://support.apple.com/en-us/116943). -2. Download the [Mac build bundle](https://github.com/cjosewski/waveterm/actions/runs/42633567395/artifacts/13683260804); sign-in may be required. Unpack that bundle, then choose the inner ZIP marked **arm64** for a Mac with an Apple chip or **x64** for a Mac with an Intel processor. If the download is no longer available, mark setup **Blocked** and request a current test copy. -3. Save your work and **Quit Wave completely**. Closing its window is not enough. Keep your installed copy. Unpack the chosen ZIP inside a new folder for this check; do not replace the installed app. -4. Open the Wave app inside that new folder directly in Finder. Do not use the Dock or search to launch it. Both old and new copies may show **0.14.5**, so the version number alone does not identify this test copy. -5. This separate app copy **still uses your existing Wave settings, history, and saved work**. Use a new, throwaway tab for the checks. Do not delete saved data or change your usual settings. + +1. Open Apple menu → About This Mac. If it shows Chip with an Apple name, choose the Apple-chip download. If it shows Processor with Intel, choose Intel. Apple’s identification guide. +2. Download the Mac build bundle; sign-in may be required. Unpack that bundle, then choose the inner ZIP marked arm64 for a Mac with an Apple chip or x64 for a Mac with an Intel processor. If the download is no longer available, mark setup Blocked and request a current test copy. +3. Save your work and Quit Wave completely. Closing its window is not enough. Keep your installed copy. Unpack the chosen ZIP inside a new folder for this check; do not replace the installed app. +4. Open the Wave app inside that new folder directly in Finder. Do not use the Dock or search to launch it. Both old and new copies may show 0.14.5, so the version number alone does not identify this test copy. +5. This separate app copy still uses your existing Wave settings, history, and saved work. Use a new, throwaway tab for the checks. Do not delete saved data or change your usual settings. 6. If Wave offers an update, postpone it until after this check so you keep testing the supplied copy. + These test builds have not been approved through Apple’s distribution checks. If Apple says it cannot verify the developer or check the app, and this is the supplied test copy you intended to open: first attempt to open it, then use **System Settings → Privacy & Security → Open Anyway**, and confirm **Open**. Follow [Apple’s app-specific instructions](https://support.apple.com/en-us/102445). If the warning says the app is damaged or will harm your computer, or there is no app-specific option, **stop and report the exact message**. Do not turn off security protections. + ## 2. Set up a throwaway tab — about 1 minute -1. Choose **New Tab**, or press **Command+T**. -2. In the side list, click the globe entry labeled **web** twice to create two browser panes. Call them **A** and **B** for this check. Add a **terminal** pane—the panel where you type commands—or reuse the new tab’s terminal if it already has one. -3. In each browser pane, use its address bar to visit **example.com**, then **example.org**, waiting for each page to load. This gives both panes a working Back and Forward history. A disabled arrow with no history is normal. + +1. Choose New Tab, or press Command+T. +2. In the side list, click the globe entry labeled web twice to create two browser panes. Call them A and B for this check. Add a terminal pane—the panel where you type commands—or reuse the new tab’s terminal if it already has one. +3. In each browser pane, use its address bar to visit example.com, then example.org, waiting for each page to load. This gives both panes a working Back and Forward history. A disabled arrow with no history is normal. 4. Bring Wave to the front by clicking an empty part of its top bar, away from buttons. + **What counts as a missed click:** while Wave is already at the front, click inside a web page, then click a browser control once. That action should work without a second click. The first click when returning from a different Mac app may only bring Wave forward; do not count that normal activation click as the browser defect. + ## 3. Check the first toolbar click — about 3 minutes + Do **five Back/Forward pairs in A, then five in B**. Keep Wave at the front throughout. Finish all of these pairs before the address-bar checks below. -1. Click inside the page, away from links. Move straight to the **left-pointing Back arrow** and click once. The address should change to example.com. -2. After it loads, click inside the page again, then click the **right-pointing Forward arrow** once. It should return to example.org. + +1. Click inside the page, away from links. Move straight to the left-pointing Back arrow and click once. The address should change to example.com. +2. After it loads, click inside the page again, then click the right-pointing Forward arrow once. It should return to example.org. + Use single clicks; record a miss before trying again. Do not click some other part of the toolbar first. This gives **20 measured Back/Forward actions**. + **After all 20 actions**, check the address bar **five times in A, then five times in B**: click inside the page, then click the address bar once. Press **Command+A**, type **example.org**, and press Return. Typing must stay in that address bar until you press Return. Keeping these ten submissions after the navigation checks prevents them from changing the history being tested. + In each pane, also click the page and then click the **circular Reload arrow** once. The page should reload and remain usable. If loading is too fast to tell, mark that item **Not checked** and explain why. + **Pass:** every measured first click works, no delayed action surprises you, and typing does not disappear or land in the other pane. + ## 4. Switch panes and keep typing — about 2 minutes + Alternate between A and B for **ten switches**: + 1. Click inside the other pane’s page, then click its address bar once. -2. Press Command+A and type **example**. Wait one second, then type **.org**. Check that the complete address stays in that same field, then press Return. +2. Press Command+A and type example. Wait one second, then type .org. Check that the complete address stays in that same field, then press Return. 3. Watch the other pane: it should not take over your typing or repeatedly become selected by itself. + After the mouse checks, **click inside A’s page**, then press **Command+L**. Repeat by **clicking inside B’s page** before pressing Command+L. Each shortcut should select that pane’s address bar and allow typing. Record any earlier missed mouse click even if this keyboard shortcut works. + **Pass:** the pane you chose keeps your typing; both panes remain responsive through all ten switches. + ## 5. Enlarge, restore, and return to the tab — about 2 minutes + Complete **five enlarge/restore rounds in total**: four quick rounds with the controls, then one round that also checks returning to the tab. -1. For each of **rounds 1–4**, click inside A’s page, then use its header control labeled **Magnify** when you pause the pointer over it. The pane should enlarge; the surrounding area may dim or blur. -2. Use the same pane’s control, now labeled **Minimize**, to restore it, as quickly as is comfortable. After each round, wait one second, then click B’s address bar and type an address. Count the first enlarge/restore pair as round 1. -3. For **round 5**, enlarge A. Create another throwaway tab with Command+T, then click the original test tab in the tab list to return. + +1. For each of rounds 1–4, click inside A’s page, then use its header control labeled Magnify when you pause the pointer over it. The pane should enlarge; the surrounding area may dim or blur. +2. Use the same pane’s control, now labeled Minimize, to restore it, as quickly as is comfortable. After each round, wait one second, then click B’s address bar and type an address. Count the first enlarge/restore pair as round 1. +3. For round 5, enlarge A. Create another throwaway tab with Command+T, then click the original test tab in the tab list to return. 4. A should still be enlarged. Click once in the dimmed area outside A to restore the normal layout, completing round 5. Check B’s address bar again. + **Pass:** each pane enlarges and restores as expected. After restoring, no dim or invisible layer remains that prevents clicks. Returning to the tab does not dismiss the wrong pane or leave controls blocked. + ## 6. Return from another app, then close one test pane — about 2 minutes -1. Switch to Finder. Return by clicking an empty part of Wave’s top bar, away from buttons. Then click A’s page and its address bar, and type an address. Repeat **three times**. The toolbar click being tested is the one after Wave is already in front. -2. Enlarge **B**, then close **only B** using the **X in B’s own pane header**. Do not close the tab or app window. A and the terminal should remain. + +1. Switch to Finder. Return by clicking an empty part of Wave’s top bar, away from buttons. Then click A’s page and its address bar, and type an address. Repeat three times. The toolbar click being tested is the one after Wave is already in front. +2. Enlarge B, then close only B using the X in B’s own pane header. Do not close the tab or app window. A and the terminal should remain. 3. In A, click the page, then the address bar, and type again. Closing B should not be necessary to make A work; A should already have worked in the earlier checks. -4. Click the terminal, type a few harmless letters such as **check**, and remove them with Backspace. **Do not press Return.** Repeat after clicking A and returning to the terminal. +4. Click the terminal, type a few harmless letters such as check, and remove them with Backspace. Do not press Return. Repeat after clicking A and returning to the terminal. + **Pass:** returning from another app does not make controls stay unresponsive; closing B leaves no blocking layer; letters appear only in the pane you selected. + ## If anything fails + Stop repeating the failed action once you have a clear example. Record the section, the last few clicks, whether Wave was already in front, and whether a second click or Command+L worked. A short recording is useful, but keep private pages and information out of it. + If the app will not open, the needed controls are missing, or the example pages cannot load, mark the test **blocked**, not passed or failed. If Wave stops responding, stop the check and report what happened; do not delete its settings or saved data. + An optional comparison with your usual installed copy can help: fully quit this test copy first, open the installed one directly, and repeat just the first-click sequence. Keep those results separate. Do not run both copies together. This comparison is not required to complete the checklist. + ## Send back these results + Use **Pass**, **Fail**, **Blocked**, or **Not checked**. Mark each item **Not checked** until you perform it; do not treat an uncertain observation as a pass. -| Check | Result / count | -| ---------------------------------------------------------- | ------------------ | -| Mac type and macOS version | | -| Test date; opened the newly unpacked copy directly | | -| Back/Forward first-click actions | \_\_\_ / 20 worked | -| Address-bar clicks and typing | | -| Reload in both panes | | -| Switching panes | \_\_\_ / 10 worked | -| Command+L in both panes | | -| Enlarge/restore rounds | \_\_\_ / 5 worked | -| Enlarged pane after leaving and returning to its tab | | -| Returning from Finder | \_\_\_ / 3 worked | -| Closing only B; A and terminal still work | | -| Any missed click, lost typing, leftover dimming, or freeze | | + +| Check | Result / count | +| --- | --- | +| Mac type and macOS version | Mac Studio Pro  27.0.1 | +| Test date; opened the newly unpacked copy directly | 11/9/26 | +| Back/Forward first-click actions | __20_ / 20 worked | +| Address-bar clicks and typing | Green | +| Reload in both panes | Green | +| Switching panes | ___10 / 10 worked | +| Command+L in both panes | Green | +| Enlarge/restore rounds | __5_ / 5 worked | +| Enlarged pane after leaving and returning to its tab | Green | +| Returning from Finder | _3__ / 3 worked | +| Closing only B; A and terminal still work | Green | +| Any missed click, lost typing, leftover dimming, or freeze | None. | + + If something failed: **What did you click immediately before it? Was Wave already in front? Did a second click or Command+L help?** + Optional Windows/Linux spot check: in a throwaway tab, repeat the mouse-only parts of sections 3–5 and the harmless terminal typing check using the matching new build. These do not replace the Mac checks; record them separately. + + From 87a402eb0bbb81864aba08628f54ec487ff22431 Mon Sep 17 00:00:00 2001 From: Chad Josewski Date: Mon, 9 Nov 2026 12:20:21 -0500 Subject: [PATCH 9/9] Record the completed Mac checks for upstream review Update the checklist status to reflect the user's passing report and restore its reference links. Preserve the saved results verbatim and link the earlier investigation to the newly recorded results. --- docs/browser-controls-macos-checklist.md | 8 ++++---- docs/browser-controls-macos.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/browser-controls-macos-checklist.md b/docs/browser-controls-macos-checklist.md index f1f2ad0aa6..a8b4887f24 100644 --- a/docs/browser-controls-macos-checklist.md +++ b/docs/browser-controls-macos-checklist.md @@ -4,17 +4,17 @@ Allow about 10–15 minutes for the checks, plus downloading and setup. This checks whether browser buttons and typing stay responsive in the newly built copy. -**Not yet performed:** these hands-on checks on the new Mac packages. Earlier automated checks passed: 49 focused tests and checks of all 19 downloadable packages. The earlier development copy was also confirmed fixed on the reporting Mac; that does not replace testing this new copy. +**Hands-on result: passed, as reported by the tester on November 9, 2026.** The saved results below identify the test machine as “Mac Studio Pro” running macOS 27.0.1, with all recorded checks passing and no missed clicks, lost typing, leftover dimming, or freezes reported. This records the result for that Mac; it does not establish hands-on results for other machines. Earlier automated checks passed: 49 focused tests and checks of all 19 downloadable packages. -Use the November 9 test build linked below. Its [automated build completed successfully for all platforms](https://github.com/cjosewski/waveterm/actions/runs/42633567395). Submission to the main Wave Terminal project remains on hold until the hands-on results are reviewed. +Use the November 9 test build linked below. Its [automated build completed successfully for all platforms](https://github.com/cjosewski/waveterm/actions/runs/42633567395). The requested submission to the main Wave Terminal project can now proceed with these user-reported results. ## 1. Open the correct test copy — about 2 minutes -1. Open Apple menu → About This Mac. If it shows Chip with an Apple name, choose the Apple-chip download. If it shows Processor with Intel, choose Intel. Apple’s identification guide. -2. Download the Mac build bundle; sign-in may be required. Unpack that bundle, then choose the inner ZIP marked arm64 for a Mac with an Apple chip or x64 for a Mac with an Intel processor. If the download is no longer available, mark setup Blocked and request a current test copy. +1. Open Apple menu → About This Mac. If it shows Chip with an Apple name, choose the Apple-chip download. If it shows Processor with Intel, choose Intel. [Apple’s identification guide](https://support.apple.com/en-us/116943). +2. Download the [Mac build bundle](https://github.com/cjosewski/waveterm/actions/runs/42633567395/artifacts/13683260804); sign-in may be required. Unpack that bundle, then choose the inner ZIP marked arm64 for a Mac with an Apple chip or x64 for a Mac with an Intel processor. If the download is no longer available, mark setup Blocked and request a current test copy. 3. Save your work and Quit Wave completely. Closing its window is not enough. Keep your installed copy. Unpack the chosen ZIP inside a new folder for this check; do not replace the installed app. 4. Open the Wave app inside that new folder directly in Finder. Do not use the Dock or search to launch it. Both old and new copies may show 0.14.5, so the version number alone does not identify this test copy. 5. This separate app copy still uses your existing Wave settings, history, and saved work. Use a new, throwaway tab for the checks. Do not delete saved data or change your usual settings. diff --git a/docs/browser-controls-macos.md b/docs/browser-controls-macos.md index fe6bc66b6a..d0c135e1da 100644 --- a/docs/browser-controls-macos.md +++ b/docs/browser-controls-macos.md @@ -6,7 +6,7 @@ This report records the browser-control failure, two corrective commits, verification, and reproduction steps. The confirmation applies to the tested development build on the reporting Mac. It does not establish that an upstream release or a packaged installer contains these changes. -For the newly packaged builds, use the [hands-on Mac checklist](./browser-controls-macos-checklist.md). Those checks remain unperformed; the earlier development-build confirmation above does not establish a pass for the new packages. +For the newly packaged builds, use the [hands-on Mac checklist](./browser-controls-macos-checklist.md). The checklist now records a passing result reported by the user on November 9, 2026; this is separate from the earlier development-build confirmation above. ## Environment and changes