Skip to content

Commit 7d8c4b3

Browse files
committed
feat(desktop): uncap browser and terminal tabs
1 parent 520961f commit 7d8c4b3

14 files changed

Lines changed: 216 additions & 149 deletions

File tree

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
vi.mock('electron', () => import('@/test/electron-mock'))
44

5-
import { MAX_BROWSER_TABS } from '@sim/browser-protocol'
65
import { sleep } from '@sim/utils/helpers'
76
import { BrowserWindow, session as electronSession } from 'electron'
87
import * as panel from '@/main/browser-agent/panel'
@@ -306,6 +305,33 @@ describe('browser-agent session', () => {
306305
)
307306
})
308307

308+
it('restores more than eight persisted tabs', () => {
309+
const tabs = Array.from({ length: 12 }, (_, index) => ({
310+
url: `https://tab-${index}.example/`,
311+
pinned: index < 2,
312+
}))
313+
const { persistence } = memoryBrowserPersistence({
314+
'chat-many-tabs': {
315+
v: 1,
316+
tabs,
317+
activeIndex: tabs.length - 1,
318+
},
319+
})
320+
session = freshSession(win, {}, undefined, persistence)
321+
322+
const restored = session.withBrowserScope('chat-many-tabs', () => {
323+
session.restoreBrowserSession()
324+
return session.getTabsState()
325+
})
326+
327+
expect(restored.tabs).toHaveLength(12)
328+
expect(restored.activeTabId).toBe('12')
329+
expect(restored.tabs.at(-1)).toMatchObject({
330+
url: 'https://tab-11.example/',
331+
active: true,
332+
})
333+
})
334+
309335
it('quiesces live scopes without publishing session closure', () => {
310336
const onTabsChanged = vi.fn()
311337
const onSessionClosed = vi.fn()
@@ -1034,16 +1060,22 @@ describe('browser-agent session', () => {
10341060
])
10351061
})
10361062

1037-
it('limits the browser session to the shared tab cap', () => {
1038-
session.ensureTab()
1039-
for (let index = 1; index < MAX_BROWSER_TABS; index++) {
1063+
it('allows creation, duplication, and reopening beyond eight browser tabs', () => {
1064+
const first = session.ensureTab()
1065+
for (let index = 1; index < 12; index++) {
10401066
session.addTab()
10411067
}
10421068

1043-
expect(session.listTabs()).toHaveLength(MAX_BROWSER_TABS)
1044-
expect(() => session.addTab()).toThrow(
1045-
`The browser supports up to ${MAX_BROWSER_TABS} open tabs.`
1046-
)
1069+
expect(session.listTabs()).toHaveLength(12)
1070+
const duplicate = session.duplicateTab(first.id)
1071+
expect(duplicate).not.toBeNull()
1072+
expect(session.listTabs()).toHaveLength(13)
1073+
if (!duplicate) throw new Error('expected the tab to be duplicated')
1074+
1075+
session.closeTab(duplicate.id)
1076+
expect(session.listTabs()).toHaveLength(12)
1077+
expect(session.reopenClosedTab()).not.toBeNull()
1078+
expect(session.listTabs()).toHaveLength(13)
10471079
})
10481080

10491081
it('embeds the active view in the MAIN window only while panel bounds are reported', () => {

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { AsyncLocalStorage } from 'node:async_hooks'
22
import { join } from 'node:path'
3-
import {
4-
type BrowserDataKind,
5-
type BrowserFindRequest,
6-
type BrowserFindResult,
7-
type BrowserOmniboxFocusMode,
8-
type BrowserTabState,
9-
type BrowserTabsState,
10-
type BrowserTheme,
11-
MAX_BROWSER_TABS,
3+
import type {
4+
BrowserDataKind,
5+
BrowserFindRequest,
6+
BrowserFindResult,
7+
BrowserOmniboxFocusMode,
8+
BrowserTabState,
9+
BrowserTabsState,
10+
BrowserTheme,
1211
} from '@sim/browser-protocol'
1312
import { createLogger } from '@sim/logger'
1413
import { getErrorMessage } from '@sim/utils/errors'
@@ -580,7 +579,6 @@ function sanitizePinnedTabUrls(value: unknown): string[] {
580579
for (const candidate of value) {
581580
const url = sanitizeRestorableUrl(candidate)
582581
if (url !== null) urls.push(url)
583-
if (urls.length >= MAX_BROWSER_TABS) break
584582
}
585583
return urls
586584
}
@@ -605,7 +603,6 @@ function sanitizeBrowserSessionSnapshot(value: unknown): BrowserSessionSnapshotV
605603
const url = sanitizeRestorableUrl(entry.url)
606604
if (url === null) continue
607605
restoredTabs.push({ url, pinned: entry.pinned === true })
608-
if (restoredTabs.length >= MAX_BROWSER_TABS) break
609606
}
610607

611608
const requestedIndex =
@@ -1023,10 +1020,8 @@ function createTabView(): WebContentsView {
10231020
return
10241021
}
10251022
if (shortcut === 'new-tab') {
1026-
if (listTabs().length < MAX_BROWSER_TABS) {
1027-
addTab()
1028-
focusRendererOmnibox('clear')
1029-
}
1023+
addTab()
1024+
focusRendererOmnibox('clear')
10301025
return
10311026
}
10321027

@@ -1217,9 +1212,6 @@ function addTabInternal({
12171212
activate = true,
12181213
notify = true,
12191214
}: AddTabOptions = {}): AgentTab {
1220-
if (tabs.filter((tab) => !tab.view.webContents.isDestroyed()).length >= MAX_BROWSER_TABS) {
1221-
throw new SessionError(`The browser supports up to ${MAX_BROWSER_TABS} open tabs.`)
1222-
}
12231215
const transferBrowserFocus =
12241216
activate &&
12251217
(currentScope.focusedBrowserTabId !== null ||
@@ -1340,7 +1332,6 @@ export function addTab(): AgentTab {
13401332
/** Restores the most recently closed regular tab for the current app session. */
13411333
export function reopenClosedTab(): AgentTab | null {
13421334
restoreBrowserSession()
1343-
if (listTabs().length >= MAX_BROWSER_TABS) return null
13441335
const url = recentlyClosedTabUrls.shift()
13451336
if (!url) return null
13461337

@@ -1364,7 +1355,7 @@ export function reopenClosedTab(): AgentTab | null {
13641355
export function duplicateTab(tabId: string): AgentTab | null {
13651356
restoreBrowserSession()
13661357
const source = tabs.find((entry) => entry.id === tabId)
1367-
if (!source || listTabs().length >= MAX_BROWSER_TABS) return null
1358+
if (!source) return null
13681359

13691360
const url = sanitizeRestorableUrl(source.view.webContents.getURL())
13701361
const tab = addTabInternal()

apps/desktop/src/main/desktop-chat-session-store.test.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,9 @@ describe('DesktopChatSessionStore', () => {
214214
)
215215
})
216216

217-
it('caps each snapshot at eight tabs and clamps its active index', () => {
218-
const store = open()
217+
it('preserves every browser and terminal tab while clamping active indexes', () => {
218+
const provider = encryption()
219+
const store = open(provider)
219220
store.setBrowser(ORIGIN, 'chat-a', {
220221
v: 1,
221222
tabs: Array.from({ length: 12 }, (_, index) => ({
@@ -227,15 +228,17 @@ describe('DesktopChatSessionStore', () => {
227228
store.setTerminal(ORIGIN, 'chat-a', {
228229
v: 1,
229230
tabs: Array.from({ length: 12 }, (_, index) => ({ cwd: `/tmp/tab-${index}` })),
230-
activeIndex: -4,
231+
activeIndex: 99,
231232
})
232233

233-
const browser = store.getBrowser(ORIGIN, 'chat-a')
234-
const terminal = store.getTerminal(ORIGIN, 'chat-a')
235-
expect(browser?.tabs).toHaveLength(8)
236-
expect(browser?.activeIndex).toBe(7)
237-
expect(terminal?.tabs).toHaveLength(8)
238-
expect(terminal?.activeIndex).toBe(0)
234+
expect(store.flush()).toBe(true)
235+
const restarted = open(provider)
236+
const browser = restarted.getBrowser(ORIGIN, 'chat-a')
237+
const terminal = restarted.getTerminal(ORIGIN, 'chat-a')
238+
expect(browser?.tabs).toHaveLength(12)
239+
expect(browser?.activeIndex).toBe(11)
240+
expect(terminal?.tabs).toHaveLength(12)
241+
expect(terminal?.activeIndex).toBe(11)
239242
})
240243

241244
it('filters unsafe or malformed values while loading an encrypted payload', () => {

apps/desktop/src/main/desktop-chat-session-store.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
55
const STORE_VERSION = 1
66
const SNAPSHOT_VERSION = 1
77
const MAX_DURABLE_ENTRIES = 100
8-
const MAX_TABS = 8
98
const MAX_ORIGIN_LENGTH = 2_048
109
const MAX_SCOPE_LENGTH = 512
1110
const MAX_URL_LENGTH = 8_192
@@ -134,7 +133,6 @@ function normalizeBrowserSnapshot(value: unknown): BrowserSessionSnapshot | null
134133

135134
const tabs: BrowserSessionSnapshot['tabs'] = []
136135
for (const candidate of value.tabs) {
137-
if (tabs.length === MAX_TABS) break
138136
if (!isRecord(candidate) || typeof candidate.pinned !== 'boolean') continue
139137
const url = normalizeBrowserUrl(candidate.url)
140138
if (url === null) continue
@@ -153,7 +151,6 @@ function normalizeTerminalSnapshot(value: unknown): TerminalSessionSnapshot | nu
153151

154152
const tabs: TerminalSessionSnapshot['tabs'] = []
155153
for (const candidate of value.tabs) {
156-
if (tabs.length === MAX_TABS) break
157154
if (!isRecord(candidate) || typeof candidate.cwd !== 'string') continue
158155
if (
159156
candidate.cwd.trim().length === 0 ||

apps/desktop/src/main/terminal/index.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
isTerminalControlKey,
1818
MAX_INPUT_KEYS,
1919
MAX_RUN_WAIT_MS,
20-
MAX_TERMINALS,
2120
MAX_TOOL_OUTPUT_CHARS,
2221
type TerminalCommandEvent,
2322
type TerminalControlKey,
@@ -75,6 +74,9 @@ const INPUT_SCREEN_LINES = 60
7574
*/
7675
const CWD_POLL_MS = 1_000
7776

77+
/** Cmd-Shift-T history; independent of how many terminals may be open. */
78+
const MAX_RECENTLY_CLOSED_TERMINALS = 10
79+
7880
/** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */
7981
const TMUX_KEY_GAP_MS = 150
8082

@@ -258,12 +260,6 @@ export class TerminalService {
258260

259261
/** Opens an additional terminal and makes it active. */
260262
openTerminal(cwd?: string): TerminalTabsState {
261-
if (this.sessions.size >= MAX_TERMINALS) {
262-
throw new TerminalError(
263-
'TOO_MANY_TERMINALS',
264-
`Up to ${MAX_TERMINALS} terminals can be open at once. Close one first.`
265-
)
266-
}
267263
const active = this.activeId ? this.sessions.get(this.activeId) : null
268264
const size = active ? { cols: active.cols, rows: active.rows } : { cols: 80, rows: 24 }
269265
// A new terminal opens where the current one is: the user is almost always
@@ -375,9 +371,7 @@ export class TerminalService {
375371
*/
376372
reopenClosedTerminal(ownerWindow: BrowserWindow | null): boolean {
377373
if (!this.ownsInteraction(ownerWindow)) return false
378-
// Peeked, not shifted: at the cap there is nothing to reopen into, and
379-
// consuming the entry here would drop that directory on the floor.
380-
if (this.recentlyClosedCwds.length === 0 || this.sessions.size >= MAX_TERMINALS) return false
374+
if (this.recentlyClosedCwds.length === 0) return false
381375
const cwd = this.recentlyClosedCwds.shift()
382376
this.openTerminal(cwd || undefined)
383377
return true
@@ -458,8 +452,8 @@ export class TerminalService {
458452

459453
private rememberClosed(cwd: string | null): void {
460454
this.recentlyClosedCwds.unshift(cwd ?? '')
461-
if (this.recentlyClosedCwds.length > MAX_TERMINALS) {
462-
this.recentlyClosedCwds.length = MAX_TERMINALS
455+
if (this.recentlyClosedCwds.length > MAX_RECENTLY_CLOSED_TERMINALS) {
456+
this.recentlyClosedCwds.length = MAX_RECENTLY_CLOSED_TERMINALS
463457
}
464458
}
465459

apps/desktop/src/main/terminal/registry.test.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,14 @@ describe('TerminalRegistry', () => {
194194
})
195195

196196
it('restores saved tab directories lazily as fresh shells', () => {
197+
const persistedTabs = Array.from({ length: 12 }, (_, index) => ({
198+
cwd: index % 2 === 0 ? tmpdir() : process.cwd(),
199+
}))
197200
const persistence: TerminalScopePersistence = {
198201
load: vi.fn(() => ({
199202
v: 1 as const,
200-
tabs: [{ cwd: '/tmp' }, { cwd: '/private/tmp' }],
201-
activeIndex: 1,
203+
tabs: persistedTabs,
204+
activeIndex: 11,
202205
})),
203206
save: vi.fn(),
204207
migrate: vi.fn(),
@@ -214,18 +217,18 @@ describe('TerminalRegistry', () => {
214217

215218
const restored = terminals.start('chat-A', { cols: 120, rows: 40 })
216219

217-
expect(stubSessions.map(({ cwd }) => cwd)).toEqual(['/tmp', '/private/tmp'])
220+
expect(stubSessions.map(({ cwd }) => cwd)).toEqual(persistedTabs.map(({ cwd }) => cwd))
218221
expect(restored).toMatchObject({
219-
activeTerminalId: '2',
220-
tabs: [
221-
{ terminalId: '1', cwd: '/tmp' },
222-
{ terminalId: '2', cwd: '/private/tmp' },
223-
],
222+
activeTerminalId: '12',
223+
tabs: persistedTabs.map(({ cwd }, index) => ({
224+
terminalId: String(index + 1),
225+
cwd,
226+
})),
224227
})
225228
expect(persistence.save).toHaveBeenLastCalledWith('chat-A', {
226229
v: 1,
227-
tabs: [{ cwd: '/tmp' }, { cwd: '/private/tmp' }],
228-
activeIndex: 1,
230+
tabs: persistedTabs,
231+
activeIndex: 11,
229232
})
230233

231234
terminals.dispose()

apps/desktop/src/main/terminal/service.test.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { mkdtempSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
4-
import { MAX_TERMINALS } from '@sim/terminal-protocol'
54
import { describe, expect, it, vi } from 'vitest'
65
import { TerminalService } from '@/main/terminal'
76

@@ -272,28 +271,22 @@ describe('focus-gated shortcuts', () => {
272271
expect(terminal.closeFocusedTerminal(renderer.window)).toBe(true)
273272
})
274273

275-
it('does not consume the reopen history when already at the terminal cap', () => {
274+
it('opens and reopens more than eight terminals', () => {
276275
const terminal = service()
277276
terminal.start({ cols: 80, rows: 24 })
278277
const renderer = rendererStub()
279278
terminal.setPanelFocused(true, renderer.contents)
280279

281280
const closed = terminal.openTerminal('/alpha')
282281
terminal.closeTerminal(closed.activeTerminalId as string)
283-
while (terminal.getTabs().tabs.length < MAX_TERMINALS) terminal.openTerminal()
282+
while (terminal.getTabs().tabs.length < 12) terminal.openTerminal()
284283

285-
// Refused, and without opening anything.
286-
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(false)
287-
expect(terminal.getTabs().tabs).toHaveLength(MAX_TERMINALS)
288-
289-
// NOTE: that the '/alpha' entry SURVIVES the refusal is the actual point of
290-
// the guard, and it is not observable from out here — every close prepends
291-
// one history entry and frees exactly one slot, so a reopen can never walk
292-
// back past the entries created by the closes that made room for it. The
293-
// ordering in reopenClosedTerminal (check the cap, then shift) is what
294-
// carries it; this test only pins the refusal itself.
295-
terminal.closeTerminal(terminal.getTabs().activeTerminalId as string)
296284
expect(terminal.reopenClosedTerminal(renderer.window)).toBe(true)
285+
const reopened = terminal.getTabs()
286+
expect(reopened.tabs).toHaveLength(13)
287+
expect(
288+
reopened.tabs.find(({ terminalId }) => terminalId === reopened.activeTerminalId)?.cwd
289+
).toBe('/alpha')
297290
})
298291

299292
it('ignores a blur reported by a renderer that does not hold the claim', () => {

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-tab-strip.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
useMemo,
88
useState,
99
} from 'react'
10-
import { type BrowserTabState, MAX_BROWSER_TABS } from '@sim/browser-protocol'
10+
import type { BrowserTabState } from '@sim/browser-protocol'
1111
import { TabStrip, type TabStripItem } from '@sim/emcn'
1212
import { Link, Loader } from '@sim/emcn/icons'
1313
import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types'
@@ -138,7 +138,6 @@ export function BrowserTabStrip({
138138
onNew={onNewTab}
139139
onTabContextMenu={openTabContextMenu}
140140
onTabDragStart={startTabDrag}
141-
maxTabs={MAX_BROWSER_TABS}
142141
{...(reorderingSupported ? { onReorder: onReorderTab } : {})}
143142
>
144143
<ContextMenu
@@ -161,7 +160,6 @@ export function BrowserTabStrip({
161160
isPinned={Boolean(contextTab?.pinned)}
162161
showRename={false}
163162
showDuplicate={Boolean(contextTab)}
164-
disableDuplicate={tabs.length >= MAX_BROWSER_TABS}
165163
showDelete={false}
166164
/>
167165
</TabStrip>

0 commit comments

Comments
 (0)