Skip to content

Commit 984fba7

Browse files
committed
Merge branch 'staging-v4' of github.com:simstudioai/sim into staging-v4
2 parents 096e2f7 + a7539dc commit 984fba7

18 files changed

Lines changed: 680 additions & 135 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/panel.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,23 @@ function resetOcclusion(): void {
283283
* the one caller that captures an already-hidden page (a tab switched while the
284284
* panel is occluded), where it stops Chromium promoting it back for the shot.
285285
*/
286+
/** Widest the placeholder snapshot needs to be; it sits behind a transient overlay. */
287+
const SNAPSHOT_MAX_WIDTH = 1024
288+
const SNAPSHOT_JPEG_QUALITY = 70
289+
290+
/**
291+
* Turns a captured frame into a compact JPEG data URL. Resize is a native
292+
* operation and JPEG encode runs in native code too, so both are far cheaper
293+
* than a full-resolution PNG `toDataURL`, which encodes synchronously on the
294+
* event loop.
295+
*/
296+
function encodeSnapshot(image: Electron.NativeImage): string {
297+
const { width } = image.getSize()
298+
const scaled = width > SNAPSHOT_MAX_WIDTH ? image.resize({ width: SNAPSHOT_MAX_WIDTH }) : image
299+
const jpeg = scaled.toJPEG(SNAPSHOT_JPEG_QUALITY)
300+
return `data:image/jpeg;base64,${jpeg.toString('base64')}`
301+
}
302+
286303
function capturePanelSnapshot(onSettled?: () => void): void {
287304
const active = host.activeTab()
288305
const win = panelWindow()
@@ -301,7 +318,13 @@ function capturePanelSnapshot(onSettled?: () => void): void {
301318
// picture of the page, so it goes to the window still showing the
302319
// browser or nowhere at all.
303320
if (panelWindow() !== win || win.isDestroyed()) return
304-
const snapshot: BrowserPanelSnapshot = { dataUrl: image.toDataURL(), tabId }
321+
// Downscale and JPEG-encode before crossing IPC. capturePage returns a
322+
// device-pixel PNG — on a retina half-window that is millions of pixels,
323+
// and toDataURL's PNG encode is synchronous on the main process, so a
324+
// full-size encode stalls every window's input for the frame. This is a
325+
// placeholder shown under a transient overlay, so a downscaled JPEG is
326+
// indistinguishable and an order of magnitude cheaper to encode and send.
327+
const snapshot: BrowserPanelSnapshot = { dataUrl: encodeSnapshot(image), tabId }
305328
win.webContents.send('browser-agent:panel-snapshot', snapshot)
306329
})
307330
.catch((error) => {

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

Lines changed: 33 additions & 6 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', () => {
@@ -725,7 +752,7 @@ describe('browser-agent session', () => {
725752
expect(view.setVisible).not.toHaveBeenCalledWith(false)
726753
await vi.waitFor(() => {
727754
expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:panel-snapshot', {
728-
dataUrl: 'data:image/png;base64,c2lt',
755+
dataUrl: 'data:image/jpeg;base64,c2lt',
729756
tabId: tab.id,
730757
})
731758
})

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/desktop/src/main/terminal/index.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ import {
3838
awaitRun,
3939
capturePane,
4040
closeRunWindow,
41+
isTmuxUnavailable,
4142
killPane,
4243
listPanes,
4344
resolveAttachment,
4445
sendKey,
4546
sendText,
4647
startRun,
4748
TMUX_KEY_NAMES,
49+
type TmuxAttachment,
4850
} from '@/main/terminal/tmux'
4951

5052
const logger = createLogger('DesktopTerminal')
@@ -72,6 +74,9 @@ const CWD_POLL_MS = 1_000
7274
/** Pause between keys sent to a tmux pane, matching the pty keystroke gap. */
7375
const TMUX_KEY_GAP_MS = 150
7476

77+
/** How long a resolved tmux attachment is reused before re-resolving. */
78+
const TMUX_ATTACHMENT_TTL_MS = 3_000
79+
7580
/** How often a handoff checks whether the user or the command has finished. */
7681
const HANDOFF_POLL_MS = 500
7782

@@ -160,6 +165,8 @@ export class TerminalService {
160165
private readonly recentlyClosedCwds: string[] = []
161166
/** Terminals handed to the user; the value is whether they have handed back. */
162167
private readonly handoffs = new Map<string, boolean>()
168+
/** Recently resolved tmux attachments, by terminal id, to avoid re-spawning. */
169+
private readonly tmuxCache = new Map<string, { at: number; attachment: TmuxAttachment | null }>()
163170

164171
constructor(private readonly options: TerminalServiceOptions = {}) {}
165172

@@ -281,6 +288,7 @@ export class TerminalService {
281288
const index = order.indexOf(terminalId)
282289
session.dispose()
283290
this.sessions.delete(terminalId)
291+
this.tmuxCache.delete(terminalId)
284292

285293
if (this.sessions.size === 0) {
286294
this.spawn(this.resolveCwd(closedCwd), cols, rows)
@@ -357,6 +365,7 @@ export class TerminalService {
357365
this.stopCwdWatch()
358366
for (const session of this.sessions.values()) session.dispose()
359367
this.sessions.clear()
368+
this.tmuxCache.clear()
360369
this.activeId = null
361370
}
362371

@@ -403,9 +412,9 @@ export class TerminalService {
403412
}
404413

405414
const session = this.requireSession(args)
406-
// Resolved once per call: a tab either has tmux attached or it does not,
407-
// and every operation below behaves differently depending on which.
408-
const tmux = await resolveAttachment(session.pid, session.env)
415+
// A tab either has tmux attached or it does not, and every operation below
416+
// behaves differently depending on which.
417+
const tmux = await this.resolveTmux(session)
409418

410419
switch (operation) {
411420
case 'cwd':
@@ -458,7 +467,7 @@ export class TerminalService {
458467
case 'read': {
459468
const requested = Number(args.lines)
460469
const lines = Number.isFinite(requested) && requested > 0 ? requested : 200
461-
if (!tmux) return session.readScrollback(lines)
470+
if (!tmux) return await session.readScrollback(lines)
462471
const target = await this.resolvePane(tmux.session, args, session)
463472
const captured = await capturePane(target, lines, session.env)
464473
if (!captured.ok) {
@@ -516,8 +525,8 @@ export class TerminalService {
516525
const terminalId = session.terminalId
517526
this.handoffs.set(terminalId, false)
518527

519-
const settled = (handedBack: boolean): TerminalHandoffResult => {
520-
const view = session.readScrollback(INPUT_SCREEN_LINES)
528+
const settled = async (handedBack: boolean): Promise<TerminalHandoffResult> => {
529+
const view = await session.readScrollback(INPUT_SCREEN_LINES)
521530
return {
522531
terminalId,
523532
reason,
@@ -538,13 +547,13 @@ export class TerminalService {
538547
}
539548
// The command finishing is the real end of the handoff, whether or not
540549
// the user pressed anything: it means the prompt got answered.
541-
if (!session.isBusy) return settled(this.handoffs.get(terminalId) === true)
550+
if (!session.isBusy) return await settled(this.handoffs.get(terminalId) === true)
542551
if (this.handoffs.get(terminalId) === true) {
543552
handedBackAt ??= Date.now()
544-
if (Date.now() - handedBackAt >= HANDOFF_SETTLE_MS) return settled(true)
553+
if (Date.now() - handedBackAt >= HANDOFF_SETTLE_MS) return await settled(true)
545554
}
546555
}
547-
return settled(this.handoffs.get(terminalId) === true)
556+
return await settled(this.handoffs.get(terminalId) === true)
548557
} finally {
549558
this.handoffs.delete(terminalId)
550559
}
@@ -555,6 +564,26 @@ export class TerminalService {
555564
if (this.handoffs.has(terminalId)) this.handoffs.set(terminalId, true)
556565
}
557566

567+
/**
568+
* The tmux session attached in a terminal, cached briefly.
569+
*
570+
* Resolving it spawns `tmux list-clients` and a whole-machine `ps` — a real
571+
* cost to pay on every tool call, when a shell's attachment does not change
572+
* between calls a second apart. A short TTL keeps the common burst of
573+
* operations (run, then poll with read, then read again) to one resolution,
574+
* while staying fresh enough to notice the user starting or leaving tmux.
575+
*/
576+
private async resolveTmux(session: TerminalSession): Promise<TmuxAttachment | null> {
577+
if (isTmuxUnavailable()) return null
578+
const cached = this.tmuxCache.get(session.terminalId)
579+
if (cached && Date.now() - cached.at < TMUX_ATTACHMENT_TTL_MS) {
580+
return cached.attachment
581+
}
582+
const attachment = await resolveAttachment(session.pid, session.env)
583+
this.tmuxCache.set(session.terminalId, { at: Date.now(), attachment })
584+
return attachment
585+
}
586+
558587
/** The pane a call names, or the session's active one. */
559588
private async resolvePane(
560589
session: string,
@@ -631,12 +660,12 @@ export class TerminalService {
631660
if (keys.length > 0) {
632661
await session.pressKeys(keys)
633662
await delay(INPUT_ECHO_MS)
634-
return { sent: keys.join(', '), ...session.readScrollback(INPUT_SCREEN_LINES) }
663+
return { sent: keys.join(', '), ...(await session.readScrollback(INPUT_SCREEN_LINES)) }
635664
}
636665
if (typeof args.text === 'string') {
637666
await session.type(args.text)
638667
await delay(INPUT_ECHO_MS)
639-
return { sent: args.text, ...session.readScrollback(INPUT_SCREEN_LINES) }
668+
return { sent: args.text, ...(await session.readScrollback(INPUT_SCREEN_LINES)) }
640669
}
641670
throw new TerminalError('INVALID_REQUEST', 'input needs `text`, `key`, or `keys`.')
642671
}

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,42 @@ describe('findSelectedRow', () => {
9494
expect(findSelectedRow(term.buffer.active)).toBeNull()
9595
})
9696
})
97+
98+
describe('replaying retained bytes into a fresh emulator', () => {
99+
/**
100+
* The screen is now rendered by replaying the retained byte stream into an
101+
* emulator built for the read, rather than keeping one fed forever. Two
102+
* things have to hold for that to be equivalent.
103+
*/
104+
it('has the whole stream parsed by the time the write callback fires', async () => {
105+
// xterm parses asynchronously in 12ms slices, so reading the buffer right
106+
// after write() would catch it half-parsed. The callback is the signal.
107+
const term = new Terminal({ cols: 40, rows: 8, allowProposedApi: true })
108+
const lines = Array.from({ length: 200 }, (_, i) => `line ${i}`)
109+
110+
await new Promise<void>((resolve) => term.write(`${lines.join('\r\n')}\r\n`, resolve))
111+
112+
const buffer = term.buffer.active
113+
const rendered: string[] = []
114+
for (let row = 0; row < buffer.length; row++) {
115+
const text = buffer.getLine(row)?.translateToString(true) ?? ''
116+
if (text.trim()) rendered.push(text.trim())
117+
}
118+
expect(rendered.at(-1)).toBe('line 199')
119+
expect(rendered).toHaveLength(200)
120+
})
121+
122+
it('reconstructs the final screen of a program that redraws in place', async () => {
123+
// A TUI overwrites the same rows, so a replay must end on the last frame
124+
// rather than showing every frame stacked up — the reason a raw byte dump
125+
// cannot be handed to the model directly.
126+
const term = new Terminal({ cols: 40, rows: 8, allowProposedApi: true })
127+
const frames = ['Loading ', 'Loading. ', 'Loading.. ', 'Done! ']
128+
const stream = frames.map((frame) => `\u001b[H\u001b[2K${frame}`).join('')
129+
130+
await new Promise<void>((resolve) => term.write(stream, resolve))
131+
132+
const firstRow = term.buffer.active.getLine(0)?.translateToString(true).trim()
133+
expect(firstRow).toBe('Done!')
134+
})
135+
})

0 commit comments

Comments
 (0)