diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 5c268072838..bd5763cd810 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1277,6 +1277,18 @@ describe('executeTool', () => { expect(driver.migrateBrowserScope('pending:other-chat', 'chat-occupied')).toBe(false) }) + it('keeps a durable destination adoptable after an empty restore', async () => { + await driver.executeTool('pending:new-chat', 'browser_open_tab', {}) + driver.activateBrowserScope('chat-real') + expect(driver.restoreBrowserScope('chat-real')).toMatchObject({ tabs: [] }) + + expect(driver.migrateBrowserScope('pending:new-chat', 'chat-real')).toBe(true) + await expect(driver.executeTool('chat-real', 'browser_list_tabs', {})).resolves.toMatchObject({ + ok: true, + result: { scopeId: 'chat-real', tabs: [{ tabId: '1' }] }, + }) + }) + it('cancels only the replaced destination authorizations during migration', async () => { await driver.executeTool('pending:new-chat', 'browser_open_tab', {}) driver.activateBrowserScope('chat-real') @@ -1395,7 +1407,7 @@ describe('executeTool', () => { it('keeps activation lazy, then restores and disposes through the driver API', async () => { const snapshot: BrowserSessionSnapshot = { v: 1, - tabs: [{ url: 'https://restored.example/', pinned: false }], + tabs: [{ url: 'https://restored.example/' }], activeIndex: 0, downloads: [], } diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 62f3fda318d..83d06102de3 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -794,10 +794,13 @@ export function restoreBrowserScope(scopeId: string): BrowserTabsState { return session.withBrowserScope(resolved, () => session.peekTabsState()) } const state = driverScopeState(resolved) - state.activationOnly = false return session.withBrowserScope(resolved, () => { session.restoreBrowserSession() - return session.peekTabsState() + const tabs = session.peekTabsState() + // Only a scope that actually holds pages is material; one restored empty + // stays adoptable by a pending chat migrating onto its id. + if (tabs.tabs.length > 0) state.activationOnly = false + return tabs }) } @@ -905,7 +908,7 @@ export async function clearBrowserProfile( retireAllDriverScopeStates() const settingsCleared = knownSessions?.clear() !== false const outcomes = await Promise.allSettled([session.clearProfileStorage(), clearCredentials()]) - // Last, covering the pinned-tab list `clearProfileStorage` just emptied. + // Last, covering the saved tab list `clearProfileStorage` just emptied. // Settings writes coalesce, and an erasure that is still sitting in that // window when the process dies leaves the previous account's data on disk // after sign-out already told the user it was gone. @@ -2368,8 +2371,7 @@ async function executeToolInner( } } assertCurrentExecution() - // The agent chose to open this page to work in, so the panel follows it. - const tab = session.addAutomationTab({ reveal: true }) + const tab = session.addAutomationTab() const contents = tab.view.webContents if (url) { assertCurrentExecution() @@ -4782,19 +4784,9 @@ export async function handlePanelAction( } return } - if (action.action === 'new-tab') { - session.addTab() - return - } - if (action.action === 'duplicate-tab') { - if (typeof action.tabId === 'string') { - session.duplicateTab(action.tabId) - } - return - } if (action.action === 'switch-tab') { if (typeof action.tabId === 'string') { - session.switchTab(action.tabId) + session.switchTab(action.tabId, { claim: action.claim !== false }) } return } diff --git a/apps/desktop/src/main/browser-agent/panel.test.ts b/apps/desktop/src/main/browser-agent/panel.test.ts index d8d04665315..5aa024772c7 100644 --- a/apps/desktop/src/main/browser-agent/panel.test.ts +++ b/apps/desktop/src/main/browser-agent/panel.test.ts @@ -20,7 +20,7 @@ function freshPanel(): PanelModule { getMainWindow: () => null, activeTab: () => null, backgroundColor: () => '#ffffff', - ensureInitialTab: () => {}, + restoreActiveScope: () => {}, onViewDetached: () => {}, }) panelModule.activatePanelScope('chat-test') @@ -33,12 +33,12 @@ const PANEL_RECT = { x: 400, y: 64, width: 600, height: 800 } function showPanel(panel: PanelModule) { const win = new BrowserWindow() const view = new WebContentsView() - const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false } + const active = { id: 'tab-1', scopeId: 'chat-test', view } panel.initPanel({ getMainWindow: () => win, activeTab: () => active, backgroundColor: () => '#0c0c0c', - ensureInitialTab: () => {}, + restoreActiveScope: () => {}, onViewDetached: () => {}, }) panel.activatePanelScope('chat-test') @@ -56,13 +56,13 @@ describe('panel chat scope', () => { it('returns keyboard focus to the renderer when attaching a view steals it mid-typing', () => { const win = new BrowserWindow() const view = new WebContentsView() - const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false } + const active = { id: 'tab-1', scopeId: 'chat-test', view } vi.mocked(win.webContents.isFocused).mockReturnValue(true) panel.initPanel({ getMainWindow: () => win, activeTab: () => active, backgroundColor: () => '#0c0c0c', - ensureInitialTab: () => {}, + restoreActiveScope: () => {}, onViewDetached: () => {}, }) panel.activatePanelScope('chat-test') @@ -327,12 +327,12 @@ describe('panel chat scope', () => { it('applies a forced hide before the panel reports its first bounds', () => { const win = new BrowserWindow() const view = new WebContentsView() - const active = { id: 'tab-1', scopeId: 'chat-test', view, pinned: false } + const active = { id: 'tab-1', scopeId: 'chat-test', view } panel.initPanel({ getMainWindow: () => win, activeTab: () => active, backgroundColor: () => '#0c0c0c', - ensureInitialTab: () => {}, + restoreActiveScope: () => {}, onViewDetached: () => {}, }) panel.activatePanelScope('chat-test') diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts index 207448ffba4..42ca72e4f89 100644 --- a/apps/desktop/src/main/browser-agent/panel.ts +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -41,12 +41,8 @@ export interface PanelHost { activeTab: () => AgentTab | null /** Native backdrop used by a blank tab before its first page paint. */ backgroundColor: () => string - /** - * Materializes the initial tab when the panel first becomes visible: a - * visible browser resource always represents one open browser window, and - * the tab strip, omnibox, and native session must not disagree about that. - */ - ensureInitialTab: () => void + /** Hydrates the active scope's saved pages when the panel first becomes visible. */ + restoreActiveScope: () => void /** Lets the session drop focus tracking for a view that is no longer attached. */ onViewDetached: (view: WebContentsView | null) => void } @@ -55,7 +51,7 @@ let host: PanelHost = { getMainWindow: () => null, activeTab: () => null, backgroundColor: () => '#ffffff', - ensureInitialTab: () => {}, + restoreActiveScope: () => {}, onViewDetached: () => {}, } @@ -834,7 +830,7 @@ export function setPanelBounds( panelBounds = bounds panelAnchor = bounds === null ? null : (anchor ?? null) if (bounds !== null) { - host.ensureInitialTab() + host.restoreActiveScope() } else { resetOcclusion() } diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index b7f62e74266..2a0a48e58fd 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -505,7 +505,7 @@ describe('browser-agent session', () => { it('preserves a persisted destination behind a lazy activation', () => { const existingSnapshot: BrowserSessionSnapshot = { v: 1, - tabs: [{ url: 'https://existing.example/', pinned: false }], + tabs: [{ url: 'https://existing.example/' }], activeIndex: 0, downloads: [], } @@ -607,10 +607,7 @@ describe('browser-agent session', () => { vi.mocked((second.view as unknown as MockView).webContents.getURL).mockReturnValue( 'https://two.example/' ) - session.withBrowserScope('chat-a', () => { - session.setTabPinned(first.id, true) - session.switchTab(second.id) - }) + session.withBrowserScope('chat-a', () => session.switchTab(second.id)) session = freshSession(win, {}, persistence) vi.mocked(persistence.load).mockClear() @@ -629,8 +626,8 @@ describe('browser-agent session', () => { scopeId: 'chat-a', activeTabId: '2', tabs: [ - { tabId: '1', url: 'https://one.example/', pinned: true, active: false }, - { tabId: '2', url: 'https://two.example/', pinned: false, active: true }, + { tabId: '1', url: 'https://one.example/', active: false }, + { tabId: '2', url: 'https://two.example/', active: true }, ], }) expect(session.withBrowserScope('chat-a', () => session.activeTab()?.view)).not.toBe( @@ -641,7 +638,6 @@ describe('browser-agent session', () => { it('selects and starts the active restore before three bounded background loads', async () => { const tabs = Array.from({ length: 7 }, (_, index) => ({ url: `https://restore-${index}.example/`, - pinned: index < 2, })) const { persistence } = memoryBrowserPersistence({ 'chat-restore-order': { @@ -677,13 +673,13 @@ describe('browser-agent session', () => { ).toMatchObject({ activeTabId: '6', tabs: [ - { tabId: '1', pinned: true }, - { tabId: '2', pinned: true }, - { tabId: '3', pinned: false }, - { tabId: '4', pinned: false }, - { tabId: '5', pinned: false }, - { tabId: '6', pinned: false, active: true }, - { tabId: '7', pinned: false }, + { tabId: '1' }, + { tabId: '2' }, + { tabId: '3' }, + { tabId: '4' }, + { tabId: '5' }, + { tabId: '6', active: true }, + { tabId: '7' }, ], }) expect(createdContents[5].loadURL).toHaveBeenCalledWith(tabs[5].url) @@ -704,7 +700,6 @@ describe('browser-agent session', () => { it('preempts a background restore for a user-selected queued tab', async () => { const tabs = Array.from({ length: 7 }, (_, index) => ({ url: `https://priority-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-restore-priority': { @@ -760,7 +755,6 @@ describe('browser-agent session', () => { it('keeps a deferred restore intact when Back and Forward cannot move', () => { const tabs = Array.from({ length: 6 }, (_, index) => ({ url: `https://deferred-history-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-deferred-history': { v: 1, tabs, activeIndex: 0, downloads: [] }, @@ -794,7 +788,6 @@ describe('browser-agent session', () => { try { const tabs = Array.from({ length: 7 }, (_, index) => ({ url: `https://model-restore-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-model-restore': { v: 1, tabs, activeIndex: 0, downloads: [] }, @@ -853,7 +846,6 @@ describe('browser-agent session', () => { try { const tabs = Array.from({ length: 4 }, (_, index) => ({ url: `https://active-restore-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-active-restore': { v: 1, tabs, activeIndex: 0, downloads: [] }, @@ -903,7 +895,7 @@ describe('browser-agent session', () => { `chat-foreground-${index}`, { v: 1 as const, - tabs: [{ url: `https://foreground-${index}.example/`, pinned: false }], + tabs: [{ url: `https://foreground-${index}.example/` }], activeIndex: 0, downloads: [], }, @@ -952,11 +944,9 @@ describe('browser-agent session', () => { try { const firstTabs = Array.from({ length: 6 }, (_, index) => ({ url: `https://hung-a-${index}.example/`, - pinned: false, })) const secondTabs = Array.from({ length: 2 }, (_, index) => ({ url: `https://waiting-b-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-hung-a': { v: 1, tabs: firstTabs, activeIndex: 0, downloads: [] }, @@ -1002,7 +992,7 @@ describe('browser-agent session', () => { const { persistence } = memoryBrowserPersistence({ 'chat-throwing-stop': { v: 1, - tabs: [{ url: restoredUrl, pinned: false }], + tabs: [{ url: restoredUrl }], activeIndex: 0, downloads: [], }, @@ -1045,8 +1035,8 @@ describe('browser-agent session', () => { vi.useFakeTimers() try { const tabs = [ - { url: 'http://127.0.0.1:4601/active', pinned: false }, - { url: 'http://127.0.0.1:4601/background', pinned: false }, + { url: 'http://127.0.0.1:4601/active' }, + { url: 'http://127.0.0.1:4601/background' }, ] const { persistence } = memoryBrowserPersistence({ 'chat-stale-restore-prompt': { v: 1, tabs, activeIndex: 0, downloads: [] }, @@ -1107,8 +1097,8 @@ describe('browser-agent session', () => { vi.useFakeTimers() try { const tabs = [ - { url: 'http://127.0.0.1:4611/active', pinned: false }, - { url: 'http://127.0.0.1:4611/background', pinned: false }, + { url: 'http://127.0.0.1:4611/active' }, + { url: 'http://127.0.0.1:4611/background' }, ] const { persistence } = memoryBrowserPersistence({ 'chat-bounded-restore-prompt': { v: 1, tabs, activeIndex: 0, downloads: [] }, @@ -1166,7 +1156,6 @@ describe('browser-agent session', () => { it('discards a queued restore before an explicit replacement navigation can race it', async () => { const tabs = Array.from({ length: 6 }, (_, index) => ({ url: `https://stale-restore-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-replace-restore': { v: 1, tabs, activeIndex: 0, downloads: [] }, @@ -1209,7 +1198,6 @@ describe('browser-agent session', () => { it('does not start queued restores after their task browser is suspended', async () => { const tabs = Array.from({ length: 6 }, (_, index) => ({ url: `https://suspended-${index}.example/`, - pinned: false, })) const { persistence } = memoryBrowserPersistence({ 'chat-restore-suspended': { @@ -1257,7 +1245,6 @@ describe('browser-agent session', () => { it('restores more than eight persisted tabs', () => { const tabs = Array.from({ length: 12 }, (_, index) => ({ url: `https://tab-${index}.example/`, - pinned: index < 2, })) const { persistence } = memoryBrowserPersistence({ 'chat-many-tabs': { @@ -1282,10 +1269,9 @@ describe('browser-agent session', () => { }) }) - it('bounds restored tabs while retaining pinned tabs and the active page', () => { + it('bounds restored tabs while retaining the active page', () => { const tabs = Array.from({ length: 40 }, (_, index) => ({ url: `https://tab-${index}.example/`, - pinned: index < 5, })) const { persistence } = memoryBrowserPersistence({ 'chat-bounded-tabs': { @@ -1303,7 +1289,6 @@ describe('browser-agent session', () => { }) expect(restored.tabs).toHaveLength(32) - expect(restored.tabs.filter((tab) => tab.pinned)).toHaveLength(5) expect(restored.tabs.find((tab) => tab.active)?.url).toBe('https://tab-39.example/') }) @@ -1329,10 +1314,7 @@ describe('browser-agent session', () => { }) it('does not truncate a saved browser session while the global tab budget is occupied', () => { - const savedTabs = [ - { url: 'https://saved-one.example/', pinned: false }, - { url: 'https://saved-two.example/', pinned: false }, - ] + const savedTabs = [{ url: 'https://saved-one.example/' }, { url: 'https://saved-two.example/' }] const { persistence, snapshots } = memoryBrowserPersistence({ 'chat-pending-restore': { v: 1, @@ -1373,9 +1355,9 @@ describe('browser-agent session', () => { 'chat-retry': { v: 1, tabs: [ - { url: 'https://one.example/', pinned: false }, - { url: 'https://two.example/', pinned: true }, - { url: 'https://three.example/', pinned: false }, + { url: 'https://one.example/' }, + { url: 'https://two.example/' }, + { url: 'https://three.example/' }, ], activeIndex: 2, downloads: [], @@ -1399,9 +1381,9 @@ describe('browser-agent session', () => { expect(session.withBrowserScope('chat-retry', () => session.getTabsState())).toMatchObject({ activeTabId: '3', tabs: [ - { tabId: '2', url: 'https://two.example/', pinned: true, active: false }, - { tabId: '1', url: 'https://one.example/', pinned: false, active: false }, - { tabId: '3', url: 'https://three.example/', pinned: false, active: true }, + { tabId: '1', url: 'https://one.example/', active: false }, + { tabId: '2', url: 'https://two.example/', active: false }, + { tabId: '3', url: 'https://three.example/', active: true }, ], }) @@ -1414,7 +1396,7 @@ describe('browser-agent session', () => { const onSessionClosed = vi.fn() const lazySnapshot: BrowserSessionSnapshot = { v: 1, - tabs: [{ url: 'https://lazy.example/', pinned: false }], + tabs: [{ url: 'https://lazy.example/' }], activeIndex: 0, downloads: [], } @@ -1453,7 +1435,7 @@ describe('browser-agent session', () => { it('migrates a persisted pending snapshot without hydrating either scope', () => { const snapshot: BrowserSessionSnapshot = { v: 1, - tabs: [{ url: 'https://pending.example/', pinned: false }], + tabs: [{ url: 'https://pending.example/' }], activeIndex: 0, downloads: [], } @@ -1513,7 +1495,7 @@ describe('browser-agent session', () => { expect(persistence.disposeScope).not.toHaveBeenCalled() expect(snapshots.get('chat-deleted')).toEqual({ v: 1, - tabs: [{ url: 'https://retained.example/', pinned: false }], + tabs: [{ url: 'https://retained.example/' }], activeIndex: 0, downloads: [], }) @@ -1577,7 +1559,7 @@ describe('browser-agent session', () => { it('handles browser shortcuts from a focused native tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const first = session.requireTab() + const first = session.ensureTab() const firstContents = (first.view as unknown as MockView).webContents const beforeInput = firstContents.on.mock.calls.find( ([eventName]) => eventName === 'before-input-event' @@ -1625,9 +1607,10 @@ describe('browser-agent session', () => { expect(session.listTabs()).toHaveLength(1) expect(firstContents.focus).toHaveBeenCalled() + // Closing the last tab leaves the strip empty; the renderer drops the + // browser panel with it, so the omnibox is handed back cleared. beforeInput?.(event, { ...input, key: 'w' }) - expect(session.listTabs()).toHaveLength(1) - expect(session.listTabs()[0].tabId).not.toBe(first.id) + expect(session.listTabs()).toHaveLength(0) expect(win.webContents.send).toHaveBeenLastCalledWith( 'browser-agent:focus-omnibox', 'clear', @@ -1637,7 +1620,7 @@ describe('browser-agent session', () => { it('opens the renderer find bar when the page takes Mod+F', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents const beforeInput = contents.on.mock.calls.find( ([eventName]) => eventName === 'before-input-event' @@ -1664,7 +1647,7 @@ describe('browser-agent session', () => { it('restarts the search while typing and steps without restarting on next/previous', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents session.findInActiveTab({ query: 'needle', newSession: true, forward: true }) @@ -1691,7 +1674,7 @@ describe('browser-agent session', () => { it('forwards match counts only for the tab the find is running on', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const first = session.requireTab() + const first = session.ensureTab() const second = session.addTab() const firstContents = (first.view as unknown as MockView).webContents const secondContents = (second.view as unknown as MockView).webContents @@ -1728,7 +1711,7 @@ describe('browser-agent session', () => { it('drops late match counts from an older request on the active tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents const found = contents.on.mock.calls.find( ([eventName]) => eventName === 'found-in-page' @@ -1752,7 +1735,7 @@ describe('browser-agent session', () => { it('drops the find when its page navigates away, but not on a same-document change', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents const navigate = contents.on.mock.calls.find( ([eventName]) => eventName === 'did-start-navigation' @@ -1778,7 +1761,7 @@ describe('browser-agent session', () => { // counting matches on a page that no longer exists, and nothing clears it // until the user happens to type a new query. panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - session.requireTab() + session.ensureTab() const second = session.addTab() session.switchTab(second.id) session.findInActiveTab({ query: 'needle', newSession: true, forward: true }) @@ -1791,7 +1774,7 @@ describe('browser-agent session', () => { it('drops the find when the tab it is running on crashes', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const first = session.requireTab() + const first = session.ensureTab() session.addTab() session.switchTab(first.id) session.findInActiveTab({ query: 'needle', newSession: true, forward: true }) @@ -1952,7 +1935,7 @@ describe('browser-agent session', () => { 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() + const first = session.ensureTab() const second = session.addTab() const firstContents = (first.view as unknown as MockView).webContents @@ -1967,7 +1950,7 @@ describe('browser-agent session', () => { it('returns focus to the page only when the user dismissed the bar', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents // Panel teardown: the bar unmounts under a user who has already moved on, @@ -1985,7 +1968,7 @@ describe('browser-agent session', () => { it('returns focus to the page even when no search was running', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents // Opened and closed without typing. Focus still has to leave the bar: it is @@ -2005,7 +1988,7 @@ describe('browser-agent session', () => { it('closes only the native browser tab targeted by the application menu accelerator', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const first = session.requireTab() + const first = session.ensureTab() const second = session.addTab() const firstContents = (first.view as unknown as MockView).webContents const secondContents = (second.view as unknown as MockView).webContents @@ -2030,25 +2013,17 @@ describe('browser-agent session', () => { // Focus ownership transfers with the close, so a repeated Mod+W closes // the newly active tab even if Electron has not emitted its focus event. expect(session.handleFocusedShortcut('close-tab')).toBe(true) - expect(session.listTabs()).toHaveLength(1) - expect(session.listTabs()[0].tabId).not.toBe(first.id) - - // The replacement is an untouched about:blank tab. It still owns the - // browser context, so it must not require a page load or another click. - const blankTabId = session.listTabs()[0].tabId - expect(session.handleFocusedShortcut('close-tab')).toBe(true) - expect(session.listTabs()).toHaveLength(1) - expect(session.listTabs()[0].tabId).not.toBe(blankTabId) + expect(session.listTabs()).toHaveLength(0) session.setPanelFocused(false) panel.setPanelBounds(null) expect(session.handleFocusedShortcut('close-tab')).toBe(false) - expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()).toHaveLength(0) }) it('keeps close-tab routed to a visible browser through a transient focus loss', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const first = session.requireTab() + const first = session.ensureTab() const second = session.addTab() session.setPanelFocused(true) @@ -2059,7 +2034,7 @@ describe('browser-agent session', () => { session.setPanelFocused(false) expect(session.handleFocusedShortcut('close-tab')).toBe(true) - expect(session.listTabs()).toHaveLength(1) + expect(session.listTabs()).toHaveLength(0) panel.setPanelBounds(null) expect(session.handleFocusedShortcut('close-tab')).toBe(false) @@ -2067,7 +2042,7 @@ describe('browser-agent session', () => { it('keeps browser tab shortcuts routed while the visible panel has no DOM focus', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - session.requireTab() + session.ensureTab() session.setPanelFocused(false) const before = session.listTabs().length @@ -2094,7 +2069,7 @@ describe('browser-agent session', () => { it('reloads only the focused browser tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const first = session.requireTab() + const first = session.ensureTab() const second = session.addTab() const firstContents = (first.view as unknown as MockView).webContents const secondContents = (second.view as unknown as MockView).webContents @@ -2121,7 +2096,7 @@ describe('browser-agent session', () => { it('does not reload a browser tab owned by another app window', () => { const otherWindow = mainWindowMock() panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }, win) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents session.setPanelFocused(true, win) @@ -2134,7 +2109,7 @@ describe('browser-agent session', () => { it('zooms only the focused browser tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - const tab = session.requireTab() + const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents contents.getZoomFactor.mockReturnValue(1) @@ -2382,7 +2357,7 @@ describe('browser-agent session', () => { it('claims reopen while focused even when there is no closed tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) - session.requireTab() + session.ensureTab() session.setPanelFocused(true) expect(session.handleFocusedShortcut('reopen-closed-tab')).toBe(true) @@ -2403,76 +2378,11 @@ describe('browser-agent session', () => { expect(session.handleFocusedShortcut('close-tab', win)).toBe(true) }) - it('reorders tabs while preserving the pinned-tab boundary', () => { - const first = session.ensureTab() - const second = session.addTab() - const third = session.addTab() - - session.reorderTab(third.id, 0) - expect(session.listTabs().map((tab) => tab.tabId)).toEqual([third.id, first.id, second.id]) - - session.setTabPinned(first.id, true) - session.reorderTab(second.id, 0) - expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id]) - - session.reorderTab(first.id, 2) - expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id]) - expect(() => session.reorderTab('999', 0)).toThrow(/No tab with id 999/) - }) - - it('moves pinned tabs left and requires unpinning before any close path', async () => { - const { persistence, snapshots } = memoryBrowserPersistence() - const pinnedSession = freshSession(win, {}, persistence) - const first = pinnedSession.ensureTab() - const second = pinnedSession.addTab() - - pinnedSession.setTabPinned(second.id, true) - - expect(pinnedSession.listTabs()).toEqual([ - expect.objectContaining({ tabId: second.id, pinned: true }), - expect.objectContaining({ tabId: first.id, pinned: false }), - ]) - expect(snapshots.get('chat-test')?.tabs).toEqual([ - { url: 'https://example.com/', pinned: true }, - { url: 'https://example.com/', pinned: false }, - ]) - expect(() => pinnedSession.closeTab(second.id)).toThrow(/Pinned tabs cannot be closed/) - - pinnedSession.setTabPinned(second.id, false) - pinnedSession.closeTab(second.id) - expect(pinnedSession.listTabs().map((tab) => tab.tabId)).toEqual([first.id]) - expect(snapshots.get('chat-test')?.tabs).toEqual([ - { url: 'https://example.com/', pinned: false }, - ]) - }) - - it('opens native tab actions and keeps them bound to the right-clicked chat', () => { - const first = session.withBrowserScope('chat-a', () => session.ensureTab()) - session.withBrowserScope('chat-b', () => session.ensureTab()) - vi.mocked(Menu.buildFromTemplate).mockClear() - - session.withBrowserScope('chat-a', () => session.showTabContextMenu(first.id)) - const template = vi.mocked(Menu.buildFromTemplate).mock.calls[0]?.[0] as - | MenuItemConstructorOptions[] - | undefined - expect(template?.filter((item) => item.type !== 'separator').map((item) => item.label)).toEqual( - ['Pin Tab', 'Duplicate Tab', 'Close Tab'] - ) - - session.activateBrowserScope('chat-b') - const duplicate = template?.find((item) => item.label === 'Duplicate Tab') - const clickDuplicate = duplicate?.click as (() => void) | undefined - clickDuplicate?.() - - expect(session.withBrowserScope('chat-a', () => session.listTabs())).toHaveLength(2) - expect(session.withBrowserScope('chat-b', () => session.listTabs())).toHaveLength(1) - }) - - it('restores pinned tabs when the browser resource opens again', async () => { + it('restores saved tabs when the browser panel opens again', async () => { const { persistence } = memoryBrowserPersistence({ 'chat-test': { v: 1, - tabs: [{ url: 'https://docs.sim.ai/guide', pinned: true }], + tabs: [{ url: 'https://docs.sim.ai/guide' }], activeIndex: 0, downloads: [], }, @@ -2482,31 +2392,41 @@ describe('browser-agent session', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) const [restored] = restoredSession.listTabs() - expect(restored).toMatchObject({ pinned: true, active: true }) + expect(restored).toMatchObject({ active: true }) const contents = (restoredSession.requireTab().view as unknown as MockView).webContents expect(contents.loadURL).toHaveBeenCalledWith('https://docs.sim.ai/guide') - expect(() => restoredSession.closeTab(restored.tabId)).toThrow(/Pinned tabs cannot be closed/) const regular = restoredSession.addTab() expect(restoredSession.listTabs()).toEqual([ - expect.objectContaining({ tabId: restored.tabId, pinned: true }), - expect.objectContaining({ tabId: regular.id, pinned: false, active: true }), + expect.objectContaining({ tabId: restored.tabId, active: false }), + expect.objectContaining({ tabId: regular.id, active: true }), ]) }) - it('allows creation, duplication, and reopening beyond eight browser tabs', () => { + it('reorders tabs to the order the resource strip asks for', () => { const first = session.ensureTab() + const second = session.addTab() + const third = session.addTab() + + session.reorderTab(third.id, 0) + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([third.id, first.id, second.id]) + + session.reorderTab(third.id, 99) + expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id, second.id, third.id]) + expect(() => session.reorderTab('999', 0)).toThrow(/No tab with id 999/) + }) + + it('allows creation and reopening beyond eight browser tabs', () => { + session.ensureTab() for (let index = 1; index < 12; index++) { session.addTab() } expect(session.listTabs()).toHaveLength(12) - const duplicate = session.duplicateTab(first.id) - expect(duplicate).not.toBeNull() + const extra = session.addTab() expect(session.listTabs()).toHaveLength(13) - if (!duplicate) throw new Error('expected the tab to be duplicated') - session.closeTab(duplicate.id) + session.closeTab(extra.id) expect(session.listTabs()).toHaveLength(12) expect(session.reopenClosedTab()).not.toBeNull() expect(session.listTabs()).toHaveLength(13) @@ -2700,18 +2620,17 @@ describe('browser-agent session', () => { ).toHaveBeenCalledWith('resize', onResize) }) - it('creates one real default tab when the browser panel becomes visible', () => { + it('never invents a tab for a visible panel, and closing the last tab leaves none', () => { expect(session.listTabs()).toHaveLength(0) panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + expect(session.listTabs()).toHaveLength(0) - expect(session.listTabs()).toHaveLength(1) - expect(session.getTabsState().activeTabId).toBe(session.listTabs()[0].tabId) - - const firstTabId = session.listTabs()[0].tabId - session.closeTab(firstTabId) - expect(session.listTabs()).toHaveLength(1) - expect(session.listTabs()[0].tabId).not.toBe(firstTabId) + const tab = session.ensureTab() + expect(session.getTabsState().activeTabId).toBe(tab.id) + session.closeTab(tab.id) + expect(session.listTabs()).toHaveLength(0) + expect(session.getTabsState().activeTabId).toBeNull() }) it('clears a stale attachment without touching a destroyed host window', () => { @@ -2813,19 +2732,19 @@ describe('browser-agent session', () => { expect(contents.loadURL).not.toHaveBeenCalled() }) - it('brings an agent-opened working tab into view, unless the user claimed the visible one', () => { - // browser_open_tab is the agent choosing a page to work in — the panel - // follows it so the work is visible, which a popup deliberately does not. + it('opens agent working tabs behind the visible page', () => { + // browser_open_tab is the agent choosing a page to work in. Which page is + // shown is the renderer's decision, so the native selection never moves. const first = session.ensureTab() - const working = session.addAutomationTab({ reveal: true }) - expect(session.activeTab()).toBe(working) + const working = session.addAutomationTab() expect(working.id).not.toBe(first.id) + expect(session.activeTab()).toBe(first) + expect(session.automationTab()).toBe(working) - // Once the user claims what they are looking at, the next agent tab opens - // behind it rather than yanking the page out from under them. session.claimActiveTabForUser() - const background = session.addAutomationTab({ reveal: true }) - expect(session.activeTab()).not.toBe(background) + const background = session.addAutomationTab() + expect(session.activeTab()).toBe(first) + expect(session.automationTab()).toBe(background) }) it('keeps agent popups in the background and context-menu links user-owned', () => { @@ -3384,7 +3303,7 @@ describe('browser-agent session', () => { const { persistence } = memoryBrowserPersistence({ 'chat-test': { v: 1, - tabs: [{ url: restoredUrl, pinned: true }], + tabs: [{ url: restoredUrl }], activeIndex: 0, downloads: [], }, @@ -3515,7 +3434,7 @@ describe('browser-agent session', () => { expect(clearCache).toHaveBeenCalled() }) - it('does not rewrite the settings file when the pinned tabs have not changed', async () => { + it('does not rewrite the settings file when the saved tabs have not changed', async () => { const { persistence } = memoryBrowserPersistence() session = freshSession(win, {}, persistence) const tab = session.ensureTab() @@ -3535,19 +3454,22 @@ describe('browser-agent session', () => { expect(persistence.save).not.toHaveBeenCalled() }) - it('persists once when a tab actually becomes pinned', async () => { + it('persists once when the saved strip actually changes', async () => { const { persistence } = memoryBrowserPersistence() session = freshSession(win, {}, persistence) const tab = session.ensureTab() - ;(tab.view as unknown as MockView).webContents.getURL.mockReturnValue('https://example.com/') vi.mocked(persistence.save).mockClear() + ;(tab.view as unknown as MockView).webContents.getURL.mockReturnValue( + 'https://example.com/changed' + ) - session.setTabPinned(tab.id, true) + session.switchTab(tab.id) + session.switchTab(tab.id) expect(persistence.save).toHaveBeenCalledTimes(1) expect(persistence.save).toHaveBeenLastCalledWith( 'chat-test', - expect.objectContaining({ tabs: [{ url: 'https://example.com/', pinned: true }] }) + expect.objectContaining({ tabs: [{ url: 'https://example.com/changed' }] }) ) }) @@ -4615,39 +4537,6 @@ describe('reopening a closed tab', () => { expect((reopened?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled() }) - it('duplicates a tab by loading the same URL in a new one', () => { - session.ensureTab() - const source = session.addTab() - ;(source.view as unknown as MockView).webContents.getURL.mockReturnValue( - 'https://example.com/inbox' - ) - - const copy = session.duplicateTab(source.id) - - expect(copy?.id).not.toBe(source.id) - expect((copy?.view as unknown as MockView).webContents.loadURL).toHaveBeenCalledWith( - 'https://example.com/inbox' - ) - }) - - it('never copies a URL carrying embedded credentials into a duplicate', () => { - session.ensureTab() - const source = session.addTab() - ;(source.view as unknown as MockView).webContents.getURL.mockReturnValue( - 'https://user:pass@example.com/' - ) - - const copy = session.duplicateTab(source.id) - - // Falls back to a blank tab rather than re-sending the credentials. - expect((copy?.view as unknown as MockView).webContents.loadURL).not.toHaveBeenCalled() - }) - - it('returns null when duplicating a tab that is not open', () => { - session.ensureTab() - expect(session.duplicateTab('no-such-tab')).toBeNull() - }) - it('drops a non-http scheme from the reopen list', () => { session.ensureTab() const closing = session.addTab() diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 2d3807af204..bbfe654b6c9 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -90,7 +90,6 @@ export interface AgentTab { id: string scopeId: string view: WebContentsView - pinned: boolean pendingRestoreUrl?: string pendingRestore?: PendingTabRestore pageIssue?: BrowserPageIssue @@ -828,7 +827,7 @@ export function showBrowserDownloadInFolder(scopeId: string, downloadId: string) * * {@link initSession} names itself as the session boundary but set three of * these fields and left the rest, so a second call would inherit the first - * session's tab id counter, theme, pinned-restore latch and persisted-list + * session's tab id counter, theme, restore latch and persisted-list * digest — the last of which would then suppress the new session's first save * as an unchanged write. Nothing re-inits in production today, which is * exactly why the gap stayed invisible, and why the tests had to reset the @@ -877,15 +876,10 @@ export function initSession( return scopeId ? withBrowserScope(scopeId, activeTab) : null }, backgroundColor: browserBackgroundColor, - ensureInitialTab: () => { + restoreActiveScope: () => { const scopeId = getActiveBrowserScopeId() if (!scopeId) return - withBrowserScope(scopeId, () => { - restoreBrowserSession() - if (!hasSession()) { - ensureTab() - } - }) + withBrowserScope(scopeId, restoreBrowserSession) }, onViewDetached: (view) => { if (!view) return @@ -946,7 +940,6 @@ function isActivationOnlyBrowserScope(scopeId: string): boolean { state.activeTabId === null && state.automationTabId === null && state.nextTabId === 1 && - !state.restored && !state.restoring ) } @@ -1128,7 +1121,7 @@ function browserSessionSnapshot(): BrowserSessionSnapshot { .map(({ interruptionReason: _interruptionReason, ...download }) => ({ ...download })) return { v: 1, - tabs: liveTabs.map((tab) => ({ url: tabUrl(tab), pinned: tab.pinned })), + tabs: liveTabs.map((tab) => ({ url: tabUrl(tab) })), activeIndex, downloads, } @@ -2427,8 +2420,8 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV if (tab) dismissFind(tab.id) }) ) - // A pinned tab persists its latest top-level location, including - // user-driven navigations that do not pass through the driver. + // A tab persists its latest top-level location, including user-driven + // navigations that do not pass through the driver. contents.on( 'did-navigate', bindToBrowserScope(scopeId, () => { @@ -2649,26 +2642,11 @@ export function requireTab(): AgentTab { } interface AddTabOptions { - pinned?: boolean activate?: boolean notify?: boolean } -/** Pinned tabs join the stable group at the far left; regular tabs append. */ -function insertPinnedAware(tab: AgentTab): void { - if (tab.pinned) { - const firstRegularTab = tabs.findIndex((entry) => !entry.pinned) - tabs.splice(firstRegularTab < 0 ? tabs.length : firstRegularTab, 0, tab) - } else { - tabs.push(tab) - } -} - -function addTabInternal({ - pinned = false, - activate = true, - notify = true, -}: AddTabOptions = {}): AgentTab { +function addTabInternal({ activate = true, notify = true }: AddTabOptions = {}): AgentTab { assertTabCapacity() const previousActiveTab = activeTab() const transferBrowserFocus = @@ -2679,9 +2657,8 @@ function addTabInternal({ id: String(currentScope.nextTabId++), scopeId: getBrowserScopeId(), view: createTabView(), - pinned, } - insertPinnedAware(tab) + tabs.push(tab) if (currentScope.automationTabId === null) currentScope.automationTabId = tab.id if (activate || currentScope.activeTabId === null) { if (previousActiveTab && previousActiveTab.id !== tab.id) { @@ -2995,7 +2972,6 @@ export function restoreBrowserSession(): void { throw new SessionError('This task browser is suspended until the task is reopened.') } if (currentScope.restored) return - currentScope.activationOnly = false const scopeId = getBrowserScopeId() let snapshot: BrowserSessionSnapshot | null = null @@ -3008,19 +2984,14 @@ export function restoreBrowserSession(): void { }) } } + // Every chat is hydrated as soon as it is opened so its pages can be listed + // as tabs. Only a chat that actually had pages holds browser state of its + // own; one without stays replaceable by a pending chat adopting its id. + if (snapshot) currentScope.activationOnly = false const selectedIndexes = new Set() if (snapshot) { - for ( - let index = 0; - index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE; - index++ - ) { - if (snapshot.tabs[index]?.pinned) selectedIndexes.add(index) - } - if (selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE && snapshot.tabs[snapshot.activeIndex]) { - selectedIndexes.add(snapshot.activeIndex) - } + if (snapshot.tabs[snapshot.activeIndex]) selectedIndexes.add(snapshot.activeIndex) for ( let index = 0; index < snapshot.tabs.length && selectedIndexes.size < MAX_LIVE_TABS_PER_SCOPE; @@ -3062,7 +3033,7 @@ export function restoreBrowserSession(): void { snapshot.downloads.map((download) => ({ ...download })) ) for (const { entry } of selectedEntries) { - const tab = addTabInternal({ pinned: entry.pinned, activate: false, notify: false }) + const tab = addTabInternal({ activate: false, notify: false }) tab.pendingRestoreUrl = entry.url const restoredOrigin = mediaOrigin(entry.url) if (restoredOrigin) grantSiteOrigin(state, restoredOrigin) @@ -3122,23 +3093,13 @@ export function addTab(): AgentTab { } /** - * Opens a tab for agent work. - * - * `reveal` is for the agent deliberately opening a page to work in - * (`browser_open_tab`): the panel follows it, so the user watches the work - * instead of staring at a page where nothing is happening. It is NOT set when a - * page spawns a tab on its own (popups, `target="_blank"`) — that is the site - * grabbing the view, not the agent choosing a workspace. - * - * Even with `reveal`, a tab the user claimed themselves wins: pulling the view - * off the page they are reading is the same interruption as a window stealing - * focus mid-sentence. The work still starts, just in the background, and the - * tab strip shows it arriving. + * Opens a tab for agent work in the background. Which page is visible is the + * renderer's decision: every page is a resource tab there, and it shows the + * agent's tab or badges it depending on what the user is doing. */ -export function addAutomationTab({ reveal = false }: { reveal?: boolean } = {}): AgentTab { +export function addAutomationTab(): AgentTab { restoreBrowserSession() - const followTheWork = reveal && !currentScope.visibleTabUserSelected - const tab = addTabInternal({ activate: followTheWork, notify: false }) + const tab = addTabInternal({ activate: false, notify: false }) currentScope.automationTabId = tab.id applyActiveTabThrottling() persistBrowserSession() @@ -3192,29 +3153,11 @@ export function reopenClosedTab(): AgentTab | null { } /** - * Opens a copy of a tab at the same URL. A duplicate is a fresh load rather - * than a clone of the original's session history: the history belongs to the - * WebContents, and there is no way to fork it. + * Shows a tab. `claim` records the visible page as the user's own; a switch + * that only mirrors the renderer's strip selection passes false so the agent + * can still close or adopt the page as its own. */ -export function duplicateTab(tabId: string): AgentTab | null { - restoreBrowserSession() - const source = tabs.find((entry) => entry.id === tabId) - if (!source) return null - - const url = sanitizeRestorableUrl(source.view.webContents.getURL()) - currentScope.visibleTabUserSelected = true - const tab = addTabInternal() - if (url && url !== 'about:blank') { - // Sanitized to http(s) without embedded credentials above, and the - // partition's onBeforeRequest still runs the full SSRF check on the load — - // same reasoning as reopenClosedTab, and this is likewise a user action. - grantSiteOriginForUserNavigation(tab.view.webContents, url) - void tab.view.webContents.loadURL(url).catch(() => {}) - } - return tab -} - -export function switchTab(tabId: string): AgentTab { +export function switchTab(tabId: string, { claim = true }: { claim?: boolean } = {}): AgentTab { restoreBrowserSession() const tab = tabs.find((entry) => entry.id === tabId) if (!tab) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) @@ -3230,7 +3173,7 @@ export function switchTab(tabId: string): AgentTab { revokeTabMediaPermissions(previousActiveTab, false) } currentScope.activeTabId = tab.id - currentScope.visibleTabUserSelected = true + if (claim) currentScope.visibleTabUserSelected = true promotePendingTabRestore(tab) // Visible selection does not move the automation exemption; the user may // inspect another page while a tool continues in its background tab. @@ -3255,23 +3198,18 @@ export function switchAutomationTab(tabId: string): AgentTab { } /** - * Moves a tab to a final list index while preserving the pinned/regular - * boundary. Dragging across that boundary moves to its nearest valid edge. + * Moves a tab to a final list index. The renderer's resource strip owns tab + * order; this keeps the native list — what restore and `browser_list_tabs` + * report — in the same order. */ export function reorderTab(tabId: string, targetIndex: number): AgentTab { restoreBrowserSession() - if (!Number.isFinite(targetIndex)) { - throw new SessionError('Browser tab target index must be a finite number.') - } const currentIndex = tabs.findIndex((entry) => entry.id === tabId) if (currentIndex < 0) { throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) } const tab = tabs[currentIndex] - const pinnedCount = tabs.filter((entry) => entry.pinned).length - const minIndex = tab.pinned ? 0 : pinnedCount - const maxIndex = tab.pinned ? pinnedCount - 1 : tabs.length - 1 - const nextIndex = Math.max(minIndex, Math.min(maxIndex, Math.trunc(targetIndex))) + const nextIndex = Math.max(0, Math.min(tabs.length - 1, Math.trunc(targetIndex))) if (nextIndex === currentIndex) return tab tabs.splice(currentIndex, 1) @@ -3281,13 +3219,18 @@ export function reorderTab(tabId: string, targetIndex: number): AgentTab { return tab } -export function closeTab(tabId: string): void { +/** + * Closes a tab. When the agent closes its own working tab it moves on to the + * neighbour so its next page tool has a target; a close the user made leaves + * the agent cursor unset instead of announcing a page the agent never chose. + */ +export function closeTab( + tabId: string, + { adoptNeighborForAgent = false }: { adoptNeighborForAgent?: boolean } = {} +): void { restoreBrowserSession() const index = tabs.findIndex((entry) => entry.id === tabId) if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) - 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, stop page-owned UI. dismissFind(tabId) clearAutomationIndicatorsForTab(tabId) @@ -3313,16 +3256,11 @@ export function closeTab(tabId: string): void { } } if (currentScope.automationTabId === tab.id) { - currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null + currentScope.automationTabId = adoptNeighborForAgent + ? ((tabs[index] ?? tabs[index - 1])?.id ?? null) + : null applyActiveTabThrottling() } - // Closing the last tab must not leave a visible browser resource with an - // empty strip. Replace it with a fresh New tab, matching normal browser UI. - if (!hasSession() && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { - addTab() - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - return - } if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId persistBrowserSession() events?.onTabsChanged() @@ -3339,49 +3277,7 @@ export function closeAutomationTab(tabId: string): void { 'That tab is currently being used by the user. Switch to another agent tab instead of closing it.' ) } - closeTab(tabId) -} - -/** - * Pins or unpins a live tab. Pinned tabs form a stable group at the far left, - * and their latest URLs are persisted locally for the next browser opening. - */ -export function setTabPinned(tabId: string, pinned: boolean): AgentTab { - restoreBrowserSession() - const index = tabs.findIndex((entry) => entry.id === tabId) - if (index < 0) throw new SessionError(`No tab with id ${tabId} — call browser_list_tabs.`) - const tab = tabs[index] - if (tab.pinned === pinned) return tab - - tabs.splice(index, 1) - tab.pinned = pinned - insertPinnedAware(tab) - persistBrowserSession() - events?.onTabsChanged() - return tab -} - -/** Opens tab actions as a native menu so the embedded page never has to be hidden. */ -export function showTabContextMenu(tabId: string): void { - const scopeId = getBrowserScopeId() - const tab = tabs.find((entry) => entry.id === tabId) - if (!tab || tab.view.webContents.isDestroyed()) return - - const inOwningScope = (action: () => void) => () => withBrowserScope(scopeId, action) - - Menu.buildFromTemplate([ - { - label: tab.pinned ? 'Unpin Tab' : 'Pin Tab', - click: inOwningScope(() => setTabPinned(tabId, !tab.pinned)), - }, - { label: 'Duplicate Tab', click: inOwningScope(() => duplicateTab(tabId)) }, - { type: 'separator' }, - { - label: 'Close Tab', - enabled: !tab.pinned, - click: inOwningScope(() => closeTab(tabId)), - }, - ]).popup() + closeTab(tabId, { adoptNeighborForAgent: true }) } /** The live page whose browser surface owns a menu accelerator. */ @@ -3510,10 +3406,6 @@ function clearFocusedBrowserTab(tabId?: string): void { } function closeTabFromUser(tabId: string): void { - if (tabs.find((tab) => tab.id === tabId)?.pinned) { - shell.beep() - return - } const closingLastTab = listTabs().length === 1 closeTab(tabId) const active = activeTab() @@ -3566,7 +3458,7 @@ export function quiesceBrowserSessions(): void { } /** - * Ends the live session without touching the profile or the pinned-tab list on + * Ends the live session without touching the profile or the saved tab list on * disk, so the strip comes back intact next time. Turning the agent browser * off in settings runs this; a sign-out wipe runs {@link clearProfileStorage}. */ @@ -3587,10 +3479,10 @@ export function closeSession(): void { /** * Wipes the embedded browser's profile: open tabs, the in-memory list behind - * Reopen Closed Tab, the persisted pinned tabs, and all site data and cache in + * Reopen Closed Tab, the saved tab lists, and all site data and cache in * the agent partition. Sim sign-out runs this so the next account signing in * on this machine cannot inherit the previous user's authenticated sessions, - * pinned tabs, or browsing trail. + * saved tabs, or browsing trail. */ export async function clearProfileStorage(): Promise { // Cached DNS verdicts are part of the browsing trail: without this a wipe @@ -3642,7 +3534,7 @@ const SITE_DATA_STORAGES = [ /** * Erases selected kinds of browsing data without ending the session. * - * Unlike {@link clearProfileStorage} this leaves tabs open and the pinned strip + * Unlike {@link clearProfileStorage} this leaves tabs open and the saved strip * intact: the user asked to clear data, not to close their browser. Saved * passwords live in a separate vault and are never touched here. */ @@ -3674,7 +3566,6 @@ export function listTabs(): BrowserTabState[] { 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 } : {}), } }) diff --git a/apps/desktop/src/main/desktop-chat-session-store.test.ts b/apps/desktop/src/main/desktop-chat-session-store.test.ts index 094ab4484e9..e74d93e7390 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.test.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.test.ts @@ -35,10 +35,7 @@ function encryption(available = true): DesktopChatSessionEncryptionProvider { const ORIGIN = 'https://www.sim.ai' const BROWSER: BrowserSessionSnapshot = { v: 1, - tabs: [ - { url: 'https://example.com/inbox', pinned: true }, - { url: 'about:blank', pinned: false }, - ], + tabs: [{ url: 'https://example.com/inbox' }, { url: 'about:blank' }], activeIndex: 1, downloads: [ { @@ -224,7 +221,7 @@ describe('DesktopChatSessionStore', () => { const store = open() const existingBrowser: BrowserSessionSnapshot = { v: 1, - tabs: [{ url: 'https://existing.example/', pinned: true }], + tabs: [{ url: 'https://existing.example/' }], activeIndex: 0, downloads: [], } @@ -246,7 +243,7 @@ describe('DesktopChatSessionStore', () => { store.setBrowser(ORIGIN, 'chat-a', BROWSER) store.setBrowser('https://self-hosted.example/path', 'chat-a', { v: 1, - tabs: [{ url: 'https://other.example/', pinned: false }], + tabs: [{ url: 'https://other.example/' }], activeIndex: 0, downloads: [], }) @@ -264,7 +261,6 @@ describe('DesktopChatSessionStore', () => { v: 1, tabs: Array.from({ length: 12 }, (_, index) => ({ url: `https://tab-${index}.example/`, - pinned: index < 2, })), activeIndex: 99, downloads: [], @@ -285,11 +281,10 @@ describe('DesktopChatSessionStore', () => { expect(terminal?.activeIndex).toBe(11) }) - it('bounds persisted browser tabs while retaining pinned and active entries', () => { + it('bounds persisted browser tabs while retaining the active entry', () => { const store = open() const tabs = Array.from({ length: 40 }, (_, index) => ({ url: `https://tab-${index}.example/`, - pinned: index < 4, })) expect( @@ -303,7 +298,6 @@ describe('DesktopChatSessionStore', () => { const snapshot = store.getBrowser(ORIGIN, 'chat-bounded') expect(snapshot?.tabs).toHaveLength(32) - expect(snapshot?.tabs.filter((tab) => tab.pinned)).toHaveLength(4) expect(snapshot?.tabs[snapshot.activeIndex]?.url).toBe('https://tab-39.example/') }) @@ -319,12 +313,12 @@ describe('DesktopChatSessionStore', () => { browser: { v: 1, tabs: [ - { url: 'https://user:password@example.com/private', pinned: false }, - { url: 'file:///Users/ada/.ssh/id_ed25519', pinned: false }, - { url: 'javascript:alert(1)', pinned: true }, - { url: 'about:blank', pinned: false }, - { url: 'http://localhost:3000/path', pinned: true }, - { url: `https://example.com/${'x'.repeat(8_200)}`, pinned: false }, + { url: 'https://user:password@example.com/private' }, + { url: 'file:///Users/ada/.ssh/id_ed25519' }, + { url: 'javascript:alert(1)' }, + { url: 'about:blank' }, + { url: 'http://localhost:3000/path' }, + { url: `https://example.com/${'x'.repeat(8_200)}` }, ], activeIndex: 20, downloads: [], @@ -360,10 +354,7 @@ describe('DesktopChatSessionStore', () => { expect(store.initialize()).toBe(true) expect(store.getBrowser(ORIGIN, 'chat-valid')).toEqual({ v: 1, - tabs: [ - { url: 'about:blank', pinned: false }, - { url: 'http://localhost:3000/path', pinned: true }, - ], + tabs: [{ url: 'about:blank' }, { url: 'http://localhost:3000/path' }], activeIndex: 1, downloads: [], }) diff --git a/apps/desktop/src/main/desktop-chat-session-store.ts b/apps/desktop/src/main/desktop-chat-session-store.ts index 71b3b663a25..418ff73493f 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.ts @@ -23,7 +23,6 @@ export interface BrowserSessionSnapshot { v: typeof SNAPSHOT_VERSION tabs: Array<{ url: string - pinned: boolean }> activeIndex: number downloads: Array<{ @@ -155,31 +154,18 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null tab: BrowserSessionSnapshot['tabs'][number] sourceIndex: number }> = [] - const replaceableTabIndex = (): number => { - for (let index = selectedTabs.length - 1; index >= 0; index--) { - const entry = selectedTabs[index] - if (entry.sourceIndex !== activeSourceIndex && !entry.tab.pinned) return index - } - return -1 - } + // The cap keeps the first tabs, always making room for the active one. A + // `pinned` flag from older snapshots is ignored. for (let sourceIndex = 0; sourceIndex < value.tabs.length; sourceIndex++) { const candidate = value.tabs[sourceIndex] - if (!isRecordLike(candidate) || typeof candidate.pinned !== 'boolean') continue + if (!isRecordLike(candidate)) continue const url = normalizeBrowserUrl(candidate.url) if (url === null) continue - const next = { tab: { url, pinned: candidate.pinned }, sourceIndex } + const next = { tab: { url }, sourceIndex } if (selectedTabs.length < MAX_BROWSER_TABS) { selectedTabs.push(next) - continue - } - if (sourceIndex === activeSourceIndex) { - const replacementIndex = replaceableTabIndex() - selectedTabs[replacementIndex >= 0 ? replacementIndex : selectedTabs.length - 1] = next - continue - } - if (candidate.pinned) { - const replacementIndex = replaceableTabIndex() - if (replacementIndex >= 0) selectedTabs[replacementIndex] = next + } else if (sourceIndex === activeSourceIndex) { + selectedTabs[selectedTabs.length - 1] = next } } selectedTabs.sort((left, right) => left.sourceIndex - right.sourceIndex) diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 519c9a9cf46..959fb436fcf 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -1146,25 +1146,6 @@ describe('registerIpcHandlers', () => { expect(() => handler?.(appEvent, { action: 'reload' })).not.toThrow() }) - it('restricts browser-tab pinning to typed app-origin messages', () => { - const { on } = collectHandlers() - const handler = on.get('browser-agent:set-tab-pinned') - - expect(() => handler?.(evilEvent, '1', true)).not.toThrow() - expect(() => handler?.(appEvent, 1, true)).not.toThrow() - expect(() => handler?.(appEvent, '1', 'yes')).not.toThrow() - expect(() => handler?.(appEvent, '1', true)).not.toThrow() - }) - - it('restricts browser-tab context menus to typed app-origin messages', () => { - const { on } = collectHandlers() - const handler = on.get('browser-agent:show-tab-context-menu') - - expect(() => handler?.(evilEvent, '1')).not.toThrow() - expect(() => handler?.(appEvent, 1)).not.toThrow() - expect(() => handler?.(appEvent, '1')).not.toThrow() - }) - it('restricts browser-tab reordering to typed app-origin messages', () => { const { on } = collectHandlers() const handler = on.get('browser-agent:reorder-tab') @@ -1337,7 +1318,6 @@ describe('registerIpcHandlers', () => { title: 'Restored', loading: false, active: true, - pinned: false, }, ], activeTabId: '1', @@ -1369,7 +1349,6 @@ describe('registerIpcHandlers', () => { title: '', loading: false, active: true, - pinned: false, }, ], activeTabId: '2', diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index c4f9fd51662..049527f5d1a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -56,10 +56,8 @@ import { peekTabsState, reorderTab, setBrowserAppTheme, - setTabPinned, showBrowserDownloadInFolder, showBrowserDownloadsMenu, - showTabContextMenu, stopFindInActiveTab, withBrowserScope, } from '@/main/browser-agent/session' @@ -1174,30 +1172,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { void handlePanelAction(scope, panelAction).catch(() => {}) }, }, - 'browser-agent:set-tab-pinned': { - kind: 'send', - gate: 'app-origin', - requires: 'browser', - passSender: true, - handler: (sender, tabId, pinned, rawScope) => { - const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) - if (!scope || typeof tabId !== 'string' || typeof pinned !== 'boolean') return - try { - withBrowserScope(scope, () => setTabPinned(tabId, pinned)) - } catch {} - }, - }, - 'browser-agent:show-tab-context-menu': { - kind: 'send', - gate: 'app-origin', - requires: 'browser', - passSender: true, - handler: (sender, tabId, rawScope) => { - const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) - if (!scope || typeof tabId !== 'string') return - withBrowserScope(scope, () => showTabContextMenu(tabId)) - }, - }, 'browser-agent:reorder-tab': { kind: 'send', gate: 'app-origin', diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 484bc2d26bd..c3714f0f3f4 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -228,12 +228,6 @@ const api: SimDesktopApi = { ipcRenderer.invoke('browser-agent:dispose-scope', scopeId), suspendScope: (scopeId: string): Promise => ipcRenderer.invoke('browser-agent:suspend-scope', scopeId), - setTabPinned: (tabId: string, pinned: boolean, scopeId: string): void => { - ipcRenderer.send('browser-agent:set-tab-pinned', tabId, pinned, scopeId) - }, - showTabContextMenu: (tabId: string, scopeId: string): void => { - ipcRenderer.send('browser-agent:show-tab-context-menu', tabId, scopeId) - }, reorderTab: (tabId: string, targetIndex: number, scopeId: string): void => { ipcRenderer.send('browser-agent:reorder-tab', tabId, targetIndex, scopeId) }, diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx index d6bdb2a11a7..bb8d64f5d8f 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx @@ -27,6 +27,7 @@ import { RESOURCE_HEADER_CLASSES, RESOURCE_TAB_ICON_BUTTON_CLASS, RESOURCE_TAB_ICON_CLASS, + resourceTabWidthClass, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' export type HeroResourceId = 'workflow' | 'table' | 'brief' @@ -155,7 +156,7 @@ export function HeroResourcePanel({ onSelect={(id) => onActiveChange(id as HeroResourceId)} onClose={(id) => onCloseResource(id as HeroResourceId)} variant='floating' - className={RESOURCE_HEADER_CLASSES.stripGeometry} + className={cn(RESOURCE_HEADER_CLASSES.stripGeometry, resourceTabWidthClass(tabs.length))} newTabControl={ diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index db78d250fec..a5eaa19c66e 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -307,7 +307,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { expect(dbChainMockFns.values.mock.calls[0][0].title).toBe('Fork | Generate Logs') }) - it('repairs legacy page-level browser resources while forking', async () => { + it('drops legacy browser rows while forking, since the desktop app owns those pages', async () => { dbChainMockFns.limit.mockResolvedValue([ { ...parentRow, @@ -318,6 +318,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { title: 'mship-todo (Channel) - sim - Slack', }, { type: 'browser', id: 'browser-session', title: 'Browser' }, + { type: 'file', id: 'file-1', title: 'report.csv' }, ], }, ]) @@ -326,7 +327,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { expect(res.status).toBe(200) expect(dbChainMockFns.values.mock.calls[0][0].resources).toEqual([ - { type: 'browser', id: 'browser-session', title: 'Browser' }, + { type: 'file', id: 'file-1', title: 'report.csv' }, ]) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index fc5a1efb684..f90203afeac 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -18,10 +18,7 @@ import { } from '@sim/emcn' import { Folder, Plus } from '@sim/emcn/icons' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' -import { - BROWSER_SESSION_RESOURCE_ID, - TERMINAL_SESSION_RESOURCE_ID, -} from '@/lib/copilot/resources/types' +import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types' import { subscribeDesktopPreferences } from '@/lib/desktop' import { isTerminalAvailable } from '@/lib/terminal/transport' import { @@ -53,6 +50,12 @@ import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +/** + * Placeholder id for the Browser launcher row. It never names a resource: the + * page the desktop app creates becomes the browser tab, keyed by its own id. + */ +export const BROWSER_LAUNCHER_ID = 'browser' + export interface AddResourceDropdownProps { workspaceId: string existingKeys: Set @@ -303,14 +306,14 @@ export function useAvailableResources( }), }, ] - // The live browser panel — desktop app only (needs the agent-browser - // bridge). There is one top-level panel; repeated launches open inner tabs. + // A new browser tab — desktop app only (needs the agent-browser bridge). + // Every launch opens another page; the strip lists each as its own tab. if (browserAvailable) { groups.push({ type: 'browser' as const, items: [ { - id: BROWSER_SESSION_RESOURCE_ID, + id: BROWSER_LAUNCHER_ID, name: 'Browser', }, ], @@ -541,9 +544,10 @@ export function ResourceMenuSections({ const Icon = config.icon const section = sectionByType.get(type) - // Browser and terminal each have one top-level panel — a flat launcher - // here creates inner tabs when that panel already exists. - if (!section && (type === 'browser' || type === 'terminal')) { + // The Browser launcher and the Terminal panel are flat rows: one opens + // a new page, the other the terminal and its inner shells. Live browser + // pages offered as context are an ordinary picker submenu. + if (!section && (type === 'terminal' || items[0]?.id === BROWSER_LAUNCHER_ID)) { const item = items[0] return ( onSelect(resourceFromItem(type, item))}> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts index 0b593280917..8d386e2156b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts @@ -32,7 +32,6 @@ export type BrowserPanelOverlay = | 'downloads' | 'resources' | 'suggestions' - | 'tab' | 'toolbar' export interface BrowserPanelOverlayController { 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 e9fa882f8d7..3275c93b6ba 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 @@ -13,12 +13,10 @@ import { claimPermissionResponse, clearOmniboxSelection, exceededOmniboxDragThreshold, - hasConfirmedBrowserTabCreation, initialUrlSuggestionIndex, resolveUrlBarInput, selectFocusedOmniboxOnNextFrame, shouldOpenUrlSuggestions, - shouldRemoveBrowserResource, shouldReportBrowserBounds, shouldShowBrowserPermissionRequest, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' @@ -424,21 +422,7 @@ describe('initialUrlSuggestionIndex', () => { }) }) -describe('hasConfirmedBrowserTabCreation', () => { - it('requires both a larger strip and a distinct active tab', () => { - expect(hasConfirmedBrowserTabCreation('tab-1', 1, 'tab-2', 2)).toBe(true) - expect(hasConfirmedBrowserTabCreation('tab-1', 1, 'tab-1', 2)).toBe(false) - expect(hasConfirmedBrowserTabCreation('tab-1', 1, 'tab-2', 1)).toBe(false) - expect(hasConfirmedBrowserTabCreation('tab-1', 1, null, 2)).toBe(false) - }) -}) - describe('suspended browser resource lifecycle', () => { - it('does not remove a resource when administrative suspension clears its tabs', () => { - expect(shouldRemoveBrowserResource(false, true, true)).toBe(false) - expect(shouldRemoveBrowserResource(false, true, false)).toBe(true) - }) - it('reports native bounds only while visible and unsuspended', () => { expect(shouldReportBrowserBounds(true, false)).toBe(true) expect(shouldReportBrowserBounds(true, true)).toBe(false) 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 d6a9aa5af31..f3aa734a126 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 @@ -8,7 +8,6 @@ import type { BrowserPanelBounds, BrowserPanelSnapshot, BrowserSitePermissionRequest, - BrowserTabState, } from '@sim/browser-protocol' import { isBrowserTheme } from '@sim/browser-protocol' import type { @@ -33,7 +32,6 @@ import { PopoverAnchor, PopoverContent, PopoverItem, - toast, } from '@sim/emcn' import { ArrowLeft, ArrowRight, Globe, Key, Link, RefreshCw, Search } from '@sim/emcn/icons' import { useTheme } from 'next-themes' @@ -53,20 +51,15 @@ import { onBrowserFindOpen, onBrowserOmniboxFocus, onBrowserToolbarCommand, - openBrowserTab, - reorderBrowserTab, reportBrowserPanelBounds, reportBrowserPanelFocused, reportBrowserTheme, sendBrowserPanelAction, setBrowserPanelOccluded, - setBrowserTabPinned, showBrowserCredentialChooser, - showBrowserTabContextMenu, showBrowserToolbarMenu, supportsAtomicBrowserPanelOcclusion, } from '@/lib/browser-agent/transport' -import { BROWSER_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types' import { faviconUrl } from '@/lib/core/utils/favicon' import { getDesktopBridge } from '@/lib/desktop' import { @@ -75,7 +68,6 @@ import { } from '@/lib/desktop/appearance' import { trackPanelFocus } from '@/lib/desktop/panel-focus' import { addMothershipContext } from '@/lib/mothership/events' -import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context' 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' @@ -89,7 +81,6 @@ import { mutationsTouchNativeSurfaceOcclusion, useBrowserPanelOcclusion, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' -import { BrowserTabStrip } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip' import { BrowserThemeNotice } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-theme-notice' import { buildOmniboxSuggestions, @@ -110,9 +101,7 @@ import type { ChatContext } from '@/stores/panel' /** Ties the omnibox to its listbox for assistive tech. */ 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}` @@ -368,15 +357,6 @@ interface BrowserSessionProps { onOverlayControllerChange?: (controller: BrowserPanelOverlayController | null) => void } -/** Administrative suspension retains the resource even though live tabs are gone. */ -export function shouldRemoveBrowserResource( - sessionAlive: boolean, - observedLiveSession: boolean, - suspended: boolean -): boolean { - return !suspended && !sessionAlive && observedLiveSession -} - /** A suspended scope never leases native compositor bounds. */ export function shouldReportBrowserBounds(visible: boolean, suspended: boolean): boolean { return visible && !suspended @@ -421,23 +401,6 @@ export function initialUrlSuggestionIndex( return query.trim() || !pageUrl || pageUrl === 'about:blank' ? 0 : null } -/** A new-tab request is complete only after the authoritative strip grows and activates a new id. */ -export function hasConfirmedBrowserTabCreation( - previousActiveTabId: string | null, - previousTabCount: number, - activeTabId: string | null, - tabCount: number -): boolean { - return tabCount > previousTabCount && activeTabId !== null && activeTabId !== previousActiveTabId -} - -interface PendingNewTabFocus { - scopeId: string - previousActiveTabId: string | null - previousTabCount: number - timeoutId: number -} - interface OmniboxPointerSelection { pointerId: number originX: number @@ -461,27 +424,9 @@ export function BrowserSession({ // panel. Direct selection prevents that one render from showing or acting on // another chat's tabs. const pageState = useBrowserSessionStore((state) => state.sessions[scopeId]?.pageState ?? null) - const tabs = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.tabs ?? EMPTY_BROWSER_TABS - ) const activeTabId = useBrowserSessionStore( (state) => state.sessions[scopeId]?.activeTabId ?? null ) - const automationTabId = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.automationTabId ?? null - ) - const automationActive = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.automationActive ?? false - ) - const automationNeedsAttention = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.automationNeedsAttention ?? false - ) - const browserAgentActive = useBrowserSessionStore( - (state) => (state.sessions[scopeId]?.agentRunIds.length ?? 0) > 0 - ) - const sessionAlive = useBrowserSessionStore( - (state) => state.sessions[scopeId]?.sessionAlive ?? true - ) const suspended = useBrowserSessionStore((state) => state.sessions[scopeId]?.suspended ?? false) const showEmptyState = Boolean( pageState && @@ -507,42 +452,14 @@ export function BrowserSession({ const toolbarMenuButtonRef = useRef(null) const omniboxFocusRafRef = useRef(null) const omniboxPointerSelectionRef = useRef(null) - const pendingNewTabFocusRef = useRef(null) const handledPermissionRequestIdsRef = useRef>(new Set()) const [answeredPermissionRequestId, setAnsweredPermissionRequestId] = useState( null ) const visibleRef = useRef(visible) visibleRef.current = visible - const { removeResource } = useMothershipResources() const { navigateToSettings } = useSettingsNavigation() - // The browser session ending closes the panel, the way the terminal panel - // goes when its last shell does. What it leaves otherwise is a tab whose - // only content explains that there is nothing to show and that starting - // again has to happen from somewhere else — the agent reopens the panel on - // its next browser action anyway. Guarded on having seen a live session so - // that opening the panel while the store still remembers a closed one does - // not immediately close it again. - const observedLiveSession = useRef(false) - useEffect(() => { - if (suspended) { - observedLiveSession.current = false - return - } - if (sessionAlive) { - // A new scope starts optimistically alive until the desktop answers. - // Only a real tab proves that this panel observed a live session; - // otherwise the expected empty response from lazy activation would look - // like a user closing the last tab and delete the persisted resource - // before its encrypted descriptor gets a chance to hydrate. - if (tabs.length > 0) observedLiveSession.current = true - return - } - if (!shouldRemoveBrowserResource(sessionAlive, observedLiveSession.current, suspended)) return - observedLiveSession.current = false - removeResource('browser', BROWSER_SESSION_RESOURCE_ID) - }, [sessionAlive, suspended, tabs.length, removeResource]) const pageUrlRef = useRef(pageState?.url ?? '') pageUrlRef.current = pageState?.url ?? '' /** Non-null while the user is editing the URL bar; otherwise it mirrors the page. */ @@ -734,61 +651,18 @@ export function BrowserSession({ }) }, []) - const clearPendingNewTabFocus = useCallback((pending?: PendingNewTabFocus): boolean => { - const current = pendingNewTabFocusRef.current - if (!current || (pending && current !== pending)) return false - window.clearTimeout(current.timeoutId) - pendingNewTabFocusRef.current = null - return true - }, []) - - // New shells acknowledge tab creation directly; older installed shells only - // publish the resulting strip. Both paths land here so neither clears the - // current page's omnibox before a distinct tab actually exists. - useEffect(() => { - const pending = pendingNewTabFocusRef.current - if ( - !pending || - pending.scopeId !== scopeId || - !hasConfirmedBrowserTabCreation( - pending.previousActiveTabId, - pending.previousTabCount, - activeTabId, - tabs.length - ) - ) { - return - } - if (!clearPendingNewTabFocus(pending)) return - if (visible) focusOmnibox('clear') - }, [activeTabId, clearPendingNewTabFocus, focusOmnibox, scopeId, tabs.length, visible]) - - useEffect(() => { - return () => { - const pending = pendingNewTabFocusRef.current - if (pending?.scopeId === scopeId) clearPendingNewTabFocus(pending) - } - }, [clearPendingNewTabFocus, scopeId]) - useEffect(() => onBrowserOmniboxFocus(focusOmnibox, scopeId), [focusOmnibox, scopeId]) - // Follow the agent's tab. The panel already marks the automated tab in the - // strip; this makes it the VISIBLE one, so watching the agent never means - // hunting for which tab it moved to. Keyed on the automation target - // CHANGING, not on it merely being set — the user can still browse a - // different tab mid-run and is only pulled along when the agent itself - // moves to another tab. - const followedAutomationTabRef = useRef(null) + // A fresh blank tab coming on screen — opened from the resource strip or by + // Cmd+T — gets the omnibox, the way Chrome's new-tab page does. A tab with a + // page keeps its content. + const focusedBlankTabIdRef = useRef(null) useEffect(() => { - if (!automationActive || !automationTabId) { - if (!automationActive) followedAutomationTabRef.current = null - return - } - if (followedAutomationTabRef.current === automationTabId) return - followedAutomationTabRef.current = automationTabId - if (automationTabId === activeTabId) return - sendBrowserPanelAction('switch-tab', { tabId: automationTabId }, scopeId) - }, [activeTabId, automationActive, automationTabId, scopeId]) + if (!visible || !activeTabId || !showEmptyState) return + if (focusedBlankTabIdRef.current === activeTabId) return + focusedBlankTabIdRef.current = activeTabId + focusOmnibox('clear') + }, [activeTabId, focusOmnibox, showEmptyState, visible]) // Sim owns keyboard events while its renderer has focus. Claim Cmd+L here // before the workspace's global "Go to Logs" command can navigate away. @@ -1128,46 +1002,6 @@ export function BrowserSession({ urlInputRef.current?.blur() } - const handleNewTab = useCallback(() => { - setSuggestionsVisible(false) - setSuggestionQuery(null) - setActiveSuggestion(null) - setSuggestionOriginUrl('') - clearPendingNewTabFocus() - const pending: PendingNewTabFocus = { - scopeId, - previousActiveTabId: activeTabId, - previousTabCount: tabs.length, - timeoutId: 0, - } - pending.timeoutId = window.setTimeout(() => { - if (clearPendingNewTabFocus(pending)) { - toast.error('Could not open a new browser tab. Please try again.') - } - }, NEW_TAB_CONFIRM_TIMEOUT_MS) - pendingNewTabFocusRef.current = pending - void openBrowserTab(scopeId) - .then((state) => { - // Older shells resolve null and confirm through the tab-state effect. - if (!state) return - if ( - !hasConfirmedBrowserTabCreation( - pending.previousActiveTabId, - pending.previousTabCount, - state.activeTabId, - state.tabs.length - ) - ) { - throw new Error('The desktop browser did not create a distinct tab.') - } - }) - .catch(() => { - if (clearPendingNewTabFocus(pending)) { - toast.error('Could not open a new browser tab. Please try again.') - } - }) - }, [activeTabId, clearPendingNewTabFocus, scopeId, tabs.length]) - /** * Opens the shell's native account chooser under the key icon. Called * directly from the click so the page still has an active user gesture, @@ -1196,70 +1030,9 @@ export function BrowserSession({ showBrowserToolbarMenu({ x: rect.left, y: rect.bottom }, scopeId) }, [scopeId]) - const handleSwitchTab = useCallback( - (tabId: string) => { - setSuggestionsVisible(false) - setSuggestionQuery(null) - setUrlDraft(null) - urlInputRef.current?.blur() - sendBrowserPanelAction('switch-tab', { tabId }, scopeId) - }, - [scopeId] - ) - - const handleCloseTab = useCallback( - (tabId: string) => { - setSuggestionsVisible(false) - setSuggestionQuery(null) - setUrlDraft(null) - urlInputRef.current?.blur() - sendBrowserPanelAction('close-tab', { tabId }, scopeId) - }, - [scopeId] - ) - - const handleDuplicateTab = useCallback( - (tabId: string) => { - sendBrowserPanelAction('duplicate-tab', { tabId }, scopeId) - }, - [scopeId] - ) - - const handleSetTabPinned = useCallback( - (tabId: string, pinned: boolean) => { - setBrowserTabPinned(tabId, pinned, scopeId) - }, - [scopeId] - ) - - const handleReorderTab = useCallback( - (tabId: string, targetIndex: number) => { - reorderBrowserTab(tabId, targetIndex, scopeId) - }, - [scopeId] - ) - return (
- - requestOverlay('tab', () => showBrowserTabContextMenu(tabId, scopeId)) - } - onCloseTabMenu={() => void closeOverlay('tab')} - onReorderTab={handleReorderTab} - contextMenuOpen={activeOverlay === 'tab'} - />