From e9441eda05dc24a7c4d2e85d499fdf932e65ca54 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 10 Sep 2026 15:11:55 -0700 Subject: [PATCH 1/4] feat(desktop): browser pages as resource tabs, agent works in the background Each live desktop browser page is now its own resource tab in the Chat panel, derived from the desktop app's tab list instead of a persisted singleton "Browser" resource with a second tab strip inside it. The inner browser tab strip, tab pinning, duplicate, reorder, the native tab context menu, and the auto-replacement of a closed last tab are removed. The agent drives its own tab (the desktop's automation cursor) without moving the user's visible page: the renderer no longer force-activates the browser on every tool call nor auto-switches the panel to the agent's tab. The agent's tab is announced as resource activity, so the existing view policy shows it or badges it depending on whether the user has taken over selection. Mentions and attachments point at exact tabs; the chat row never stores browser resources, and legacy rows are dropped on read. --- .../src/main/browser-agent/driver.test.ts | 2 +- apps/desktop/src/main/browser-agent/driver.ts | 15 +- .../src/main/browser-agent/panel.test.ts | 14 +- apps/desktop/src/main/browser-agent/panel.ts | 12 +- .../src/main/browser-agent/session.test.ts | 296 +++++------------- .../desktop/src/main/browser-agent/session.ts | 192 ++---------- .../main/desktop-chat-session-store.test.ts | 31 +- .../src/main/desktop-chat-session-store.ts | 26 +- apps/desktop/src/main/ipc.test.ts | 32 -- apps/desktop/src/main/ipc.ts | 47 --- apps/desktop/src/preload/index.ts | 9 - .../add-resource-dropdown.tsx | 24 +- .../browser-panel-occlusion.ts | 1 - .../browser-session/browser-session.test.ts | 16 - .../browser-session/browser-session.tsx | 245 +-------------- .../browser-session/browser-tab-strip.tsx | 246 --------------- .../resource-content/resource-content.tsx | 5 +- .../resource-registry/browser-tab-icon.tsx | 83 +++++ .../resource-registry/resource-registry.tsx | 5 +- .../resource-tabs/resource-tabs.test.ts | 15 +- .../resource-tabs/resource-tabs.tsx | 45 ++- .../mothership-view/mothership-view.tsx | 67 ++-- .../user-input/components/constants.ts | 6 +- .../plus-menu-dropdown.test.tsx | 88 ++++-- .../plus-menu-dropdown/plus-menu-dropdown.tsx | 10 +- .../resource-mention-items.test.ts | 145 ++++----- .../resource-mention-items.ts | 68 ++-- .../app/workspace/[workspaceId]/home/home.tsx | 11 + .../stream/handle-resource-event.test.ts | 10 +- .../hooks/stream/handle-resource-event.ts | 3 + .../home/hooks/use-browser-tab-resources.ts | 129 ++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 64 ++-- .../sim/lib/browser-agent/attachments.test.ts | 43 ++- apps/sim/lib/browser-agent/attachments.ts | 26 +- .../browser-agent/tab-label.test.ts} | 5 +- .../browser-agent/tab-label.ts} | 0 apps/sim/lib/browser-agent/transport.test.ts | 49 +-- apps/sim/lib/browser-agent/transport.ts | 51 ++- apps/sim/lib/copilot/chat/post.test.ts | 10 +- apps/sim/lib/copilot/chat/post.ts | 9 +- .../lib/copilot/chat/process-contents.test.ts | 10 +- apps/sim/lib/copilot/chat/process-contents.ts | 13 +- apps/sim/lib/copilot/resources/types.test.ts | 27 +- apps/sim/lib/copilot/resources/types.ts | 53 ++-- apps/sim/stores/browser-session/store.test.ts | 44 --- apps/sim/stores/browser-session/store.ts | 18 -- packages/browser-protocol/src/index.ts | 15 +- packages/desktop-bridge/contract-snapshot.ts | 52 ++- packages/desktop-bridge/src/index.ts | 6 - 49 files changed, 833 insertions(+), 1560 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/browser-tab-icon.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-browser-tab-resources.ts rename apps/sim/{app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.test.ts => lib/browser-agent/tab-label.test.ts} (85%) rename apps/sim/{app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-label.ts => lib/browser-agent/tab-label.ts} (100%) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 5c268072838..170d02a4c80 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -1395,7 +1395,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..cea058ca05f 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -905,7 +905,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 +2368,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,16 +4781,6 @@ 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) 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..84564d409e6 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,28 @@ 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', () => { - const first = session.ensureTab() + 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 +2607,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 +2719,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 +3290,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 +3421,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 +3441,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 +4524,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..923089d3a46 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() @@ -3191,29 +3152,6 @@ export function reopenClosedTab(): AgentTab | null { return tab } -/** - * 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. - */ -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 { restoreBrowserSession() const tab = tabs.find((entry) => entry.id === tabId) @@ -3254,40 +3192,10 @@ export function switchAutomationTab(tabId: string): AgentTab { return tab } -/** - * Moves a tab to a final list index while preserving the pinned/regular - * boundary. Dragging across that boundary moves to its nearest valid edge. - */ -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))) - if (nextIndex === currentIndex) return tab - - tabs.splice(currentIndex, 1) - tabs.splice(nextIndex, 0, tab) - persistBrowserSession() - events?.onTabsChanged() - return tab -} - export function closeTab(tabId: string): 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) @@ -3316,13 +3224,6 @@ export function closeTab(tabId: string): void { currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? 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() @@ -3342,48 +3243,6 @@ export function closeAutomationTab(tabId: string): void { 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() -} - /** The live page whose browser surface owns a menu accelerator. */ function focusedTabForShortcut(ownerWindow?: BrowserWindow | null): AgentTab | null { if (!isPanelVisible() || !panelUpdateAllowed(ownerWindow ?? undefined, getBrowserScopeId())) { @@ -3510,10 +3369,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 +3421,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 +3442,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 +3497,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 +3529,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..5b912715e61 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -1146,36 +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') - - expect(() => handler?.(evilEvent, '1', 0)).not.toThrow() - expect(() => handler?.(appEvent, 1, 0)).not.toThrow() - expect(() => handler?.(appEvent, '1', '0')).not.toThrow() - expect(() => handler?.(appEvent, '1', Number.NaN)).not.toThrow() - expect(() => handler?.(appEvent, '1', 0)).not.toThrow() - }) - it('restricts browser-panel focus updates to boolean app-origin messages', () => { const { on } = collectHandlers() const handler = on.get('browser-agent:set-panel-focused') @@ -1337,7 +1307,6 @@ describe('registerIpcHandlers', () => { title: 'Restored', loading: false, active: true, - pinned: false, }, ], activeTabId: '1', @@ -1369,7 +1338,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..2cf75d7626c 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -54,12 +54,9 @@ import { getBrowserDownloadsState, grantSiteOriginForUserNavigation, peekTabsState, - reorderTab, setBrowserAppTheme, - setTabPinned, showBrowserDownloadInFolder, showBrowserDownloadsMenu, - showTabContextMenu, stopFindInActiveTab, withBrowserScope, } from '@/main/browser-agent/session' @@ -1174,50 +1171,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', - requires: 'browser', - passSender: true, - handler: (sender, tabId, targetIndex, rawScope) => { - const scope = activeRendererScope(browserScopeBySender, sender as WebContents, rawScope) - if ( - !scope || - typeof tabId !== 'string' || - typeof targetIndex !== 'number' || - !Number.isFinite(targetIndex) - ) { - return - } - try { - withBrowserScope(scope, () => reorderTab(tabId, targetIndex)) - } catch {} - }, - }, 'browser-agent:set-panel-bounds': { kind: 'send', gate: 'app-origin', diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 484bc2d26bd..05bdc84f441 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -228,15 +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) - }, setPanelBounds: ( bounds: BrowserPanelBounds | null, anchor: BrowserPanelAnchor | null, 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..d9e00fc2652 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, by + // Cmd+T, or by the shell replacing a closed last tab — 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'} - />