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)
})
)