From bcb0d1b8b4ff7ad78911c1d72e504c2fd6ccc02c Mon Sep 17 00:00:00 2001 From: AaronWander Date: Tue, 3 Mar 2026 11:03:49 +0800 Subject: [PATCH] fix(browser): wait for extension tabs after relay drop (#32331) --- ...-tab-available.prefers-last-target.test.ts | 29 +++++++++++++++++++ src/browser/server-context.selection.ts | 25 ++++++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 81f71cc21d3..0f6a5e99a9f 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -122,4 +122,33 @@ describe("browser server-context ensureTabAvailable", () => { const chrome = ctx.forProfile("chrome"); await expect(chrome.ensureTabAvailable()).rejects.toThrow(/no attached Chrome tabs/i); }); + + it("waits briefly for extension tabs to reappear when a previous target exists", async () => { + vi.useFakeTimers(); + try { + const responses = [ + // First call: select tab A and store lastTargetId. + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + // Second call: transient drop, then the extension re-announces attached tab A. + [], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + [{ id: "A", type: "page", url: "https://a.example", webSocketDebuggerUrl: "ws://x/a" }], + ]; + stubChromeJsonList(responses); + const state = makeBrowserState(); + + const ctx = createBrowserRouteContext({ getState: () => state }); + const chrome = ctx.forProfile("chrome"); + const first = await chrome.ensureTabAvailable(); + expect(first.targetId).toBe("A"); + + const secondPromise = chrome.ensureTabAvailable(); + await vi.advanceTimersByTimeAsync(250); + const second = await secondPromise; + expect(second.targetId).toBe("A"); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/browser/server-context.selection.ts b/src/browser/server-context.selection.ts index 740a99db2b8..3b0a267f2eb 100644 --- a/src/browser/server-context.selection.ts +++ b/src/browser/server-context.selection.ts @@ -32,15 +32,28 @@ export function createProfileSelectionOps({ const ensureTabAvailable = async (targetId?: string): Promise => { await ensureBrowserAvailable(); const profileState = getProfileState(); - const tabs1 = await listTabs(); + let tabs1 = await listTabs(); if (tabs1.length === 0) { if (profile.driver === "extension") { - throw new Error( - `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + - "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", - ); + // Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker + // lifecycle, relay restart). If we previously had a target selected, wait briefly for + // the extension to reconnect and re-announce its attached tabs before failing. + if (profileState.lastTargetId?.trim()) { + const deadlineAt = Date.now() + 3_000; + while (tabs1.length === 0 && Date.now() < deadlineAt) { + await new Promise((resolve) => setTimeout(resolve, 200)); + tabs1 = await listTabs(); + } + } + if (tabs1.length === 0) { + throw new Error( + `tab not found (no attached Chrome tabs for profile "${profile.name}"). ` + + "Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).", + ); + } + } else { + await openTab("about:blank"); } - await openTab("about:blank"); } const tabs = await listTabs();