Skip to content

Commit 7c146c6

Browse files
committed
fix(desktop): reduce hidden panel background work
1 parent 1e210f4 commit 7c146c6

10 files changed

Lines changed: 237 additions & 49 deletions

File tree

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,22 @@ function pushPageState(contents: WebContents): void {
119119
driverCallbacks?.onPageState(pageStateFor(contents, active.id))
120120
}
121121

122+
let lastTabsStateFingerprint: string | null = null
123+
124+
/**
125+
* Pushes the tab list to the renderer, skipping a push identical to the last.
126+
*
127+
* Every tab's load and title events call this, background tabs included, and a
128+
* site that rewrites its title on a timer fires them continuously — each an
129+
* unconditional broadcast that rebuilt the whole strip. The fingerprint drops
130+
* the repeats, matching what the terminal side already does with emitTabs.
131+
*/
122132
function pushTabsState(): void {
123-
driverCallbacks?.onTabsState(session.getTabsState())
133+
const state = session.getTabsState()
134+
const fingerprint = JSON.stringify(state)
135+
if (fingerprint === lastTabsStateFingerprint) return
136+
lastTabsStateFingerprint = fingerprint
137+
driverCallbacks?.onTabsState(state)
124138
}
125139

126140
/** Instruments a fresh tab: CDP dialog/chooser handling + page-state pushes. */

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,15 +235,42 @@ describe('browser-agent session', () => {
235235
expect(session.closeFocusedTab()).toBe(true)
236236
})
237237

238-
it('only disables hidden-page throttling while browser automation is active', () => {
239-
const tab = session.ensureTab()
240-
const contents = (tab.view as unknown as MockView).webContents
238+
it('unthrottles only the active tab while automation is active', () => {
239+
const active = session.ensureTab()
240+
const activeContents = (active.view as unknown as MockView).webContents
241+
const background = session.addTab()
242+
const backgroundContents = (background.view as unknown as MockView).webContents
243+
// addTab activated the second tab; put focus back on the first.
244+
session.switchTab(active.id)
245+
activeContents.setBackgroundThrottling.mockClear()
246+
backgroundContents.setBackgroundThrottling.mockClear()
241247

242248
session.setAutomationActive(true)
243-
expect(contents.setBackgroundThrottling).toHaveBeenLastCalledWith(false)
249+
// The waking is scoped to the active tab; the background tab stays throttled.
250+
expect(activeContents.setBackgroundThrottling).toHaveBeenLastCalledWith(false)
251+
expect(backgroundContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
244252

245253
session.setAutomationActive(false)
246-
expect(contents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
254+
expect(activeContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
255+
})
256+
257+
it('moves the automation exemption to whichever tab becomes active', () => {
258+
const first = session.ensureTab()
259+
const second = session.addTab()
260+
session.switchTab(first.id)
261+
const firstContents = (first.view as unknown as MockView).webContents
262+
const secondContents = (second.view as unknown as MockView).webContents
263+
264+
session.setAutomationActive(true)
265+
firstContents.setBackgroundThrottling.mockClear()
266+
secondContents.setBackgroundThrottling.mockClear()
267+
268+
session.switchTab(second.id)
269+
270+
// The old active tab is re-throttled, the new one exempted — otherwise a
271+
// mid-tool switch would strand the wake on a tab the agent left behind.
272+
expect(firstContents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
273+
expect(secondContents.setBackgroundThrottling).toHaveBeenLastCalledWith(false)
247274
})
248275

249276
it('updates the native backdrop when Sim changes browser theme', () => {

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

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,10 @@ function createTabView(): WebContentsView {
291291
sandbox: true,
292292
webSecurity: true,
293293
webviewTag: false,
294-
// Visible pages remain full speed. Hidden pages may be throttled unless
295-
// a browser tool is actively waiting on them.
296-
backgroundThrottling: !automationActive,
294+
// Throttled by default: a hidden tab should idle. The one exception is
295+
// the active tab while a tool waits on it, applied explicitly by
296+
// applyActiveTabThrottling — never blanket across every tab.
297+
backgroundThrottling: true,
297298
spellcheck: false,
298299
},
299300
})
@@ -383,15 +384,33 @@ export function hasSession(): boolean {
383384
}
384385

385386
/**
386-
* Keeps hidden pages responsive during an agent action, then returns them to
387-
* Chromium's normal background throttling so they cannot contend with Sim.
387+
* Keeps the ACTIVE tab responsive during an agent action, then returns it to
388+
* normal background throttling.
389+
*
390+
* Only the active tab, deliberately. The agent drives one tab at a time — the
391+
* active one — possibly while the panel is hidden and even that view is
392+
* detached, so it is the only tab that must not be throttled mid-tool. Waking
393+
* every tab, as this once did, meant an agent run kept all N-1 background
394+
* renderers at full speed for the length of the run, which is the browser
395+
* side of the multi-tab lag. Nothing depends on a background tab staying
396+
* awake: switching to one activates it (and re-applies this) before any tool
397+
* touches it, and network loading is not throttled anyway.
388398
*/
389399
export function setAutomationActive(active: boolean): void {
390400
automationActive = active
401+
applyActiveTabThrottling()
402+
}
403+
404+
/**
405+
* Unthrottles the active tab while automation is active, and throttles every
406+
* other tab. Call after anything that changes which tab is active, so the
407+
* exemption follows the active tab rather than being stranded on the old one.
408+
*/
409+
function applyActiveTabThrottling(): void {
391410
for (const tab of tabs) {
392-
if (!tab.view.webContents.isDestroyed()) {
393-
tab.view.webContents.setBackgroundThrottling(!active)
394-
}
411+
if (tab.view.webContents.isDestroyed()) continue
412+
const exempt = automationActive && tab.id === activeTabId
413+
tab.view.webContents.setBackgroundThrottling(!exempt)
395414
}
396415
}
397416

@@ -482,6 +501,7 @@ function addTabInternal({
482501
}
483502
if (activate || activeTabId === null) {
484503
activeTabId = tab.id
504+
applyActiveTabThrottling()
485505
layout()
486506
if (transferBrowserFocus) focusedBrowserTabId = tab.id
487507
if (notify) events?.onActiveTabChanged(tab.view.webContents)
@@ -563,6 +583,9 @@ export function switchTab(tabId: string): AgentTab {
563583
const transferBrowserFocus =
564584
focusedBrowserTabId !== null || tabs.some((entry) => entry.view.webContents.isFocused())
565585
activeTabId = tab.id
586+
// The automation exemption follows the active tab, so a mid-tool switch
587+
// unthrottles the new one and re-throttles the old.
588+
applyActiveTabThrottling()
566589
layout()
567590
if (transferBrowserFocus) focusedBrowserTabId = tab.id
568591
events?.onActiveTabChanged(tab.view.webContents)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,26 @@ function mutationTouchesOverlay(mutation: MutationRecord): boolean {
7575
* Mutation and resize observers keep the tracked overlay set current, while
7676
* captured scroll handles poppers moving without changing their own DOM.
7777
*/
78-
export function useBrowserPanelOcclusion(hostRef: RefObject<HTMLElement | null>): boolean {
78+
export function useBrowserPanelOcclusion(
79+
hostRef: RefObject<HTMLElement | null>,
80+
visible: boolean
81+
): boolean {
7982
const [occluded, setOccluded] = useState(false)
8083

8184
useEffect(() => {
8285
const host = hostRef.current
8386
if (!host) return
87+
// Occlusion of a hidden panel is meaningless — there is no native view on
88+
// screen to be covered — so none of the machinery below runs while hidden.
89+
// That machinery is a document-body MutationObserver plus capture-phase
90+
// scroll/resize listeners, which otherwise fire on every DOM change and
91+
// scroll anywhere in the app (the chat transcript streaming, most of all)
92+
// for a panel nobody can see.
93+
if (!visible) {
94+
setOccluded(false)
95+
resetBrowserPanelOcclusion()
96+
return
97+
}
8498

8599
let disposed = false
86100
let checkQueued = false
@@ -154,7 +168,7 @@ export function useBrowserPanelOcclusion(hostRef: RefObject<HTMLElement | null>)
154168
window.removeEventListener('scroll', scheduleOcclusionCheck, true)
155169
resetBrowserPanelOcclusion()
156170
}
157-
}, [hostRef])
171+
}, [hostRef, visible])
158172

159173
return occluded
160174
}

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function describeAnchor(panel: HTMLElement | null): BrowserPanelAnchor | null {
110110
}
111111
}
112112

113-
export function BrowserSession() {
113+
export function BrowserSession({ visible }: { visible: boolean }) {
114114
const { theme } = useTheme()
115115
const pageState = useBrowserSessionStore((state) => state.pageState)
116116
const tabs = useBrowserSessionStore((state) => state.tabs)
@@ -123,7 +123,7 @@ export function BrowserSession() {
123123
const panelRef = useRef<HTMLDivElement>(null)
124124
const hostRef = useRef<HTMLDivElement>(null)
125125
const urlInputRef = useRef<HTMLInputElement>(null)
126-
const panelOccluded = useBrowserPanelOcclusion(hostRef)
126+
const panelOccluded = useBrowserPanelOcclusion(hostRef, visible)
127127
const pageUrlRef = useRef(pageState?.url ?? '')
128128
pageUrlRef.current = pageState?.url ?? ''
129129
/** Non-null while the user is editing the URL bar; otherwise it mirrors the page. */
@@ -182,6 +182,15 @@ export function BrowserSession() {
182182
useEffect(() => {
183183
const host = hostRef.current
184184
if (!host) return
185+
// Hidden behind another resource: the native view has nowhere to sit, so
186+
// report it gone once and install none of the scroll/resize/heartbeat
187+
// machinery below — all of which would otherwise fire on every scroll and
188+
// resize anywhere in the app, for a panel nobody can see.
189+
if (!visible) {
190+
setPanelVisible(false)
191+
reportBrowserPanelBounds(null)
192+
return
193+
}
185194
// Resolved once: the panel is a stable ancestor for this effect's lifetime,
186195
// and only its inline width changes.
187196
const panel = host.closest<HTMLElement>('[data-mothership-panel]')
@@ -252,7 +261,7 @@ export function BrowserSession() {
252261
}
253262
reportBrowserPanelBounds(null)
254263
}
255-
}, [])
264+
}, [visible])
256265

257266
const submitUrl = useCallback(() => {
258267
const raw = (urlDraft ?? '').trim()

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,21 +182,23 @@ const DARK_THEME = {
182182
* this addon ship in lockstep; adopting it would mean moving the core library
183183
* back a major version onto a renderer that is no longer published.
184184
*/
185-
function attachWebglRenderer(terminal: Terminal): void {
185+
function attachWebglRenderer(terminal: Terminal): (() => void) | null {
186186
try {
187187
const webgl = new WebglAddon()
188188
webgl.onContextLoss(() => {
189189
logger.warn('Terminal WebGL context lost; falling back to the DOM renderer')
190190
webgl.dispose()
191191
})
192192
terminal.loadAddon(webgl)
193+
return () => webgl.dispose()
193194
} catch (error) {
194195
// The DOM renderer stays in place. Logged rather than swallowed: it is a
195196
// large, silent performance cliff, and "the terminal feels slow" is
196197
// otherwise indistinguishable from every other cause of slowness.
197198
logger.warn('Terminal WebGL unavailable; using the slower DOM renderer', {
198199
error: (error as Error).message,
199200
})
201+
return null
200202
}
201203
}
202204

@@ -274,7 +276,6 @@ function TerminalView({
274276
terminal.unicode.activeVersion = '11'
275277

276278
terminal.open(host)
277-
attachWebglRenderer(terminal)
278279

279280
terminalRef.current = terminal
280281
fitRef.current = fit
@@ -300,8 +301,7 @@ function TerminalView({
300301
// appear above the history it followed. Buffering keeps the order true.
301302
let painted = false
302303
let buffered = ''
303-
const unsubscribeData = onTerminalData((id, data) => {
304-
if (id !== terminalId) return
304+
const unsubscribeData = onTerminalData(terminalId, (data) => {
305305
if (painted) terminal.write(data)
306306
else buffered += data
307307
})
@@ -381,6 +381,23 @@ function TerminalView({
381381
terminal.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
382382
}, [resolvedTheme])
383383

384+
// A GPU renderer only for the terminal on screen.
385+
//
386+
// Every open terminal keeps its emulator alive so switching is instant, but
387+
// a hidden one is `display: none` and xterm has already paused painting it —
388+
// a WebGL context for it buys nothing and costs plenty. Browsers cap how
389+
// many contexts can be live and evict the oldest past that, so holding one
390+
// per tab means tabs quietly knocking each other's renderers out and falling
391+
// back to the DOM. Handing the renderer to whichever tab is visible keeps
392+
// one context for the whole panel.
393+
useEffect(() => {
394+
if (!active) return
395+
const terminal = terminalRef.current
396+
if (!terminal) return
397+
const disposeRenderer = attachWebglRenderer(terminal)
398+
return () => disposeRenderer?.()
399+
}, [active])
400+
384401
useEffect(() => {
385402
if (!active) return
386403
// Measure after the browser has laid the newly shown terminal out.

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ interface ResourceContentProps {
8585
genericResourceData?: GenericResourceData
8686
previewContextKey?: string
8787
onNotFound?: (resourceId: string) => void
88+
/**
89+
* Whether this resource is the one on screen. Only the persistent panels
90+
* (browser, terminal) read it — to stand down document-wide observers while
91+
* hidden — so it defaults to visible for every other resource, which is only
92+
* ever rendered when active.
93+
*/
94+
visible?: boolean
8895
}
8996

9097
/**
@@ -147,6 +154,7 @@ export const ResourceContent = memo(function ResourceContent({
147154
genericResourceData,
148155
previewContextKey,
149156
onNotFound,
157+
visible = true,
150158
}: ResourceContentProps) {
151159
const streamFileName = previewSession?.fileName || 'file.md'
152160
const syntheticFile = useMemo(() => {
@@ -278,7 +286,7 @@ export const ResourceContent = memo(function ResourceContent({
278286
)
279287

280288
case 'browser':
281-
return <BrowserSession key={resource.id} />
289+
return <BrowserSession key={resource.id} visible={visible} />
282290

283291
case 'terminal':
284292
return <TerminalSession key={resource.id} />

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,18 @@ export const MothershipView = memo(
166166
key={resource.id}
167167
className={cn('absolute inset-0', resource.id !== active?.id && 'hidden')}
168168
>
169-
<ResourceContent workspaceId={workspaceId} resource={resource} />
169+
{/*
170+
A hidden persistent panel can otherwise only INFER it is off
171+
screen by measuring itself, which is enough to pause xterm and
172+
hide the native view but not to switch off document-wide
173+
observers the panel installs. The explicit flag lets it stand
174+
those down while hidden.
175+
*/}
176+
<ResourceContent
177+
workspaceId={workspaceId}
178+
resource={resource}
179+
visible={resource.id === active?.id}
180+
/>
170181
</div>
171182
))}
172183
{active && !isPersistentPanel(active) && (

apps/sim/lib/terminal/transport.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,30 @@ export function initTerminalTransport(): void {
5353
.catch(() => {})
5454
}
5555

56-
/** Subscribes to raw PTY output for every terminal. */
57-
export function onTerminalData(callback: (terminalId: string, data: string) => void): () => void {
58-
return bridge()?.onData(callback) ?? (() => {})
56+
/**
57+
* One bridge subscription for all terminals, fanned out by id.
58+
*
59+
* Every mounted terminal view wants only its own bytes, but each PTY message
60+
* crosses the context bridge once per registered listener — so a listener per
61+
* view made a single terminal's output cost O(open tabs) crossings a message,
62+
* most of them discarded by an id check. This keeps exactly one bridge
63+
* listener and routes each message to the view that asked for that id.
64+
*/
65+
const dataHandlers = new Map<string, (data: string) => void>()
66+
let dataBridgeUnsubscribe: (() => void) | null = null
67+
68+
function ensureDataBridge(): void {
69+
if (dataBridgeUnsubscribe) return
70+
dataBridgeUnsubscribe = bridge()?.onData((id, data) => dataHandlers.get(id)?.(data)) ?? (() => {})
71+
}
72+
73+
/** Subscribes to raw PTY output for one terminal. */
74+
export function onTerminalData(terminalId: string, callback: (data: string) => void): () => void {
75+
ensureDataBridge()
76+
dataHandlers.set(terminalId, callback)
77+
return () => {
78+
if (dataHandlers.get(terminalId) === callback) dataHandlers.delete(terminalId)
79+
}
5980
}
6081

6182
/**

0 commit comments

Comments
 (0)