From 7ef6aa79ace0c4ae2fcf01767bba87a95b2c8798 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 17:36:53 -0700 Subject: [PATCH 1/3] fix(desktop-browser): add recoverable page failure states --- .../src/main/browser-agent/driver.test.ts | 45 ++++ apps/desktop/src/main/browser-agent/driver.ts | 40 ++- .../src/main/browser-agent/session.test.ts | 163 +++++++++++- .../desktop/src/main/browser-agent/session.ts | 231 +++++++++++++----- apps/desktop/src/test/electron-mock.ts | 2 + .../browser-page-issue.test.ts | 108 ++++++++ .../browser-session/browser-page-issue.tsx | 169 +++++++++++++ .../browser-session/browser-session.test.ts | 8 + .../browser-session/browser-session.tsx | 113 +++++++-- .../browser-session/browser-tab-label.ts | 3 + .../browser-session/browser-tab-strip.tsx | 10 +- apps/sim/stores/browser-session/store.test.ts | 25 ++ apps/sim/stores/browser-session/store.ts | 26 +- packages/browser-protocol/src/index.ts | 26 ++ 14 files changed, 865 insertions(+), 104 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index d63d828c324..06d74964d8f 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -412,6 +412,51 @@ describe('executeTool', () => { ) }) + it('publishes main-frame load failures and retries their uncommitted URL', async () => { + const onPageState = vi.fn() + const win = new BrowserWindow() + driver.initDriver( + { + onPageState, + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => win + ) + driver.activateBrowserScope('chat-test') + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + const eventHandlers = (contents.on as unknown as ReturnType).mock.calls + const failLoad = eventHandlers.find(([eventName]) => eventName === 'did-fail-load')?.[1] as + | ((...args: unknown[]) => void) + | undefined + const failedUrl = 'http://localhost:3004/login' + + onPageState.mockClear() + failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, false) + failLoad?.({}, -3, 'ERR_ABORTED', failedUrl, true) + expect(onPageState).not.toHaveBeenCalled() + + failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, true) + + expect(onPageState).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: failedUrl, + issue: { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: failedUrl, + }, + }) + ) + + vi.mocked(contents.loadURL).mockClear() + await driver.handlePanelAction('chat-test', { action: 'reload' }) + expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) + }) + it('forces fill availability to replay on scope activation and tab switches', async () => { const refreshAvailability = vi .spyOn(fillCoordinator()!, 'refreshAvailability') diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 371a1b78a8e..147aa105edc 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -264,14 +264,16 @@ function recordNotice(notice: string): void { * navigations and tab switches. */ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { + const issue = session.pageIssueForContents(contents) return { scopeId: session.getBrowserScopeId(), tabId, - url: contents.getURL(), - title: contents.getTitle(), - loading: contents.isLoadingMainFrame(), - canGoBack: contents.navigationHistory.canGoBack(), - canGoForward: contents.navigationHistory.canGoForward(), + url: issue?.url ?? contents.getURL(), + title: issue?.kind === 'load-error' ? '' : contents.getTitle(), + loading: issue ? false : contents.isLoadingMainFrame(), + canGoBack: session.canGoBack(contents), + canGoForward: session.canGoForward(contents), + ...(issue ? { issue } : {}), } } @@ -346,6 +348,18 @@ function instrumentTab(contents: WebContents): void { pushTabsState() }) ) + contents.on( + 'did-fail-load', + inScope((_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === 0 || errorCode === -3) return + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: errorCode, + description: errorDescription, + url: validatedURL || contents.getURL(), + }) + }) + ) contents.on( 'did-frame-navigate', inScope( @@ -364,7 +378,6 @@ function instrumentTab(contents: WebContents): void { for (const event of [ 'did-navigate-in-page', 'page-title-updated', - 'did-start-loading', 'did-finish-load', 'did-stop-loading', ] as const) { @@ -376,6 +389,14 @@ function instrumentTab(contents: WebContents): void { }) ) } + contents.on( + 'did-start-loading', + inScope(() => { + session.notePageLoadStarted(contents) + pushPageState(contents) + pushTabsState() + }) + ) driverCallbacks?.onSessionStatus(true, scopeId) } @@ -435,6 +456,7 @@ export function initDriver( // The fill affordance belongs to whichever page is in front. void fillCoordinator()?.refreshAvailability(true) }, + onPageStateChanged: pushPageState, onTabsChanged: pushTabsState, onTabThemeChanged: (contents, theme) => { void cdp.setColorScheme(contents, theme).catch((error) => { @@ -3814,13 +3836,13 @@ export async function handlePanelAction( const contents = tab.view.webContents switch (action.action) { case 'reload': - contents.reload() + session.reloadPage(contents) return case 'back': - if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack() + session.goBack(contents) return case 'forward': - if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward() + session.goForward(contents) return case 'print': contents.print({ printBackground: true }) diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 68152b337b5..ee037001153 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { MenuItemConstructorOptions } from 'electron' +import type { MenuItemConstructorOptions, WebContents } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -25,6 +25,7 @@ interface MockView { setWindowOpenHandler: ReturnType loadURL: ReturnType reload: ReturnType + forcefullyCrashRenderer: ReturnType getURL: ReturnType getTitle: ReturnType close: ReturnType @@ -40,6 +41,13 @@ interface MockView { capturePage: ReturnType findInPage: ReturnType stopFindInPage: ReturnType + navigationHistory: { + canGoBack: ReturnType + canGoForward: ReturnType + getActiveIndex: ReturnType + goBack: ReturnType + goForward: ReturnType + } } setBackgroundColor: ReturnType setBounds: ReturnType @@ -77,6 +85,7 @@ function freshSession( onSessionClosed: vi.fn(), onTabCreated: vi.fn(), onActiveTabChanged: vi.fn(), + onPageStateChanged: vi.fn(), onTabsChanged: vi.fn(), onTabThemeChanged: vi.fn(), onTabNavigated: vi.fn(), @@ -244,7 +253,12 @@ describe('browser-agent session', () => { )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined renderGone?.({}, { reason: 'crashed' }) - expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([]) + expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([ + expect.objectContaining({ + tabId: first.id, + issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }), + }), + ]) expect(session.withBrowserScope('chat-b', () => session.listTabs())).toHaveLength(1) }) @@ -852,6 +866,126 @@ describe('browser-agent session', () => { expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find', 'chat-test') }) + it('treats a failed navigation as a synthetic Back and Forward history entry', async () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.getURL.mockReturnValue('https://example.com/committed') + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'https://example.com/failed', + }) + + expect(session.canGoBack(contents)).toBe(true) + expect(session.listTabs()[0]).toMatchObject({ + url: 'https://example.com/failed', + issue: { kind: 'load-error' }, + }) + + expect(session.goBack(contents)).toBe(true) + expect(session.listTabs()[0]).toMatchObject({ url: 'https://example.com/committed' }) + expect(session.listTabs()[0]).not.toHaveProperty('issue') + expect(session.canGoForward(contents)).toBe(true) + + mockContents.navigationHistory.getActiveIndex.mockReturnValue(2) + mockContents.navigationHistory.canGoForward.mockReturnValue(true) + expect(session.goForward(contents)).toBe(true) + expect(mockContents.navigationHistory.goForward).toHaveBeenCalledTimes(1) + session.notePageLoadStarted(contents) + + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + expect(session.goForward(contents)).toBe(true) + expect(mockContents.loadURL).toHaveBeenCalledWith('https://example.com/failed') + }) + + it('discards a dismissed failed navigation when a fresh navigation starts', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + url: 'https://missing.invalid', + }) + session.goBack(contents) + + session.notePageLoadStarted(contents) + + expect(session.canGoForward(contents)).toBe(false) + }) + + it('keeps recovery state scoped to its tab while the user switches tabs', () => { + const first = session.ensureTab() + const second = session.addTab() + const firstContents = (first.view as unknown as MockView).webContents as unknown as WebContents + session.recordPageLoadFailure(firstContents, { + kind: 'load-error', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + url: 'https://missing.invalid', + }) + + session.switchTab(second.id) + expect(session.listTabs().find((tab) => tab.tabId === first.id)?.issue).toMatchObject({ + kind: 'load-error', + }) + expect(session.listTabs().find((tab) => tab.tabId === second.id)).not.toHaveProperty('issue') + + session.switchTab(first.id) + expect(session.requireTab().id).toBe(first.id) + expect(session.pageIssueForContents(firstContents)).toMatchObject({ kind: 'load-error' }) + }) + + it('hands focus to an accessible recovery page for active-tab failures', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const onPageStateChanged = vi.fn() + session = freshSession(win, { onPageStateChanged }) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -7, + description: 'ERR_TIMED_OUT', + url: 'https://slow.example.com', + }) + + expect(win.webContents.focus).toHaveBeenCalled() + expect(onPageStateChanged).toHaveBeenCalledWith(contents) + }) + + it('recovers unresponsive tabs and clears the issue when Chromium responds again', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.getURL.mockReturnValue('https://example.com') + const unresponsive = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'unresponsive' + )?.[1] as (() => void) | undefined + const responsive = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'responsive' + )?.[1] as (() => void) | undefined + const gone = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'render-process-gone' + )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined + + unresponsive?.() + expect(session.pageIssueForContents(contents)).toEqual({ + kind: 'unresponsive', + url: 'https://example.com', + }) + responsive?.() + expect(session.pageIssueForContents(contents)).toBeUndefined() + + unresponsive?.() + session.reloadPage(contents) + expect(mockContents.forcefullyCrashRenderer).toHaveBeenCalled() + gone?.({}, { reason: 'killed' }) + expect(mockContents.reload).toHaveBeenCalled() + }) + it('drops the find when the user switches to another tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) const first = session.requireTab() @@ -1888,7 +2022,7 @@ describe('browser-agent session', () => { ) }) - it('drops a tab whose renderer crashed instead of wedging the session', () => { + it('keeps a crashed tab recoverable without disturbing sibling tabs', () => { const first = session.ensureTab() const second = session.addTab() const crashed = (second.view as unknown as MockView).webContents @@ -1898,14 +2032,17 @@ describe('browser-agent session', () => { onGone({}, { reason: 'crashed' }) - // Left in place, activeTab() filters the dead view out while activeTabId - // still names it, so requireTab() reports "no page is open" even though - // another tab is right there. - expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id]) - expect(session.requireTab().id).toBe(first.id) + expect(session.listTabs()).toEqual([ + expect.objectContaining({ tabId: first.id }), + expect.objectContaining({ + tabId: second.id, + issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }), + }), + ]) + expect(session.requireTab().id).toBe(second.id) }) - it('reports the session closed when the only tab crashes', async () => { + it('keeps the only crashed tab open for recovery', async () => { const onSessionClosed = vi.fn() session = freshSession(win, { onSessionClosed }) const contents = (session.ensureTab().view as unknown as MockView).webContents @@ -1915,8 +2052,12 @@ describe('browser-agent session', () => { onGone({}, { reason: 'oom' }) - expect(session.listTabs()).toHaveLength(0) - expect(onSessionClosed).toHaveBeenCalled() + expect(session.listTabs()).toEqual([ + expect.objectContaining({ + issue: expect.objectContaining({ kind: 'crashed', reason: 'oom' }), + }), + ]) + expect(onSessionClosed).not.toHaveBeenCalled() }) it('hides the panel when the renderer stops renewing its bounds lease', async () => { diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 08f91419f96..9de0af5287a 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -6,6 +6,7 @@ import type { BrowserFindRequest, BrowserFindResult, BrowserOmniboxFocusMode, + BrowserPageIssue, BrowserTabState, BrowserTabsState, BrowserTheme, @@ -86,6 +87,10 @@ export interface AgentTab { view: WebContentsView pinned: boolean pendingRestoreUrl?: string + pageIssue?: BrowserPageIssue + syntheticForward?: { url: string; baseHistoryIndex: number } + preserveSyntheticForwardOnNextLoad?: boolean + recoveringUnresponsive?: boolean } export interface BrowserSessionPersistence { @@ -116,6 +121,8 @@ export interface AgentSessionEvents { onTabClosed: (contents: WebContents) => void /** The active tab changed (new tab, switch, close). */ onActiveTabChanged: (contents: WebContents) => void + /** The active tab's recoverable page state changed without a navigation. */ + onPageStateChanged: (contents: WebContents) => void /** The tab list or active tab changed. */ onTabsChanged: () => void /** Sim's appearance preference changed for an existing tab. */ @@ -1005,6 +1012,123 @@ function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void { win.webContents.send('browser-agent:focus-omnibox', mode, getBrowserScopeId()) } +function tabForContents(contents: WebContents): AgentTab | null { + return tabs.find((tab) => tab.view.webContents === contents) ?? null +} + +function publishPageIssue(tab: AgentTab, focusRecovery = false): void { + events?.onTabsChanged() + if (tab.id !== currentScope.activeTabId) return + if (focusRecovery && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { + const win = panelWindow() + if (win && !win.isDestroyed()) win.webContents.focus() + } + events?.onPageStateChanged(tab.view.webContents) +} + +/** Returns the recoverable problem currently replacing a tab's native page. */ +export function pageIssueForContents(contents: WebContents): BrowserPageIssue | undefined { + return tabForContents(contents)?.pageIssue +} + +/** Records a failed main-frame navigation without losing the last committed page. */ +export function recordPageLoadFailure( + contents: WebContents, + issue: Extract +): void { + const tab = tabForContents(contents) + if (!tab) return + tab.pageIssue = issue + tab.syntheticForward = undefined + publishPageIssue(tab, true) +} + +/** Clears transient recovery state when Chromium begins a new top-level load. */ +export function notePageLoadStarted(contents: WebContents): void { + const tab = tabForContents(contents) + if (!tab) return + const changed = Boolean(tab.pageIssue) + tab.pageIssue = undefined + if (tab.preserveSyntheticForwardOnNextLoad) { + tab.preserveSyntheticForwardOnNextLoad = false + } else { + tab.syntheticForward = undefined + } + if (changed) publishPageIssue(tab) +} + +/** Includes Sim's failed-navigation entry in the browser's Back availability. */ +export function canGoBack(contents: WebContents): boolean { + return ( + pageIssueForContents(contents)?.kind === 'load-error' || contents.navigationHistory.canGoBack() + ) +} + +/** Includes a dismissed failed navigation in the browser's Forward availability. */ +export function canGoForward(contents: WebContents): boolean { + return ( + Boolean(tabForContents(contents)?.syntheticForward) || contents.navigationHistory.canGoForward() + ) +} + +/** Traverses backward while preserving a failed navigation as a forward entry. */ +export function goBack(contents: WebContents): boolean { + const tab = tabForContents(contents) + if (!tab) return false + if (tab.pageIssue?.kind === 'load-error') { + tab.syntheticForward = { + url: tab.pageIssue.url, + baseHistoryIndex: contents.navigationHistory.getActiveIndex(), + } + tab.pageIssue = undefined + publishPageIssue(tab) + return true + } + if (!contents.navigationHistory.canGoBack()) return false + tab.preserveSyntheticForwardOnNextLoad = Boolean(tab.syntheticForward) + contents.navigationHistory.goBack() + return true +} + +/** Traverses forward through native history before retrying a failed navigation. */ +export function goForward(contents: WebContents): boolean { + const tab = tabForContents(contents) + if (!tab) return false + const syntheticForward = tab.syntheticForward + if (syntheticForward) { + if ( + contents.navigationHistory.getActiveIndex() < syntheticForward.baseHistoryIndex && + contents.navigationHistory.canGoForward() + ) { + tab.preserveSyntheticForwardOnNextLoad = true + contents.navigationHistory.goForward() + return true + } + tab.syntheticForward = undefined + void contents.loadURL(syntheticForward.url).catch(() => {}) + return true + } + if (!contents.navigationHistory.canGoForward()) return false + contents.navigationHistory.goForward() + return true +} + +/** Retries the appropriate recovery path for a failed, crashed, or hung page. */ +export function reloadPage(contents: WebContents): void { + const tab = tabForContents(contents) + const issue = tab?.pageIssue + if (issue?.kind === 'load-error') { + void contents.loadURL(issue.url).catch(() => {}) + return + } + if (issue?.kind === 'unresponsive' && tab) { + tab.recoveringUnresponsive = true + contents.forcefullyCrashRenderer() + return + } + contents.reload() +} + /** Hands one page selection to the exact app window and chat hosting its tab. */ function addPageSelectionToChat(contents: WebContents, text: string): void { if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return @@ -1245,17 +1369,47 @@ function createTabView(): WebContentsView { contents.on('will-prevent-unload', (event) => { event.preventDefault() }) - // A crashed renderer would otherwise stay in `tabs` forever: `activeTab()` - // filters it out and returns null while `activeTabId` still names it, so - // `requireTab()` reports "no page is open" even with other tabs open, and - // the panel goes blank with no way back. contents.on( 'render-process-gone', bindToBrowserScope(scopeId, (_event, details) => { const tab = tabs.find((entry) => entry.view === view) if (!tab) return - logger.warn('Browser tab renderer exited; dropping the tab', { reason: details.reason }) - forgetTab(tab) + if (tab.recoveringUnresponsive) { + tab.recoveringUnresponsive = false + contents.reload() + return + } + dismissFind(tab.id) + tab.pageIssue = { + kind: 'crashed', + reason: details.reason, + url: tab.pendingRestoreUrl || contents.getURL(), + } + tab.syntheticForward = undefined + logger.warn('Browser tab renderer exited', { reason: details.reason }) + publishPageIssue(tab, true) + }) + ) + contents.on( + 'unresponsive', + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab || tab.pageIssue?.kind === 'crashed') return + dismissFind(tab.id) + tab.pageIssue = { + kind: 'unresponsive', + url: tab.pendingRestoreUrl || contents.getURL(), + } + publishPageIssue(tab, true) + }) + ) + contents.on( + 'responsive', + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab || tab.pageIssue?.kind !== 'unresponsive') return + tab.pageIssue = undefined + publishPageIssue(tab) }) ) contents.on( @@ -1795,49 +1949,6 @@ export function reorderTab(tabId: string, targetIndex: number): AgentTab { return tab } -/** - * Drops a tab whose renderer is already gone. Unlike {@link closeTab} this - * takes no view down (there is nothing left to close), applies to pinned tabs - * too — a crashed pinned tab is no more usable than any other — and does not - * offer the page for Reopen Closed Tab, since the user did not close it. - */ -function forgetTab(tab: AgentTab): void { - const index = tabs.indexOf(tab) - if (index < 0) return - // Before the splice, while the tab is still resolvable: a find left running - // on a tab that is going away keeps `findingTabId` naming a dead tab and - // leaves the bar open counting matches on a page nobody can see. - dismissFind(tab.id) - clearAutomationIndicatorsForTab(tab.id) - tabs.splice(index, 1) - const transferBrowserFocus = currentScope.focusedBrowserTabId === tab.id - clearFocusedBrowserTab(tab.id) - detachIfAttached(tab.view) - if (currentScope.activeTabId === tab.id) { - currentScope.activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null - layout() - const active = activeTab() - if (active) { - events?.onActiveTabChanged(active.view.webContents) - } - } - if (currentScope.automationTabId === tab.id) { - currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null - applyActiveTabThrottling() - } - if (!hasSession() && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { - addTab() - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - return - } - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - persistBrowserSession() - events?.onTabsChanged() - if (!hasSession()) { - events?.onSessionClosed() - } -} - export function closeTab(tabId: string): void { restoreBrowserSession() const index = tabs.findIndex((entry) => entry.id === tabId) @@ -1845,7 +1956,7 @@ export function closeTab(tabId: string): void { if (tabs[index].pinned) { throw new SessionError('Pinned tabs cannot be closed. Unpin the tab first.') } - // Before the splice, while the tab is still resolvable — see forgetTab. + // Before the splice, while the tab is still resolvable, stop page-owned UI. dismissFind(tabId) clearAutomationIndicatorsForTab(tabId) const [tab] = tabs.splice(index, 1) @@ -2212,14 +2323,18 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise export function listTabs(): BrowserTabState[] { return tabs .filter((tab) => !tab.view.webContents.isDestroyed()) - .map((tab) => ({ - tabId: tab.id, - title: tab.view.webContents.getTitle(), - url: tab.pendingRestoreUrl || tab.view.webContents.getURL(), - loading: tab.view.webContents.isLoadingMainFrame(), - active: tab.id === currentScope.activeTabId, - pinned: tab.pinned, - })) + .map((tab) => { + const issue = tab.pageIssue + return { + tabId: tab.id, + title: issue?.kind === 'load-error' ? '' : tab.view.webContents.getTitle(), + url: issue?.url || tab.pendingRestoreUrl || tab.view.webContents.getURL(), + loading: issue ? false : tab.view.webContents.isLoadingMainFrame(), + active: tab.id === currentScope.activeTabId, + pinned: tab.pinned, + ...(issue ? { issue } : {}), + } + }) } export function getTabsState(): BrowserTabsState { diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 348fe5c1f63..92ab79f12c3 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -169,6 +169,7 @@ function createWebContentsMock() { setIgnoreMenuShortcuts: vi.fn(), getZoomFactor: vi.fn(() => 1), setZoomFactor: vi.fn(), + forcefullyCrashRenderer: vi.fn(), copy: vi.fn(), paste: vi.fn(), capturePage: vi.fn(() => { @@ -186,6 +187,7 @@ function createWebContentsMock() { navigationHistory: { canGoBack: vi.fn(() => false), canGoForward: vi.fn(() => false), + getActiveIndex: vi.fn(() => 0), goBack: vi.fn(), goForward: vi.fn(), }, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts new file mode 100644 index 00000000000..11e92494b97 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' +import { + BrowserPageIssueView, + browserPageIssueCopy, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue' + +describe('browserPageIssueCopy', () => { + it('names the failed host for a refused connection', () => { + expect( + browserPageIssueCopy({ + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'http://localhost:3004/login', + }) + ).toMatchObject({ + headline: "This site can't be reached", + detail: 'localhost refused to connect.', + code: 'ERR_CONNECTION_REFUSED', + }) + }) + + it.each([ + ['ERR_NAME_NOT_RESOLVED', 'example.invalid could not be found.'], + ['ERR_INTERNET_DISCONNECTED', 'Check your internet connection and try again.'], + ['ERR_TIMED_OUT', 'example.invalid took too long to respond.'], + ['ERR_PROXY_CONNECTION_FAILED', 'The configured proxy server could not be reached.'], + ['ERR_ADDRESS_UNREACHABLE', 'example.invalid is unavailable from this network.'], + ])('uses specific recovery copy for %s', (description, detail) => { + expect( + browserPageIssueCopy({ + kind: 'load-error', + code: -2, + description, + url: 'https://example.invalid/path', + }).detail + ).toBe(detail) + }) + + it('does not offer a certificate bypass', () => { + const copy = browserPageIssueCopy({ + kind: 'load-error', + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + url: 'https://example.invalid', + }) + + expect(copy.headline).toBe("Your connection isn't private") + expect(copy.suggestions.join(' ')).not.toMatch(/continue|proceed|bypass/i) + }) + + it('bounds untrusted Chromium descriptions to a safe code', () => { + expect( + browserPageIssueCopy({ + kind: 'load-error', + code: -2, + description: '', + url: 'not a valid URL', + }) + ).toMatchObject({ detail: 'The site could not be reached.', code: 'ERR_FAILED' }) + }) + + it('distinguishes renderer crashes and hangs', () => { + expect( + browserPageIssueCopy({ kind: 'crashed', reason: 'oom', url: 'https://example.com' }) + ).toMatchObject({ headline: 'This page crashed', code: 'RENDERER_OUT_OF_MEMORY' }) + expect( + browserPageIssueCopy({ kind: 'unresponsive', url: 'https://example.com' }) + ).toMatchObject({ headline: "This page isn't responding", code: 'RENDERER_UNRESPONSIVE' }) + }) + + it('moves focus into the recovery page and exposes a keyboard-reachable Reload button', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onReload = vi.fn() + + act(() => { + root.render( + createElement(BrowserPageIssueView, { + issue: { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'http://localhost:3004', + }, + onReload, + }) + ) + }) + + expect(document.activeElement?.id).toBe('browser-page-issue-heading') + const reload = container.querySelector('button[type="button"]') + expect(reload?.textContent).toContain('Reload') + reload?.focus() + expect(document.activeElement).toBe(reload) + act(() => reload?.click()) + expect(onReload).toHaveBeenCalledTimes(1) + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx new file mode 100644 index 00000000000..af9981ac85a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx @@ -0,0 +1,169 @@ +import { useEffect, useRef } from 'react' +import type { BrowserPageIssue } from '@sim/browser-protocol' +import { Button } from '@sim/emcn' +import { CircleAlert, Globe, RefreshCw } from '@sim/emcn/icons' + +interface BrowserPageIssueProps { + issue: BrowserPageIssue + onReload: () => void +} + +interface BrowserPageIssueCopy { + headline: string + detail: string + suggestions: string[] + code: string +} + +function hostnameFromUrl(url: string): string { + try { + return new URL(url).hostname || 'The site' + } catch { + return 'The site' + } +} + +function normalizedNetworkError(description: string): string { + return /^ERR_[A-Z0-9_]+$/.test(description) ? description : 'ERR_FAILED' +} + +/** Maps Chromium and renderer failures to concise, non-bypassable recovery copy. */ +export function browserPageIssueCopy(issue: BrowserPageIssue): BrowserPageIssueCopy { + const hostname = hostnameFromUrl(issue.url) + if (issue.kind === 'crashed') { + return { + headline: 'This page crashed', + detail: `${hostname} ran into a problem and closed unexpectedly.`, + suggestions: ['Reloading the page', 'Closing other tabs if this keeps happening'], + code: issue.reason === 'oom' ? 'RENDERER_OUT_OF_MEMORY' : 'RENDERER_CRASHED', + } + } + if (issue.kind === 'unresponsive') { + return { + headline: "This page isn't responding", + detail: `${hostname} stopped responding.`, + suggestions: ['Waiting a moment', 'Reloading the page'], + code: 'RENDERER_UNRESPONSIVE', + } + } + + const code = normalizedNetworkError(issue.description) + if (code.startsWith('ERR_CERT_') || code === 'ERR_SSL_PROTOCOL_ERROR') { + return { + headline: "Your connection isn't private", + detail: `The security certificate for ${hostname} could not be verified.`, + suggestions: ['Checking your device clock', 'Contacting the site administrator'], + code, + } + } + if ( + code === 'ERR_PROXY_CONNECTION_FAILED' || + code === 'ERR_TUNNEL_CONNECTION_FAILED' || + code === 'ERR_NO_SUPPORTED_PROXIES' + ) { + return { + headline: "This site can't be reached", + detail: 'The configured proxy server could not be reached.', + suggestions: ['Checking the proxy settings', 'Checking the network connection'], + code, + } + } + if ( + code === 'ERR_ADDRESS_UNREACHABLE' || + code === 'ERR_NETWORK_UNREACHABLE' || + code === 'ERR_BLOCKED_BY_CLIENT' || + code === 'ERR_BLOCKED_BY_RESPONSE' || + code === 'ERR_ACCESS_DENIED' + ) { + return { + headline: "This site can't be reached", + detail: `${hostname} is unavailable from this network.`, + suggestions: ['Checking the address', 'Checking firewall and network settings'], + code, + } + } + + switch (code) { + case 'ERR_CONNECTION_REFUSED': + return { + headline: "This site can't be reached", + detail: `${hostname} refused to connect.`, + suggestions: ['Checking the connection', 'Checking the address'], + code, + } + case 'ERR_NAME_NOT_RESOLVED': + case 'ERR_NAME_RESOLUTION_FAILED': + return { + headline: "This site can't be reached", + detail: `${hostname} could not be found.`, + suggestions: ['Checking the address', 'Checking the DNS and network connection'], + code, + } + case 'ERR_INTERNET_DISCONNECTED': + return { + headline: "You're offline", + detail: 'Check your internet connection and try again.', + suggestions: ['Checking network cables and Wi-Fi', 'Reconnecting to the internet'], + code, + } + case 'ERR_TIMED_OUT': + case 'ERR_CONNECTION_TIMED_OUT': + return { + headline: "This site can't be reached", + detail: `${hostname} took too long to respond.`, + suggestions: ['Checking the connection', 'Trying again in a moment'], + code, + } + default: + return { + headline: "This site can't be reached", + detail: `${hostname} could not be reached.`, + suggestions: ['Checking the address', 'Checking the connection'], + code, + } + } +} + +export function BrowserPageIssueView({ issue, onReload }: BrowserPageIssueProps) { + const headingRef = useRef(null) + const copy = browserPageIssueCopy(issue) + + useEffect(() => { + headingRef.current?.focus() + }, [issue]) + + const Icon = issue.kind === 'load-error' ? Globe : CircleAlert + + return ( +
+
+ +

+ {copy.headline} +

+

{copy.detail}

+

Try:

+
    + {copy.suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+

{copy.code}

+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts index 957dab4d10d..d1a3a767f24 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.test.ts @@ -6,6 +6,7 @@ import { browserPanelSnapshotStyle, browserSelectionContext, clearOmniboxSelection, + exceededOmniboxDragThreshold, hasConfirmedBrowserTabCreation, initialUrlSuggestionIndex, resolveUrlBarInput, @@ -156,6 +157,13 @@ describe('clearOmniboxSelection', () => { }) }) +describe('exceededOmniboxDragThreshold', () => { + it('preserves select-all through pointer jitter but cancels it for a drag', () => { + expect(exceededOmniboxDragThreshold(100, 100, 103, 102)).toBe(false) + expect(exceededOmniboxDragThreshold(100, 100, 105, 100)).toBe(true) + }) +}) + describe('shouldOpenUrlSuggestions', () => { it('opens only once the renderer owns the painted frame', () => { expect(shouldOpenUrlSuggestions('suggestions', 3)).toBe(true) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index b4e7d90c0f8..6084bd9cbdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -73,6 +73,7 @@ import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/compo import { BrowserDownloads } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-downloads' import { BrowserFindBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-find-bar' import { BrowserLoadingBar } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-loading-bar' +import { BrowserPageIssueView } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue' import { type BrowserPanelOverlay, type BrowserPanelOverlayController, @@ -104,6 +105,7 @@ import type { ChatContext } from '@/stores/panel' const SUGGESTIONS_LIST_ID = 'browser-url-suggestions' const SEARCH_SUGGESTIONS_DEBOUNCE_MS = 160 const NEW_TAB_CONFIRM_TIMEOUT_MS = 10_000 +const OMNIBOX_DRAG_THRESHOLD_PX = 4 const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const suggestionRowId = (index: number) => `${SUGGESTIONS_LIST_ID}-${index}` @@ -168,11 +170,7 @@ export function resolveUrlBarInput(raw: string): string { return `${isLocal ? 'http' : 'https'}://${input}` } -/** - * Selects the omnibox after the pointer event that focused it has settled. - * Selecting synchronously in `focus` lets the remainder of that click collapse - * the selection to an arbitrary caret position. - */ +/** Selects after keyboard or programmatic focus has settled. */ export function selectFocusedOmniboxOnNextFrame(input: HTMLInputElement): number { return requestAnimationFrame(() => { if (input.ownerDocument.activeElement === input) { @@ -181,6 +179,16 @@ export function selectFocusedOmniboxOnNextFrame(input: HTMLInputElement): number }) } +/** Chromium cancels focus-click select-all once the pointer becomes a drag. */ +export function exceededOmniboxDragThreshold( + originX: number, + originY: number, + clientX: number, + clientY: number +): boolean { + return Math.hypot(clientX - originX, clientY - originY) > OMNIBOX_DRAG_THRESHOLD_PX +} + /** Removes the selection left behind when focus moves into the native page view. */ export function clearOmniboxSelection(input: HTMLInputElement): void { const caret = input.selectionEnd ?? input.value.length @@ -331,6 +339,12 @@ interface PendingNewTabFocus { timeoutId: number } +interface OmniboxPointerSelection { + pointerId: number + originX: number + originY: number +} + export function BrowserSession({ visible, scopeId, @@ -370,6 +384,7 @@ export function BrowserSession({ (state) => state.sessions[scopeId]?.sessionAlive ?? true ) const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false) + const hasPageIssue = Boolean(pageState?.issue) const panelRef = useRef(null) const hostRef = useRef(null) // Lets the occlusion handshake reject a capture taken before a modal's @@ -380,6 +395,7 @@ export function BrowserSession({ const fillButtonRef = useRef(null) const toolbarMenuButtonRef = useRef(null) const omniboxFocusRafRef = useRef(null) + const omniboxPointerSelectionRef = useRef(null) const pendingNewTabFocusRef = useRef(null) const visibleRef = useRef(visible) visibleRef.current = visible @@ -727,6 +743,11 @@ export function BrowserSession({ reportBrowserPanelBounds(null, null, scopeId) return } + if (hasPageIssue) { + setPanelVisible(true) + reportBrowserPanelBounds(null, null, scopeId) + return + } // Resolved once: the panel is a stable ancestor for this effect's lifetime, // and only its inline width changes. const panel = host.closest('[data-mothership-panel]') @@ -888,7 +909,7 @@ export function BrowserSession({ void geometryOcclusionLease.setDesired(false) reportBrowserPanelBounds(null, null, scopeId) } - }, [visible, suspended, scopeId]) + }, [hasPageIssue, visible, suspended, scopeId]) /** * Programmatic focus on a new tab keeps the omnibox ready for typing without @@ -912,27 +933,27 @@ export function BrowserSession({ // pointer events reach the Sim popover instead of the WebContentsView. useEffect(() => { if (suggestions.length > 0) { + if (hasPageIssue) return void requestOverlay('suggestions', () => {}) return } void closeOverlay('suggestions') - }, [closeOverlay, requestOverlay, suggestions.length]) + }, [closeOverlay, hasPageIssue, requestOverlay, suggestions.length]) - const suggestionsOpen = shouldOpenUrlSuggestions(activeOverlay, suggestions.length) + const suggestionsOpen = hasPageIssue + ? suggestions.length > 0 + : shouldOpenUrlSuggestions(activeOverlay, suggestions.length) - const navigateTo = useCallback( - (url: string) => { - sendBrowserPanelAction('navigate', { url }, scopeId) - setSuggestionsVisible(false) - setSuggestionQuery(null) - setActiveSuggestion(null) - setSuggestionOriginUrl('') - urlInputRef.current?.blur() - }, - [scopeId] - ) + const navigateTo = (url: string) => { + sendBrowserPanelAction('navigate', { url }, scopeId) + setSuggestionsVisible(false) + setSuggestionQuery(null) + setActiveSuggestion(null) + setSuggestionOriginUrl('') + urlInputRef.current?.blur() + } - const submitUrl = useCallback(() => { + const submitUrl = () => { // Enter can only take a highlight from a list the user can actually see. const highlighted = suggestionsOpen && activeSuggestion !== null ? suggestions[activeSuggestion] : undefined @@ -946,7 +967,7 @@ export function BrowserSession({ return } urlInputRef.current?.blur() - }, [activeSuggestion, navigateTo, suggestions, suggestionsOpen, urlDraft]) + } const handleNewTab = useCallback(() => { setSuggestionsVisible(false) @@ -1146,12 +1167,51 @@ export function BrowserSession({ : undefined } onPointerDown={(event) => { + if (omniboxFocusRafRef.current !== null) { + cancelAnimationFrame(omniboxFocusRafRef.current) + omniboxFocusRafRef.current = null + } + omniboxPointerSelectionRef.current = + event.button === 0 && document.activeElement !== event.currentTarget + ? { + pointerId: event.pointerId, + originX: event.clientX, + originY: event.clientY, + } + : null setSuggestionsVisible(true) if (document.activeElement !== event.currentTarget) { setSuggestionOriginUrl(pageState?.url ?? '') setSuggestionQuery('') } }} + onPointerMove={(event) => { + const pending = omniboxPointerSelectionRef.current + if (!pending || pending.pointerId !== event.pointerId) return + const hasSelection = + event.currentTarget.selectionStart !== event.currentTarget.selectionEnd + if ( + hasSelection || + exceededOmniboxDragThreshold( + pending.originX, + pending.originY, + event.clientX, + event.clientY + ) + ) { + omniboxPointerSelectionRef.current = null + } + }} + onPointerUp={(event) => { + const pending = omniboxPointerSelectionRef.current + omniboxPointerSelectionRef.current = null + if (pending?.pointerId === event.pointerId && event.button === 0) { + event.currentTarget.select() + } + }} + onPointerCancel={() => { + omniboxPointerSelectionRef.current = null + }} onChange={(event) => { setSuggestionsVisible(true) setSuggestionQuery(event.target.value) @@ -1165,9 +1225,12 @@ export function BrowserSession({ setUrlDraft((current) => current ?? pageState?.url ?? '') setSuggestionOriginUrl(pageState?.url ?? '') setSuggestionQuery('') - selectFocusedOmniboxOnNextFrame(event.currentTarget) + if (!omniboxPointerSelectionRef.current) { + selectFocusedOmniboxOnNextFrame(event.currentTarget) + } }} onBlur={(event) => { + omniboxPointerSelectionRef.current = null clearOmniboxSelection(event.currentTarget) setSuggestionsVisible(false) setSuggestionQuery(null) @@ -1380,6 +1443,12 @@ export function BrowserSession({

)} + {pageState?.issue && ( + sendBrowserPanelAction('reload', {}, scopeId)} + /> + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts index 17395528660..5e9eed4b3cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts @@ -20,6 +20,9 @@ export function shouldShowBrowserTabSpinner( /** A settled blank-title page is identified by its host, never as still loading. */ export function browserTabTitle(tab: BrowserTabState): string { + if (tab.issue?.kind === 'crashed') return 'Page crashed' + if (tab.issue?.kind === 'unresponsive') return 'Not responding' + if (tab.issue?.kind === 'load-error') return browserTabHostname(tab.url) ?? 'Page unavailable' const title = tab.title.trim() if (title) return title if (tab.loading) return 'Loading…' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx index 2945162eac1..13689869dce 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx @@ -11,7 +11,7 @@ import { } from 'react' import type { BrowserTabState } from '@sim/browser-protocol' import { cn, TabStrip, type TabStripItem, toast } from '@sim/emcn' -import { Globe, Loader } from '@sim/emcn/icons' +import { CircleAlert, Globe, Loader } from '@sim/emcn/icons' import { ThinkingLoader } from '@/components/ui' import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types' import { faviconUrl } from '@/lib/core/utils/favicon' @@ -49,6 +49,14 @@ function BrowserTabIcon({ tab }: { tab: BrowserTabState }) { const faviconFailed = Boolean(hostname && failedHostname === hostname) const showSpinner = shouldShowBrowserTabSpinner(tab.loading, hostname, loadedHostname) + if (tab.issue) { + return ( + + + + ) + } + return ( {hostname && !faviconFailed && ( diff --git a/apps/sim/stores/browser-session/store.test.ts b/apps/sim/stores/browser-session/store.test.ts index 31f9c10822b..11160bda34c 100644 --- a/apps/sim/stores/browser-session/store.test.ts +++ b/apps/sim/stores/browser-session/store.test.ts @@ -76,6 +76,31 @@ describe('browser session store', () => { expect(getBrowserSession('chat-test').sessionAlive).toBe(false) }) + it('retains a main-frame load failure in the active page state', () => { + useBrowserSessionStore.getState().setPageState({ + tabId: '1', + scopeId: 'chat-test', + title: '', + url: 'http://localhost:3004/login', + loading: false, + canGoBack: false, + canGoForward: false, + issue: { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'http://localhost:3004/login', + }, + }) + + expect(getBrowserSession('chat-test').pageState?.issue).toEqual({ + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'http://localhost:3004/login', + }) + }) + it('reorders tabs optimistically without changing the active page', () => { const store = useBrowserSessionStore.getState() store.setTabsState({ diff --git a/apps/sim/stores/browser-session/store.ts b/apps/sim/stores/browser-session/store.ts index 19afa105071..a866e2c7e73 100644 --- a/apps/sim/stores/browser-session/store.ts +++ b/apps/sim/stores/browser-session/store.ts @@ -1,4 +1,9 @@ -import type { BrowserPageState, BrowserTabState, BrowserTabsState } from '@sim/browser-protocol' +import type { + BrowserPageIssue, + BrowserPageState, + BrowserTabState, + BrowserTabsState, +} from '@sim/browser-protocol' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { @@ -78,6 +83,16 @@ function isPristineSession(session: BrowserSessionData): boolean { ) } +function pageIssueEqual(a: BrowserPageIssue | undefined, b: BrowserPageIssue | undefined): boolean { + if (a === b) return true + if (!a || !b || a.kind !== b.kind || a.url !== b.url) return false + if (a.kind === 'load-error') { + return b.kind === 'load-error' && a.code === b.code && a.description === b.description + } + if (a.kind === 'crashed') return b.kind === 'crashed' && a.reason === b.reason + return true +} + function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean { return ( a.tabId === b.tabId && @@ -85,7 +100,8 @@ function tabFieldsEqual(a: BrowserTabState, b: BrowserTabState): boolean { a.title === b.title && a.loading === b.loading && a.active === b.active && - a.pinned === b.pinned + a.pinned === b.pinned && + pageIssueEqual(a.issue, b.issue) ) } @@ -110,6 +126,7 @@ function retainSettledTabTitles( if ( incoming.title.trim() === '' && !incoming.loading && + !incoming.issue && current?.url === incoming.url && current.title.trim() !== '' ) { @@ -129,7 +146,8 @@ function pageStateEqual(a: BrowserPageState | null, b: BrowserPageState | null): a.title === b.title && a.loading === b.loading && a.canGoBack === b.canGoBack && - a.canGoForward === b.canGoForward + a.canGoForward === b.canGoForward && + pageIssueEqual(a.issue, b.issue) ) } @@ -198,6 +216,7 @@ export const useBrowserSessionStore = create()( title: pageState.title, loading: pageState.loading, active: true, + issue: pageState.issue, } : tab.active ? { ...tab, active: false } @@ -252,6 +271,7 @@ export const useBrowserSessionStore = create()( loading: activeTab.loading, canGoBack: false, canGoForward: false, + ...(activeTab.issue ? { issue: activeTab.issue } : {}), } const sessionAlive = tabs.length > 0 if ( diff --git a/packages/browser-protocol/src/index.ts b/packages/browser-protocol/src/index.ts index ca7dd65cbd1..85a308c3f62 100644 --- a/packages/browser-protocol/src/index.ts +++ b/packages/browser-protocol/src/index.ts @@ -178,8 +178,32 @@ export interface BrowserPageState { loading: boolean canGoBack: boolean canGoForward: boolean + /** Recoverable problem replacing the native page surface. Optional for older shells. */ + issue?: BrowserPageIssue } +/** A recoverable top-level page problem rendered by Sim instead of a blank native view. */ +export type BrowserPageIssue = + | { + kind: 'load-error' + /** Chromium network error number, such as -102 for connection refused. */ + code: number + /** Chromium network error name, such as ERR_CONNECTION_REFUSED. */ + description: string + /** The attempted URL, which may never have committed in WebContents. */ + url: string + } + | { + kind: 'crashed' + /** Chromium renderer exit reason, such as crashed or oom. */ + reason: string + url: string + } + | { + kind: 'unresponsive' + url: string + } + /** * One find-in-page request against the active tab. Backed by Chromium's own * find, so behaviour matches Chrome exactly — this only carries the query and @@ -222,6 +246,8 @@ export interface BrowserTabState { title: string loading: boolean active: boolean + /** Recoverable problem currently replacing this tab's native page surface. */ + issue?: BrowserPageIssue /** Pinned tabs are ordered before regular tabs and cannot be closed. */ pinned: boolean } From af4cf712574baf3fd235342f2cdfed4110841524 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 17:47:36 -0700 Subject: [PATCH 2/3] fix(desktop-browser): align recovery interaction paths --- .../src/main/browser-agent/driver.test.ts | 8 ++++++ apps/desktop/src/main/browser-agent/driver.ts | 11 ++++---- .../browser-page-issue.test.ts | 25 +++++++++++++++++++ .../browser-session/browser-page-issue.tsx | 8 +++--- .../browser-session/browser-session.tsx | 6 ++++- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 06d74964d8f..f165bac475c 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -455,6 +455,14 @@ describe('executeTool', () => { vi.mocked(contents.loadURL).mockClear() await driver.handlePanelAction('chat-test', { action: 'reload' }) expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) + + vi.mocked(contents.loadURL).mockClear() + await driver.executeTool('chat-test', 'browser_go_back', {}) + expect(session.pageIssueForContents(contents)).toBeUndefined() + expect(session.canGoForward(contents)).toBe(true) + + await driver.executeTool('chat-test', 'browser_go_forward', {}) + expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) }) it('forces fill availability to replay on scope activation and tab switches', async () => { diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 147aa105edc..4964ec01691 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -1985,19 +1985,20 @@ async function executeToolInner( case 'browser_go_forward': { invalidateSnapshot() const contents = session.requireAutomationTab().view.webContents - const history = contents.navigationHistory assertCurrentExecution() let completion: Promise if (tool === 'browser_go_back') { - if (!history.canGoBack()) throw new ToolError('Cannot go back — no earlier history entry.') + if (!session.canGoBack(contents)) { + throw new ToolError('Cannot go back — no earlier history entry.') + } completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS) - history.goBack() + session.goBack(contents) } else { - if (!history.canGoForward()) { + if (!session.canGoForward(contents)) { throw new ToolError('Cannot go forward — no later history entry.') } completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS) - history.goForward() + session.goForward(contents) } return await navigationResult(contents, completion) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts index 11e92494b97..b4247d443f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.test.ts @@ -90,6 +90,7 @@ describe('browserPageIssueCopy', () => { url: 'http://localhost:3004', }, onReload, + focusRecovery: true, }) ) }) @@ -105,4 +106,28 @@ describe('browserPageIssueCopy', () => { act(() => root.unmount()) container.remove() }) + + it('does not move focus when its browser resource is hidden', () => { + const container = document.createElement('div') + const sentinel = document.createElement('button') + document.body.append(container, sentinel) + sentinel.focus() + const root = createRoot(container) + + act(() => { + root.render( + createElement(BrowserPageIssueView, { + issue: { kind: 'unresponsive', url: 'https://example.com' }, + onReload: vi.fn(), + focusRecovery: false, + }) + ) + }) + + expect(document.activeElement).toBe(sentinel) + + act(() => root.unmount()) + container.remove() + sentinel.remove() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx index af9981ac85a..b22b8a72236 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-issue.tsx @@ -6,6 +6,7 @@ import { CircleAlert, Globe, RefreshCw } from '@sim/emcn/icons' interface BrowserPageIssueProps { issue: BrowserPageIssue onReload: () => void + focusRecovery: boolean } interface BrowserPageIssueCopy { @@ -124,13 +125,14 @@ export function browserPageIssueCopy(issue: BrowserPageIssue): BrowserPageIssueC } } -export function BrowserPageIssueView({ issue, onReload }: BrowserPageIssueProps) { +/** Replaces a hidden native page and optionally claims renderer focus for keyboard recovery. */ +export function BrowserPageIssueView({ issue, onReload, focusRecovery }: BrowserPageIssueProps) { const headingRef = useRef(null) const copy = browserPageIssueCopy(issue) useEffect(() => { - headingRef.current?.focus() - }, [issue]) + if (focusRecovery) headingRef.current?.focus() + }, [focusRecovery, issue]) const Icon = issue.kind === 'load-error' ? Globe : CircleAlert diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index 6084bd9cbdd..67e9df2e6e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -932,8 +932,11 @@ export function BrowserSession({ // Keep the page's exact captured frame underneath it while it is open so // pointer events reach the Sim popover instead of the WebContentsView. useEffect(() => { + if (hasPageIssue) { + void closeOverlay('suggestions') + return + } if (suggestions.length > 0) { - if (hasPageIssue) return void requestOverlay('suggestions', () => {}) return } @@ -1446,6 +1449,7 @@ export function BrowserSession({ {pageState?.issue && ( sendBrowserPanelAction('reload', {}, scopeId)} /> )} From 2eb09504a95ffb9a4efdadae95098909acd973b0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 17:57:18 -0700 Subject: [PATCH 3/3] fix(desktop-browser): expire stale recovery history --- .../src/main/browser-agent/session.test.ts | 39 ++++++++++++++++++- .../desktop/src/main/browser-agent/session.ts | 20 ++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index ee037001153..c11989dc788 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -134,6 +134,17 @@ function hostResizeHandler(win: BrowserWindow): () => void { return handler as () => void } +function mainFrameNavigationStarted( + contents: MockView['webContents'], + isSameDocument = false +): void { + const handler = contents.on.mock.calls + .filter(([eventName]) => eventName === 'did-start-navigation') + .at(-1)?.[1] + if (typeof handler !== 'function') throw new Error('no navigation-start listener bound') + handler({ isMainFrame: true, isSameDocument }) +} + describe('browser-agent session', () => { let win: BrowserWindow let session: SessionModule @@ -893,7 +904,7 @@ describe('browser-agent session', () => { mockContents.navigationHistory.canGoForward.mockReturnValue(true) expect(session.goForward(contents)).toBe(true) expect(mockContents.navigationHistory.goForward).toHaveBeenCalledTimes(1) - session.notePageLoadStarted(contents) + mainFrameNavigationStarted(mockContents) mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) expect(session.goForward(contents)).toBe(true) @@ -911,7 +922,31 @@ describe('browser-agent session', () => { }) session.goBack(contents) - session.notePageLoadStarted(contents) + mainFrameNavigationStarted(mockContents) + + expect(session.canGoForward(contents)).toBe(false) + }) + + it('discards synthetic Forward after same-document traversal and a fresh navigation', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'https://example.com/failed', + }) + + session.goBack(contents) + mockContents.navigationHistory.canGoBack.mockReturnValue(true) + expect(session.goBack(contents)).toBe(true) + + mainFrameNavigationStarted(mockContents, true) + + expect(session.canGoForward(contents)).toBe(true) + + mainFrameNavigationStarted(mockContents) expect(session.canGoForward(contents)).toBe(false) }) diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 9de0af5287a..bd13b9de908 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -89,7 +89,7 @@ export interface AgentTab { pendingRestoreUrl?: string pageIssue?: BrowserPageIssue syntheticForward?: { url: string; baseHistoryIndex: number } - preserveSyntheticForwardOnNextLoad?: boolean + preserveSyntheticForwardOnNextNavigation?: boolean recoveringUnresponsive?: boolean } @@ -1043,18 +1043,23 @@ export function recordPageLoadFailure( publishPageIssue(tab, true) } -/** Clears transient recovery state when Chromium begins a new top-level load. */ +/** Clears transient recovery state when Chromium begins loading a new document. */ export function notePageLoadStarted(contents: WebContents): void { const tab = tabForContents(contents) if (!tab) return const changed = Boolean(tab.pageIssue) tab.pageIssue = undefined - if (tab.preserveSyntheticForwardOnNextLoad) { - tab.preserveSyntheticForwardOnNextLoad = false + if (changed) publishPageIssue(tab) +} + +function notePageNavigationStarted(contents: WebContents): void { + const tab = tabForContents(contents) + if (!tab) return + if (tab.preserveSyntheticForwardOnNextNavigation) { + tab.preserveSyntheticForwardOnNextNavigation = false } else { tab.syntheticForward = undefined } - if (changed) publishPageIssue(tab) } /** Includes Sim's failed-navigation entry in the browser's Back availability. */ @@ -1085,7 +1090,7 @@ export function goBack(contents: WebContents): boolean { return true } if (!contents.navigationHistory.canGoBack()) return false - tab.preserveSyntheticForwardOnNextLoad = Boolean(tab.syntheticForward) + tab.preserveSyntheticForwardOnNextNavigation = Boolean(tab.syntheticForward) contents.navigationHistory.goBack() return true } @@ -1100,7 +1105,7 @@ export function goForward(contents: WebContents): boolean { contents.navigationHistory.getActiveIndex() < syntheticForward.baseHistoryIndex && contents.navigationHistory.canGoForward() ) { - tab.preserveSyntheticForwardOnNextLoad = true + tab.preserveSyntheticForwardOnNextNavigation = true contents.navigationHistory.goForward() return true } @@ -1498,6 +1503,7 @@ function createTabView(): WebContentsView { 'did-start-navigation', bindToBrowserScope(scopeId, (details) => { if (!details.isMainFrame) return + notePageNavigationStarted(contents) events?.onTabNavigated(contents, false) }) )