mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
fix(desktop-browser): add recoverable page failure states (#7142)
* fix(desktop-browser): add recoverable page failure states * fix(desktop-browser): align recovery interaction paths * fix(desktop-browser): expire stale recovery history
This commit is contained in:
@@ -412,6 +412,59 @@ 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<typeof vi.fn>).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)
|
||||
|
||||
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 () => {
|
||||
const refreshAvailability = vi
|
||||
.spyOn(fillCoordinator()!, 'refreshAvailability')
|
||||
|
||||
@@ -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) => {
|
||||
@@ -1963,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<void>
|
||||
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)
|
||||
}
|
||||
@@ -3814,13 +3837,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 })
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
loadURL: ReturnType<typeof vi.fn>
|
||||
reload: ReturnType<typeof vi.fn>
|
||||
forcefullyCrashRenderer: ReturnType<typeof vi.fn>
|
||||
getURL: ReturnType<typeof vi.fn>
|
||||
getTitle: ReturnType<typeof vi.fn>
|
||||
close: ReturnType<typeof vi.fn>
|
||||
@@ -40,6 +41,13 @@ interface MockView {
|
||||
capturePage: ReturnType<typeof vi.fn>
|
||||
findInPage: ReturnType<typeof vi.fn>
|
||||
stopFindInPage: ReturnType<typeof vi.fn>
|
||||
navigationHistory: {
|
||||
canGoBack: ReturnType<typeof vi.fn>
|
||||
canGoForward: ReturnType<typeof vi.fn>
|
||||
getActiveIndex: ReturnType<typeof vi.fn>
|
||||
goBack: ReturnType<typeof vi.fn>
|
||||
goForward: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
setBackgroundColor: ReturnType<typeof vi.fn>
|
||||
setBounds: ReturnType<typeof vi.fn>
|
||||
@@ -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(),
|
||||
@@ -125,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
|
||||
@@ -244,7 +264,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 +877,150 @@ 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)
|
||||
mainFrameNavigationStarted(mockContents)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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 +2057,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 +2067,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 +2087,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 () => {
|
||||
|
||||
@@ -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 }
|
||||
preserveSyntheticForwardOnNextNavigation?: 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,128 @@ 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<BrowserPageIssue, { kind: 'load-error' }>
|
||||
): void {
|
||||
const tab = tabForContents(contents)
|
||||
if (!tab) return
|
||||
tab.pageIssue = issue
|
||||
tab.syntheticForward = undefined
|
||||
publishPageIssue(tab, true)
|
||||
}
|
||||
|
||||
/** 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 (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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.preserveSyntheticForwardOnNextNavigation = 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.preserveSyntheticForwardOnNextNavigation = 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 +1374,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(
|
||||
@@ -1344,6 +1503,7 @@ function createTabView(): WebContentsView {
|
||||
'did-start-navigation',
|
||||
bindToBrowserScope(scopeId, (details) => {
|
||||
if (!details.isMainFrame) return
|
||||
notePageNavigationStarted(contents)
|
||||
events?.onTabNavigated(contents, false)
|
||||
})
|
||||
)
|
||||
@@ -1795,49 +1955,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 +1962,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 +2329,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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @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: '<script>alert(1)</script>',
|
||||
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,
|
||||
focusRecovery: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(document.activeElement?.id).toBe('browser-page-issue-heading')
|
||||
const reload = container.querySelector<HTMLButtonElement>('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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
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
|
||||
focusRecovery: boolean
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replaces a hidden native page and optionally claims renderer focus for keyboard recovery. */
|
||||
export function BrowserPageIssueView({ issue, onReload, focusRecovery }: BrowserPageIssueProps) {
|
||||
const headingRef = useRef<HTMLHeadingElement>(null)
|
||||
const copy = browserPageIssueCopy(issue)
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRecovery) headingRef.current?.focus()
|
||||
}, [focusRecovery, issue])
|
||||
|
||||
const Icon = issue.kind === 'load-error' ? Globe : CircleAlert
|
||||
|
||||
return (
|
||||
<section
|
||||
className='absolute inset-0 flex items-center justify-center bg-[var(--bg)] px-8'
|
||||
role='alert'
|
||||
aria-live='polite'
|
||||
aria-labelledby='browser-page-issue-heading'
|
||||
>
|
||||
<div className='-translate-y-8 w-full max-w-[360px]'>
|
||||
<Icon className='mb-7 size-8 text-[var(--text-icon)]' />
|
||||
<h2
|
||||
ref={headingRef}
|
||||
id='browser-page-issue-heading'
|
||||
className='rounded-[4px] font-medium text-[var(--text-primary)] text-base outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--border-1)]'
|
||||
tabIndex={-1}
|
||||
>
|
||||
{copy.headline}
|
||||
</h2>
|
||||
<p className='mt-2 text-[var(--text-secondary)] text-small'>{copy.detail}</p>
|
||||
<p className='mt-6 text-[var(--text-secondary)] text-small'>Try:</p>
|
||||
<ul className='mt-2 list-disc space-y-1 pl-5 text-[var(--text-secondary)] text-small'>
|
||||
{copy.suggestions.map((suggestion) => (
|
||||
<li key={suggestion}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className='mt-6 break-all font-mono text-[var(--text-muted)] text-xs'>{copy.code}</p>
|
||||
<Button type='button' variant='default' size='sm' className='mt-8 gap-1' onClick={onReload}>
|
||||
<RefreshCw className='size-[14px]' />
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
+95
-22
@@ -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<HTMLDivElement>(null)
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
// Lets the occlusion handshake reject a capture taken before a modal's
|
||||
@@ -380,6 +395,7 @@ export function BrowserSession({
|
||||
const fillButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const toolbarMenuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const omniboxFocusRafRef = useRef<number | null>(null)
|
||||
const omniboxPointerSelectionRef = useRef<OmniboxPointerSelection | null>(null)
|
||||
const pendingNewTabFocusRef = useRef<PendingNewTabFocus | null>(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<HTMLElement>('[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
|
||||
@@ -911,28 +932,31 @@ 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) {
|
||||
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 +970,7 @@ export function BrowserSession({
|
||||
return
|
||||
}
|
||||
urlInputRef.current?.blur()
|
||||
}, [activeSuggestion, navigateTo, suggestions, suggestionsOpen, urlDraft])
|
||||
}
|
||||
|
||||
const handleNewTab = useCallback(() => {
|
||||
setSuggestionsVisible(false)
|
||||
@@ -1146,12 +1170,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 +1228,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 +1446,13 @@ export function BrowserSession({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{pageState?.issue && (
|
||||
<BrowserPageIssueView
|
||||
issue={pageState.issue}
|
||||
focusRecovery={visible}
|
||||
onReload={() => sendBrowserPanelAction('reload', {}, scopeId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+3
@@ -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…'
|
||||
|
||||
+9
-1
@@ -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 (
|
||||
<span className='flex size-[16px] shrink-0 items-center justify-center'>
|
||||
<CircleAlert className='size-[12px] text-[var(--text-icon)]' />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='relative flex size-[16px] shrink-0 items-center justify-center'>
|
||||
{hostname && !faviconFailed && (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<BrowserSessionState>()(
|
||||
title: pageState.title,
|
||||
loading: pageState.loading,
|
||||
active: true,
|
||||
issue: pageState.issue,
|
||||
}
|
||||
: tab.active
|
||||
? { ...tab, active: false }
|
||||
@@ -252,6 +271,7 @@ export const useBrowserSessionStore = create<BrowserSessionState>()(
|
||||
loading: activeTab.loading,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
...(activeTab.issue ? { issue: activeTab.issue } : {}),
|
||||
}
|
||||
const sessionAlive = tabs.length > 0
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user