From 5c232da59b943c081847dfb77a27944ccad642e1 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 11:54:17 +0300 Subject: [PATCH] Fix browser pane close and runtime recovery --- .../src/browserManager.reliability.test.ts | 179 ++++++++++++++++++ apps/desktop/src/browserManager.ts | 116 ++++++------ apps/desktop/src/browserRuntimeLoad.test.ts | 100 ++++++++++ apps/desktop/src/browserRuntimeLoad.ts | 60 ++++++ .../src/components/BrowserPanel.browser.tsx | 63 +++++- .../src/components/BrowserPanel.logic.test.ts | 9 + apps/web/src/components/BrowserPanel.logic.ts | 6 + apps/web/src/components/BrowserPanel.tsx | 7 +- apps/web/src/wsNativeApi.test.ts | 9 +- apps/web/src/wsNativeApi.ts | 5 +- 10 files changed, 487 insertions(+), 67 deletions(-) create mode 100644 apps/desktop/src/browserManager.reliability.test.ts create mode 100644 apps/desktop/src/browserRuntimeLoad.test.ts create mode 100644 apps/desktop/src/browserRuntimeLoad.ts diff --git a/apps/desktop/src/browserManager.reliability.test.ts b/apps/desktop/src/browserManager.reliability.test.ts new file mode 100644 index 00000000..698e4fa6 --- /dev/null +++ b/apps/desktop/src/browserManager.reliability.test.ts @@ -0,0 +1,179 @@ +// FILE: browserManager.reliability.test.ts +// Purpose: Verifies browser session closure and recovery from destroyed Electron runtimes. +// Layer: Desktop unit test +// Depends on: DesktopBrowserManager with a minimal Electron session mock + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const electron = vi.hoisted(() => { + const createdWebContents: Array<{ loadURL: ReturnType }> = []; + let nextWebContentsId = 1; + + function createWebContents() { + let currentUrl = "about:blank"; + const webContents = { + id: nextWebContentsId++, + debugger: { + isAttached: () => false, + detach: vi.fn(), + }, + navigationHistory: { + canGoBack: () => false, + canGoForward: () => false, + }, + isDestroyed: () => false, + getURL: () => currentUrl, + getTitle: () => currentUrl, + isLoading: () => false, + getProcessId: () => 42, + loadURL: vi.fn(async (url: string) => { + currentUrl = url; + }), + setUserAgent: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + close: vi.fn(), + reload: vi.fn(), + goBack: vi.fn(), + goForward: vi.fn(), + openDevTools: vi.fn(), + }; + createdWebContents.push(webContents); + return webContents; + } + + return { + setUserAgent: vi.fn(), + onBeforeSendHeaders: vi.fn(), + createdWebContents, + createWebContents, + }; +}); + +vi.mock("electron", () => ({ + app: { + userAgentFallback: "Mozilla/5.0 Chrome/124.0.0.0 Electron/40.0.0 Scient/0.5.12 Safari/537.36", + getName: () => "Scient", + getPreferredSystemLanguages: () => ["en-US"], + }, + BrowserWindow: class { + readonly mocked = true; + }, + clipboard: { + writeImage: vi.fn(), + writeText: vi.fn(), + }, + nativeImage: { + createFromBuffer: vi.fn(), + }, + session: { + fromPartition: () => ({ + setUserAgent: electron.setUserAgent, + webRequest: { + onBeforeSendHeaders: electron.onBeforeSendHeaders, + }, + }), + }, + shell: { + openExternal: vi.fn(), + }, + webContents: { + fromId: vi.fn(), + }, + WebContentsView: class { + readonly webContents = electron.createWebContents(); + readonly setBounds = vi.fn(); + }, +})); + +import type { ThreadId } from "@synara/contracts"; + +import { DesktopBrowserManager } from "./browserManager"; + +const THREAD_ID = "thread-close-tab" as ThreadId; + +describe("DesktopBrowserManager reliability", () => { + beforeEach(() => { + vi.clearAllMocks(); + electron.createdWebContents.splice(0); + }); + + it("closes the browser session when its final tab closes", () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const tabId = opened.activeTabId; + + expect(tabId).toBeTruthy(); + const closed = manager.closeTab({ threadId: THREAD_ID, tabId: tabId ?? "" }); + + expect(closed.open).toBe(false); + expect(closed.tabs).toEqual([]); + expect(closed.activeTabId).toBeNull(); + manager.dispose(); + }); + + it("keeps the browser open while another tab remains", () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const firstTabId = opened.activeTabId; + const withSecondTab = manager.newTab({ + threadId: THREAD_ID, + url: "https://example.com/", + }); + + const next = manager.closeTab({ threadId: THREAD_ID, tabId: firstTabId ?? "" }); + + expect(next.open).toBe(true); + expect(next.tabs).toHaveLength(1); + expect(next.activeTabId).toBe(withSecondTab.activeTabId); + manager.dispose(); + }); + + it("replaces a destroyed tracked runtime before navigating", async () => { + const manager = new DesktopBrowserManager(); + const opened = manager.open({ threadId: THREAD_ID }); + const tabId = opened.activeTabId; + expect(tabId).toBeTruthy(); + + const runtimeKey = `${THREAD_ID}:${tabId}`; + const internals = manager as unknown as { + runtimes: Map< + string, + { + key: string; + threadId: ThreadId; + tabId: string; + webContents: { isDestroyed: () => boolean }; + view: null; + ownsWebContents: boolean; + listenerDisposers: Array<() => void>; + } + >; + }; + internals.runtimes.set(runtimeKey, { + key: runtimeKey, + threadId: THREAD_ID, + tabId: tabId ?? "", + webContents: { isDestroyed: () => true }, + view: null, + ownsWebContents: true, + listenerDisposers: [], + }); + + expect(() => + manager.navigate({ + threadId: THREAD_ID, + tabId: tabId ?? "", + url: "https://example.com/", + }), + ).not.toThrow(); + + await vi.waitFor(() => { + expect(electron.createdWebContents).toHaveLength(1); + expect(electron.createdWebContents[0]?.loadURL).toHaveBeenCalledWith("https://example.com/"); + }); + expect(manager.getState({ threadId: THREAD_ID }).lastError).toBeNull(); + manager.dispose(); + }); +}); diff --git a/apps/desktop/src/browserManager.ts b/apps/desktop/src/browserManager.ts index fb79fb30..951db5c2 100644 --- a/apps/desktop/src/browserManager.ts +++ b/apps/desktop/src/browserManager.ts @@ -20,6 +20,7 @@ import { artifactPreviewNavigationAllowed, artifactPreviewRequestAllowed, } from "./artifactPreviewPolicy"; +import { loadBrowserRuntimeUrl } from "./browserRuntimeLoad"; import type { BrowserAttachWebviewInput, BrowserCaptureScreenshotResult, @@ -222,13 +223,6 @@ function normalizeBounds(bounds: BrowserPanelBounds | null): BrowserPanelBounds }; } -function isAbortedNavigationError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false; - } - return /ERR_ABORTED|\(-3\)/i.test(error.message); -} - function mapBrowserLoadError(errorCode: number): string { switch (errorCode) { case -102: @@ -932,23 +926,21 @@ export class DesktopBrowserManager { syncThreadLastError(state); this.markThreadStateChanged(input.threadId); - const runtime = this.runtimes.get(buildRuntimeKey(input.threadId, tab.id)); - if (runtime) { + const runtimeKey = buildRuntimeKey(input.threadId, tab.id); + const shouldLoadRuntime = + this.runtimes.has(runtimeKey) || this.activeThreadId === input.threadId; + if (shouldLoadRuntime) { + if (this.activeThreadId === input.threadId) { + this.clearSuspendTimer(input.threadId); + } + // Re-resolve through ensureLiveRuntime so a destroyed-but-still-tracked WebContents is + // replaced before navigation instead of being handed to the async loader. + const runtime = this.ensureLiveRuntime(input.threadId, tab.id); const bounds = this.getVisibleBoundsForThread(input.threadId); if (state.activeTabId === tab.id && bounds) { this.attachRuntime(runtime, bounds); } void this.loadTab(input.threadId, tab.id, { force: true, runtime }); - } else if (this.activeThreadId === input.threadId) { - // Load the target tab directly so we don't clobber its pending URL with a - // thread-wide runtime sync from the old live page state. - const nextRuntime = this.ensureLiveRuntime(input.threadId, tab.id); - this.clearSuspendTimer(input.threadId); - const bounds = this.getVisibleBoundsForThread(input.threadId); - if (state.activeTabId === tab.id && bounds) { - this.attachRuntime(nextRuntime, bounds); - } - void this.loadTab(input.threadId, tab.id, { force: true, runtime: nextRuntime }); } this.emitState(input.threadId); @@ -1021,6 +1013,13 @@ export class DesktopBrowserManager { return this.snapshotThreadState(input.threadId, state); } + if (nextTabs.length === 0) { + // The tab close button is also the natural close affordance for a one-tab browser. + // Tear down the browser session so the renderer can close this dock pane and reveal + // the surface chooser instead of immediately manufacturing another blank tab. + return this.close({ threadId: input.threadId }); + } + this.closePopupWindowsForTab(input.threadId, input.tabId); this.destroyRuntime(input.threadId, input.tabId); state.tabs = nextTabs; @@ -1028,20 +1027,6 @@ export class DesktopBrowserManager { this.clearArtifactSession(input.threadId, closedTab); } - if (nextTabs.length === 0) { - // Closing the last tab keeps the browser open on a fresh blank tab (the same state - // as a brand-new browser session) so the user can type a new URL in the search box, - // instead of tearing the whole panel down. - const replacementTab = createBrowserTab(); - state.tabs = [replacementTab]; - state.activeTabId = replacementTab.id; - state.lastError = null; - - this.markThreadStateChanged(input.threadId); - this.emitState(input.threadId); - return this.snapshotThreadState(input.threadId, state); - } - if (!state.activeTabId || state.activeTabId === input.tabId) { state.activeTabId = nextTabs[Math.max(0, nextTabs.length - 1)]?.id ?? null; } @@ -1869,41 +1854,64 @@ export class DesktopBrowserManager { return; } - const runtime = options.runtime ?? this.ensureLiveRuntime(threadId, tabId); - const webContents = runtime.webContents; const nextUrl = normalizeUrlInput( options.force === true ? tab.url : (tab.lastCommittedUrl ?? tab.url), ); - const currentUrl = webContents.getURL(); - const shouldLoad = options.force === true || currentUrl !== nextUrl || currentUrl.length === 0; - - if (!shouldLoad) { - this.queueRuntimeStateSync(threadId, tabId); - return; - } - - tab.url = nextUrl; - tab.status = "live"; - tab.isLoading = true; - tab.lastError = null; - syncThreadLastError(state); - this.markThreadStateChanged(threadId); - this.emitState(threadId); try { - await webContents.loadURL(nextUrl); - this.queueRuntimeStateSync(threadId, tabId); - } catch (error) { - if (isAbortedNavigationError(error)) { + const runtime = options.runtime ?? this.ensureLiveRuntime(threadId, tabId); + const outcome = await loadBrowserRuntimeUrl({ + webContents: runtime.webContents, + nextUrl, + force: options.force === true, + isCurrent: () => this.runtimes.get(runtime.key) === runtime, + onLoadStart: () => { + tab.url = nextUrl; + tab.status = "live"; + tab.isLoading = true; + tab.lastError = null; + syncThreadLastError(state); + this.markThreadStateChanged(threadId); + this.emitState(threadId); + }, + }); + + if (outcome === "loaded" || outcome === "unchanged" || outcome === "aborted") { this.queueRuntimeStateSync(threadId, tabId); return; } + if (outcome === "stale") { + // If this exact runtime died without its normal render-process-gone cleanup firing, + // remove it so the next visible activation creates a healthy replacement. + if (this.runtimes.get(runtime.key) === runtime && runtime.webContents.isDestroyed()) { + this.destroyRuntime(threadId, tabId); + const didChange = suspendTabState(tab) || syncThreadLastError(state); + if (didChange) { + this.markThreadStateChanged(threadId); + this.emitState(threadId); + } + } + return; + } + tab.isLoading = false; tab.lastError = "Couldn't open this page."; syncThreadLastError(state); this.markThreadStateChanged(threadId); this.emitState(threadId); + } catch { + // Runtime construction and bookkeeping errors must be surfaced as browser state, never as + // unhandled promises from the fire-and-forget navigation paths above. + const currentTab = this.getTab(state, tabId); + if (!currentTab) { + return; + } + currentTab.isLoading = false; + currentTab.lastError = "Couldn't open this page."; + syncThreadLastError(state); + this.markThreadStateChanged(threadId); + this.emitState(threadId); } } diff --git a/apps/desktop/src/browserRuntimeLoad.test.ts b/apps/desktop/src/browserRuntimeLoad.test.ts new file mode 100644 index 00000000..5e76a11a --- /dev/null +++ b/apps/desktop/src/browserRuntimeLoad.test.ts @@ -0,0 +1,100 @@ +// FILE: browserRuntimeLoad.test.ts +// Purpose: Guards browser navigation against stale and destroyed Electron runtimes. +// Layer: Desktop unit test +// Depends on: browserRuntimeLoad + +import { describe, expect, it, vi } from "vitest"; + +import { loadBrowserRuntimeUrl } from "./browserRuntimeLoad"; + +function runtime( + overrides: { + currentUrl?: string; + isDestroyed?: () => boolean; + getURL?: () => string; + loadURL?: (url: string) => Promise; + isCurrent?: () => boolean; + } = {}, +) { + const onLoadStart = vi.fn(); + const loadURL = vi.fn(overrides.loadURL ?? (async () => undefined)); + const webContents = { + isDestroyed: overrides.isDestroyed ?? (() => false), + getURL: overrides.getURL ?? (() => overrides.currentUrl ?? "about:blank"), + loadURL, + }; + + return { + input: { + webContents, + nextUrl: "https://example.com/", + force: false, + isCurrent: overrides.isCurrent ?? (() => true), + onLoadStart, + }, + loadURL, + onLoadStart, + }; +} + +describe("loadBrowserRuntimeUrl", () => { + it("skips an unchanged live runtime", async () => { + const testRuntime = runtime({ currentUrl: "https://example.com/" }); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("unchanged"); + expect(testRuntime.onLoadStart).not.toHaveBeenCalled(); + expect(testRuntime.loadURL).not.toHaveBeenCalled(); + }); + + it("loads a changed URL and reports the loading transition", async () => { + const testRuntime = runtime(); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("loaded"); + expect(testRuntime.onLoadStart).toHaveBeenCalledOnce(); + expect(testRuntime.loadURL).toHaveBeenCalledWith("https://example.com/"); + }); + + it("treats a destroyed getURL race as stale instead of rejecting", async () => { + let destroyed = false; + const testRuntime = runtime({ + isDestroyed: () => destroyed, + getURL: () => { + destroyed = true; + throw new Error("Object has been destroyed"); + }, + }); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("stale"); + expect(testRuntime.onLoadStart).not.toHaveBeenCalled(); + }); + + it("ignores a runtime invalidated while loadURL is pending", async () => { + let current = true; + const testRuntime = runtime({ + isCurrent: () => current, + loadURL: async () => { + current = false; + throw new Error("Object has been destroyed"); + }, + }); + + await expect(loadBrowserRuntimeUrl(testRuntime.input)).resolves.toBe("stale"); + expect(testRuntime.onLoadStart).toHaveBeenCalledOnce(); + }); + + it("distinguishes aborted navigation from a current-runtime load failure", async () => { + const aborted = runtime({ + loadURL: async () => { + throw new Error("net::ERR_ABORTED (-3)"); + }, + }); + const failed = runtime({ + loadURL: async () => { + throw new Error("net::ERR_CONNECTION_RESET"); + }, + }); + + await expect(loadBrowserRuntimeUrl(aborted.input)).resolves.toBe("aborted"); + await expect(loadBrowserRuntimeUrl(failed.input)).resolves.toBe("failed"); + }); +}); diff --git a/apps/desktop/src/browserRuntimeLoad.ts b/apps/desktop/src/browserRuntimeLoad.ts new file mode 100644 index 00000000..c84c4f2c --- /dev/null +++ b/apps/desktop/src/browserRuntimeLoad.ts @@ -0,0 +1,60 @@ +// FILE: browserRuntimeLoad.ts +// Purpose: Runs one browser runtime navigation without leaking destroyed-WebContents races. +// Layer: Desktop runtime helper +// Depends on: a minimal Electron WebContents-compatible navigation surface + +export interface BrowserRuntimeLoadTarget { + isDestroyed: () => boolean; + getURL: () => string; + loadURL: (url: string) => Promise; +} + +export type BrowserRuntimeLoadOutcome = "loaded" | "unchanged" | "aborted" | "stale" | "failed"; + +interface LoadBrowserRuntimeUrlInput { + webContents: BrowserRuntimeLoadTarget; + nextUrl: string; + force: boolean; + isCurrent: () => boolean; + onLoadStart: () => void; +} + +function isAbortedNavigationError(error: unknown): boolean { + return error instanceof Error && /ERR_ABORTED|\(-3\)/i.test(error.message); +} + +function runtimeIsStale(input: LoadBrowserRuntimeUrlInput): boolean { + try { + return !input.isCurrent() || input.webContents.isDestroyed(); + } catch { + return true; + } +} + +// Electron can destroy a WebContents between a queued navigation and the async load. Keep every +// WebContents access inside this guarded boundary so stale runtimes resolve benignly rather than +// becoming unhandled promise rejections in the main process. +export async function loadBrowserRuntimeUrl( + input: LoadBrowserRuntimeUrlInput, +): Promise { + if (runtimeIsStale(input)) { + return "stale"; + } + + try { + const currentUrl = input.webContents.getURL(); + const shouldLoad = input.force || currentUrl.length === 0 || currentUrl !== input.nextUrl; + if (!shouldLoad) { + return "unchanged"; + } + + input.onLoadStart(); + await input.webContents.loadURL(input.nextUrl); + return runtimeIsStale(input) ? "stale" : "loaded"; + } catch (error) { + if (runtimeIsStale(input)) { + return "stale"; + } + return isAbortedNavigationError(error) ? "aborted" : "failed"; + } +} diff --git a/apps/web/src/components/BrowserPanel.browser.tsx b/apps/web/src/components/BrowserPanel.browser.tsx index f5b5c4ca..5f53a80d 100644 --- a/apps/web/src/components/BrowserPanel.browser.tsx +++ b/apps/web/src/components/BrowserPanel.browser.tsx @@ -3,7 +3,7 @@ import "../index.css"; -import type { ThreadBrowserState, ThreadId } from "@synara/contracts"; +import type { NativeApi, ThreadBrowserState, ThreadId } from "@synara/contracts"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -18,9 +18,13 @@ vi.mock("~/lib/serverReactQuery", async (importOriginal) => ({ }), })); +const nativeApiTestState = vi.hoisted(() => ({ + api: undefined as NativeApi | undefined, +})); + vi.mock("~/nativeApi", async (importOriginal) => ({ ...(await importOriginal()), - readNativeApi: () => undefined, + readNativeApi: () => nativeApiTestState.api, })); import { useBrowserStateStore } from "../browserStateStore"; @@ -82,12 +86,27 @@ function renderPanel() { ); } -describe("BrowserPanel copy feedback", () => { +function renderLivePanel(onClosePanel: () => void) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe("BrowserPanel interactions", () => { beforeEach(() => { useBrowserStateStore.getState().upsertThreadState(browserState("tab-1")); }); afterEach(() => { + nativeApiTestState.api = undefined; useBrowserStateStore.getState().removeThreadState(THREAD_ID); vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -130,4 +149,42 @@ describe("BrowserPanel copy feedback", () => { await expect.element(page.getByRole("button", { name: "Copy link" })).toBeVisible(); expect(page.getByText("Link copied").query()).toBeNull(); }); + + it("closes the browser pane when its final tab closes", async () => { + const openState = browserState("tab-1"); + openState.version = 10; + openState.tabs = [openState.tabs[0]!]; + const closedState: ThreadBrowserState = { + ...openState, + version: openState.version + 1, + open: false, + activeTabId: null, + tabs: [], + }; + const closeTab = vi.fn(async () => closedState); + nativeApiTestState.api = { + browser: { + open: vi.fn(async () => openState), + hide: vi.fn(async () => undefined), + setPanelBounds: vi.fn(async () => undefined), + closeTab, + onState: vi.fn(() => () => undefined), + onCopyLink: vi.fn(() => () => undefined), + }, + projects: { + revokeHtmlArtifactPreview: vi.fn(async () => ({ revoked: false })), + }, + } as unknown as NativeApi; + useBrowserStateStore.getState().upsertThreadState(openState); + const onClosePanel = vi.fn(); + + await renderLivePanel(onClosePanel); + const closeButton = await page.getByRole("button", { name: "Close Browser" }).element(); + (closeButton as HTMLButtonElement).click(); + + await vi.waitFor(() => { + expect(closeTab).toHaveBeenCalledWith({ threadId: THREAD_ID, tabId: "tab-1" }); + expect(onClosePanel).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/apps/web/src/components/BrowserPanel.logic.test.ts b/apps/web/src/components/BrowserPanel.logic.test.ts index dc6ee428..11e63eec 100644 --- a/apps/web/src/components/BrowserPanel.logic.test.ts +++ b/apps/web/src/components/BrowserPanel.logic.test.ts @@ -7,8 +7,17 @@ import { normalizeBrowserAddressInput, resolveBrowserChromeStatus, resolveBrowserAddressSync, + shouldCloseBrowserPanelAfterTabClose, } from "./BrowserPanel.logic"; +describe("shouldCloseBrowserPanelAfterTabClose", () => { + it("closes the dock pane only after the browser session has fully closed", () => { + expect(shouldCloseBrowserPanelAfterTabClose({ open: false, tabs: [] })).toBe(true); + expect(shouldCloseBrowserPanelAfterTabClose({ open: true, tabs: [] })).toBe(false); + expect(shouldCloseBrowserPanelAfterTabClose({ open: false, tabs: [{}] })).toBe(false); + }); +}); + describe("browserCopyFeedbackMatches", () => { const feedback = { item: "link" as const, diff --git a/apps/web/src/components/BrowserPanel.logic.ts b/apps/web/src/components/BrowserPanel.logic.ts index 673a0784..8368e2a6 100644 --- a/apps/web/src/components/BrowserPanel.logic.ts +++ b/apps/web/src/components/BrowserPanel.logic.ts @@ -70,6 +70,12 @@ export function browserCopyFeedbackMatches( return Boolean(feedback && scope && feedback.tabId === scope.tabId && feedback.url === scope.url); } +export function shouldCloseBrowserPanelAfterTabClose( + state: { open: boolean; tabs: readonly unknown[] } | null | undefined, +): boolean { + return state?.open === false && state.tabs.length === 0; +} + // Hides about:blank from the address bar so new tabs behave like real browsers. export function browserAddressDisplayValue( tab: { url: string; displayUrl?: string | null } | null | undefined, diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx index 5c7eb285..fa42853d 100644 --- a/apps/web/src/components/BrowserPanel.tsx +++ b/apps/web/src/components/BrowserPanel.tsx @@ -64,6 +64,7 @@ import { normalizeBrowserAddressInput, resolveBrowserChromeStatus, resolveBrowserAddressSync, + shouldCloseBrowserPanelAfterTabClose, type BrowserAddressSuggestion, type BrowserCopyFeedback, } from "./BrowserPanel.logic"; @@ -1347,7 +1348,7 @@ export function BrowserPanel({ return; } upsertThreadState(state); - if (!state.open && state.tabs.length === 0) { + if (shouldCloseBrowserPanelAfterTabClose(state)) { onClosePanel(); } }); @@ -1691,7 +1692,9 @@ export function BrowserPanel({ }} > - Close tab + + {threadBrowserState?.tabs.length === 1 ? "Close Browser" : "Close tab"} + ); diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 9663536c..0d95e418 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -821,7 +821,7 @@ describe("wsNativeApi", () => { expect(requestMock).not.toHaveBeenCalled(); }); - it("keeps a blank fallback browser tab after closing the last tab", async () => { + it("closes the fallback browser after closing the last tab", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); const api = createWsNativeApi(); const threadId = ThreadId.makeUnsafe("thread-1"); @@ -831,10 +831,9 @@ describe("wsNativeApi", () => { expect(tabId).toBeTruthy(); const nextState = await api.browser.closeTab({ threadId, tabId: tabId ?? "" }); - expect(nextState.open).toBe(true); - expect(nextState.tabs).toHaveLength(1); - expect(nextState.activeTabId).toBe(nextState.tabs[0]?.id); - expect(nextState.tabs[0]?.url).toBe("about:blank"); + expect(nextState.open).toBe(false); + expect(nextState.tabs).toEqual([]); + expect(nextState.activeTabId).toBeNull(); }); it("forwards context menu metadata to desktop bridge", async () => { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index eb1e98c4..0f9a251c 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -931,9 +931,8 @@ export function createWsNativeApi(): NativeApi { } state.tabs = nextTabs; if (nextTabs.length === 0) { - const replacementTab = createFallbackTab(); - state.tabs = [replacementTab]; - state.activeTabId = replacementTab.id; + state.open = false; + state.activeTabId = null; state.lastError = null; } else if (!state.tabs.some((tab) => tab.id === state.activeTabId)) { state.activeTabId = state.tabs[0]?.id ?? null;