diff --git a/apps/extension/src/content.test.ts b/apps/extension/src/content.test.ts index 4e1ff2c..d501e6e 100644 --- a/apps/extension/src/content.test.ts +++ b/apps/extension/src/content.test.ts @@ -124,6 +124,63 @@ async function page( } describe("page-local AgentOnWeb visibility", () => { + it("applies an acknowledged mode click after a page update even without a background broadcast", async () => { + const p = await page(connected); + // SPA/HMR page replacement must not make mode buttons depend on receiving + // an unsolicited broadcast: the mode.set reply already confirms the mode. + p.window.document.body.replaceChildren(p.window.document.createElement("main")); + p.sendMessage.mockImplementation(async request => ({ ok: true, + result: request.type === "mode.set" ? { ...connected, mode: request.mode } : connected, + })); + p.shadow.querySelector(".surface-toggle")!.click(); + for (const mode of ["focus", "watch", "chill"] as const) { + p.shadow.querySelector(`[aria-label^="${mode[0]!.toUpperCase() + mode.slice(1)} mode"]`)!.click(); + await vi.waitFor(() => expect(p.host.dataset.mode).toBe(mode)); + expect(p.frame.hidden).toBe(mode === "watch"); + } + }); + + it("ignores an older mode click reply and preserves newer presentation state", async () => { + const p = await page(connected); + const replies = new Map void>(); + p.sendMessage.mockImplementation(request => request.type === "mode.set" + ? new Promise(resolve => replies.set(request.mode, resolve)) + : Promise.resolve({ ok: true, result: connected })); + p.shadow.querySelector(".surface-toggle")!.click(); + p.shadow.querySelector('[aria-label^="Focus mode"]')!.click(); + p.shadow.querySelector('[aria-label^="Watch mode"]')!.click(); + p.emit("state.update", { ...connected, opacity: 0.8, agentSessions: [{ + id: "pi:new", terminalId: "terminal", runtimeId: "native-test", agent: "Pi", title: "New live task", + detail: "", status: "running", updatedAt: Date.now(), attentionId: "", + }] }); + replies.get("watch")!({ ok: true, result: { ...connected, mode: "watch" } }); + await vi.waitFor(() => expect(p.host.dataset.mode).toBe("watch")); + replies.get("focus")!({ ok: true, result: { ...connected, mode: "focus" } }); + await Promise.resolve(); + expect(p.host.dataset.mode).toBe("watch"); + expect(p.host.style.getPropertyValue("--agentonweb-surface-opacity")).toBe("0.8"); + expect(p.shadow.querySelector(".agent-session")!.textContent).toContain("New live task"); + }); + + it("lets a later keyboard command supersede a pending mode click and ignores failed replies", async () => { + const p = await page(connected); + let reply!: (value: { ok: boolean; result: SurfaceViewState }) => void; + p.sendMessage.mockImplementation(request => request.type === "mode.set" + ? new Promise(resolve => { reply = resolve; }) + : Promise.resolve({ ok: true, result: connected })); + p.shadow.querySelector(".surface-toggle")!.click(); + const focus = p.shadow.querySelector('[aria-label^="Focus mode"]')!; + focus.click(); + p.emit("surface.show", { ...connected, mode: "watch" }); + reply({ ok: true, result: { ...connected, mode: "focus" } }); + await Promise.resolve(); + expect(p.host.dataset.mode).toBe("watch"); + focus.click(); + reply({ ok: false, result: { ...connected, mode: "focus" } }); + await Promise.resolve(); + expect(p.host.dataset.mode).toBe("watch"); + }); + it("keeps the active iframe visible and focused across status and presentation updates", async () => { const p = await page(connected); p.frame.focus(); diff --git a/apps/extension/src/content.tsx b/apps/extension/src/content.tsx index 5f5daa3..0a5a0e5 100644 --- a/apps/extension/src/content.tsx +++ b/apps/extension/src/content.tsx @@ -65,6 +65,7 @@ if (!document.getElementById(HOST_ID)) { let receivedUpdate = false; let pendingSurfaceFocus = false; let requestedMode: AgentOnWebMode | undefined; + let modeRequestRevision = 0; let previousFocus = document.activeElement instanceof HTMLElement && document.activeElement !== host ? document.activeElement : undefined; const optionLatch = new DoubleTapLatch(); @@ -117,7 +118,16 @@ if (!document.getElementById(HOST_ID)) { optionLatch.reset(); setVisible(true, false); requestedMode = mode; - send({ source: "agentonweb-content", type: "mode.set", mode }); + const revision = ++modeRequestRevision; + // A page transition can miss the active-tab broadcast. The request reply + // also acknowledges this selection; apply only its mode so live session + // and connection updates received in the meantime remain intact. + void browser.runtime.sendMessage({ source: "agentonweb-content", type: "mode.set", mode } satisfies ContentRequest) + .then((response: { ok?: boolean; result?: SurfaceViewState } | undefined) => { + if (revision !== modeRequestRevision || !response?.ok || response.result?.mode !== mode) return; + render({ ...state, mode }); + if (requestedMode === mode) { requestedMode = undefined; focusForMode(); } + }).catch(() => {}); }; // The background owns both keyboard paths, including deduplication and the // explicit reveal of this tab. Mode buttons keep their existing direct path. @@ -286,6 +296,8 @@ if (!document.getElementById(HOST_ID)) { if (requestedMode === state.mode) { requestedMode = undefined; focusForMode(); } } if (isSurfaceCommand(message)) { + // A later keyboard/open command supersedes any pending button reply. + modeRequestRevision++; receivedUpdate = true; const next = message.type === "surface.toggle" ? !visible : true; setVisible(next, message.type === "surface.toggle", message.state); diff --git a/docs/verification/arc-pi-mode-2026-10-19.md b/docs/verification/arc-pi-mode-2026-10-19.md new file mode 100644 index 0000000..5726496 --- /dev/null +++ b/docs/verification/arc-pi-mode-2026-10-19.md @@ -0,0 +1,31 @@ +# Arc Pi notifications and mode clicks — 2026-10-19 + +## Findings and changes + +- The installed Pi is 1.1.0. Its default configuration had no `extensions/agentonweb.ts`, so ordinary Pi sessions could not report lifecycle events. Installed the repository's observational extension with `node packages/terminal-host/lib/cli.js pi-extension install`. Model settings and existing packages were preserved. Existing Pi processes need `/reload` or a new session. +- Mode buttons sent `mode.set` but ignored its acknowledged result, relying entirely on an unsolicited background broadcast. Keyboard commands additionally receive a targeted `surface.show`. A deterministic test reproduces the button staying in Chill after a successful Focus acknowledgement when the broadcast is absent. +- Buttons now apply the mode confirmed by their own request. A revision guard rejects stale click replies and replies superseded by keyboard/open commands. Only the mode is applied, preserving newer connection, session and opacity updates. +- Ordinary page body replacement did not remove the overlay. The user's specific hot-update framework/page was not available, so hot reload itself is not established as the cause. Missing broadcast delivery is the reproduced failure condition and repaired path. + +## Automated verification + +- Before the fix: `node node_modules/vitest/vitest.mjs run apps/extension/src/content.test.ts` failed the two new acknowledgement tests: expected Focus/Watch, received Chill. +- After the fix: the complete suite passed **386 tests**, with one existing optional DSH test skipped, across 56 files. This includes real Pi RPC and authenticated host/PTY lifecycle integration. +- Three regression scenarios cover successful acknowledgement without broadcast after body replacement, out-of-order replies preserving newer state, and keyboard precedence/failed replies. +- TypeScript, architecture and privacy checks, the Chrome extension build and `git diff --check` passed. The final content test was rerun after extending its assertion to preserve live Pi session state. +- The DSH smoke test was also verified against native DSH **0.2.1-rc.2**. Its account-first onboarding shows **Get started → Close**, while older releases show **Configure later**. The test now handles both and opens Settings through the account menu when present. The original command reproduced the old button timeout; the updated command passed plugin installation, authenticated API checks and the native AgentOnWeb settings page with no page errors. + +## Native Arc verification + +Reloaded Arc's existing unpacked AgentOnWeb extension `kphdajdklejladebbpbjmghcjflhdpnl`, loaded from `apps/extension/dist`, with the rebuilt content script. The existing terminal host remained running; it was not restarted. + +Used a new verification shell, native Pi 1.1.0 and an isolated local deterministic model endpoint. Its temporary configuration loaded the exact installed AgentOnWeb loader. No paid provider, user model credentials or project task was used. + +1. A real Pi turn completed in Watch and produced a **Completed** page notification and a single Pi session row. +2. Clicking the notification selected the original Pi terminal with the completed answer. +3. Replacing the website body retained the overlay; coordinate mouse clicks switched Focus, Chill and Watch. +4. Reloading the whole browser page retained the native Pi process and did not replay the dismissed notification. A second real turn produced a new Completed notification. + +The verification terminal, browser tabs, local model server and temporary configuration were removed after acceptance. The pre-existing terminal and host service were retained. + +These are page notifications, not macOS Notification Center notifications. Previously open websites need a refresh to load the rebuilt content script. The repair is installed locally in Arc; npm publication and browser-store releases are outside this verification. diff --git a/scripts/check-dsh-plugin.mjs b/scripts/check-dsh-plugin.mjs index e9c4d65..c3c4b5f 100644 --- a/scripts/check-dsh-plugin.mjs +++ b/scripts/check-dsh-plugin.mjs @@ -70,7 +70,13 @@ try { await page.goto(url); // Fresh DSH onboarding is completed without credentials or model requests. await page.getByRole("button", { name: "Continue", exact: true }).click(); - await page.getByRole("button", { name: "Configure later", exact: true }).click(); + // DSH 0.2.1 offers account sign-in first; older releases open the API-key + // editor directly. Dismiss only the known first-run dialog in either flow. + await page.getByRole("button", { name: "Configure later", exact: true }) + .or(page.getByRole("dialog", { name: "Get started", exact: true }).getByRole("button", { name: "Close", exact: true })).click(); + // Recent DSH releases keep Settings in the account menu. + const accountMenu = page.getByRole("button", { name: "Account menu", exact: true }); + if (await accountMenu.isVisible()) await accountMenu.click(); await page.getByText("Settings", { exact: true }).click(); await page.getByText("AgentOnWeb", { exact: true }).click(); await page.getByText("No authorized browser connections.", { exact: true }).waitFor();