Skip to content

Commit a7539dc

Browse files
committed
perf(terminal): pause work for hidden sessions
1 parent 98a818d commit a7539dc

4 files changed

Lines changed: 250 additions & 46 deletions

File tree

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

Lines changed: 124 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import {
4+
memo,
45
type DragEvent as ReactDragEvent,
56
type MouseEvent as ReactMouseEvent,
67
useCallback,
@@ -120,6 +121,16 @@ function useSettledCommands(tabs: TerminalTabState[]): ReadonlySet<string> {
120121
*/
121122
const RESIZE_SETTLE_MS = 120
122123

124+
/**
125+
* How much output an offscreen terminal banks before it gives up and asks the
126+
* desktop app for the screen again on the way back in.
127+
*
128+
* Matched to the scrollback the desktop app retains: past that, replaying the
129+
* bank costs more than the snapshot and cannot show anything the snapshot
130+
* would not.
131+
*/
132+
const MAX_BANKED_CHARS = 256_000
133+
123134
const LIGHT_THEME = {
124135
background: '#ffffff',
125136
foreground: '#1f2328',
@@ -237,21 +248,26 @@ function handleTerminalShortcut(event: KeyboardEvent, terminal: Terminal): boole
237248
}
238249
}
239250

240-
function TerminalView({
251+
const TerminalView = memo(function TerminalView({
241252
terminalId,
242253
active,
243-
canClose,
254+
visible,
244255
}: {
245256
terminalId: string
246257
active: boolean
247-
canClose: boolean
258+
visible: boolean
248259
}) {
249260
const { resolvedTheme } = useTheme()
250261
const hostRef = useRef<HTMLDivElement>(null)
251262
const terminalRef = useRef<Terminal | null>(null)
252263
const fitRef = useRef<FitAddon | null>(null)
253-
const activeRef = useRef(active)
254-
activeRef.current = active
264+
// Being the selected tab is not enough to be on screen: the whole panel is
265+
// hidden whenever another resource is open.
266+
const onscreen = active && visible
267+
const onscreenRef = useRef(onscreen)
268+
onscreenRef.current = onscreen
269+
const showRef = useRef<(() => void) | null>(null)
270+
const hideRef = useRef<(() => void) | null>(null)
255271

256272
useEffect(() => {
257273
const host = hostRef.current
@@ -292,41 +308,94 @@ function TerminalView({
292308
const disposeResize = terminal.onResize(({ cols, rows }) =>
293309
resizeTerminal(terminalId, cols, rows)
294310
)
295-
// The desktop app owns the scrollback, so a view opening onto a shell that
296-
// has been running without it paints from what is already on that screen.
311+
// Output is only parsed into a terminal that is on screen.
312+
//
313+
// `write` parses whether or not the view is painting, and parsing is the
314+
// expensive half — pausing the renderer for a hidden tab saved the drawing
315+
// and none of the decoding. So every open terminal was decoding its
316+
// shell's output at full rate behind whatever the user was actually
317+
// looking at, and the cost grew with the number of tabs rather than with
318+
// the one in front of them. An offscreen view banks its bytes and replays
319+
// them on the way back in, which is indistinguishable on screen.
297320
//
298-
// Live bytes are held until that paint lands rather than written straight
299-
// through: the snapshot is a moment in time, and anything arriving while
300-
// it is in flight would either be wiped by the reset or, written first,
301-
// appear above the history it followed. Buffering keeps the order true.
321+
// Bytes are banked during the opening snapshot too. The desktop app owns
322+
// the scrollback, so a view opening onto a shell that has been running
323+
// without it paints from what is already on that screen — but the snapshot
324+
// is a moment in time, and anything arriving while it is in flight would
325+
// either be wiped by the reset or, written first, appear above the history
326+
// it followed. Banking keeps the order true.
327+
let writable = false
328+
let banked = ''
329+
let overflowed = false
302330
let painted = false
303-
let buffered = ''
331+
304332
const unsubscribeData = onTerminalData(terminalId, (data) => {
305-
if (painted) terminal.write(data)
306-
else buffered += data
333+
if (writable) {
334+
terminal.write(data)
335+
return
336+
}
337+
if (overflowed) return
338+
banked += data
339+
if (banked.length > MAX_BANKED_CHARS) {
340+
banked = ''
341+
overflowed = true
342+
}
307343
})
308344

309-
void getTerminalScrollback(terminalId)
310-
.catch((error: Error) => {
311-
// Logged rather than swallowed: a failure here is indistinguishable
312-
// on screen from a shell that has printed nothing, so the panel comes
313-
// up blank over a live terminal with no clue why. The usual cause is
314-
// a desktop build older than this renderer, which has no scrollback
315-
// channel to answer with.
316-
logger.warn('Could not read terminal scrollback; the panel will start empty', {
317-
terminalId,
318-
error: error.message,
345+
// Guarded because a snapshot is a round trip to the desktop app, and a tab
346+
// switched away from and back to during one would otherwise start a second
347+
// that resets and repaints over the first.
348+
let repainting = false
349+
const repaint = () => {
350+
if (repainting) return
351+
repainting = true
352+
return getTerminalScrollback(terminalId)
353+
.catch((error: Error) => {
354+
// Logged rather than swallowed: a failure here is indistinguishable
355+
// on screen from a shell that has printed nothing, so the panel comes
356+
// up blank over a live terminal with no clue why. The usual cause is
357+
// a desktop build older than this renderer, which has no scrollback
358+
// channel to answer with.
359+
logger.warn('Could not read terminal scrollback; the panel will start empty', {
360+
terminalId,
361+
error: error.message,
362+
})
363+
return ''
319364
})
320-
return ''
321-
})
322-
.then((scrollback) => {
323-
if (disposed) return
324-
terminal.reset()
325-
if (scrollback) terminal.write(scrollback)
326-
if (buffered) terminal.write(buffered)
327-
buffered = ''
328-
painted = true
329-
})
365+
.then((scrollback) => {
366+
repainting = false
367+
if (disposed) return
368+
terminal.reset()
369+
if (scrollback) terminal.write(scrollback)
370+
if (banked) terminal.write(banked)
371+
banked = ''
372+
overflowed = false
373+
painted = true
374+
// Only now: bytes that landed mid-snapshot are in the bank, and
375+
// writing them straight through would have put them out of order.
376+
// Read live rather than assumed — the tab may have been switched
377+
// away from while this was in flight.
378+
writable = onscreenRef.current
379+
})
380+
}
381+
382+
const show = () => {
383+
if (writable || disposed) return
384+
if (!painted || overflowed) {
385+
void repaint()
386+
return
387+
}
388+
if (banked) terminal.write(banked)
389+
banked = ''
390+
writable = true
391+
}
392+
393+
const hide = () => {
394+
writable = false
395+
}
396+
397+
showRef.current = show
398+
hideRef.current = hide
330399

331400
// Resizing is debounced, and deliberately not applied to hidden tabs.
332401
//
@@ -346,7 +415,7 @@ function TerminalView({
346415
// A zero-sized host means this terminal is off screen — either behind
347416
// another tab or with the whole panel hidden behind another resource —
348417
// not that it shrank. Fitting to that would resize the pty to nonsense.
349-
if (!activeRef.current || host.clientWidth <= 0 || host.clientHeight <= 0) return
418+
if (!onscreenRef.current || host.clientWidth <= 0 || host.clientHeight <= 0) return
350419
try {
351420
fit.fit()
352421
} catch {
@@ -358,6 +427,8 @@ function TerminalView({
358427

359428
return () => {
360429
disposed = true
430+
showRef.current = null
431+
hideRef.current = null
361432
reportTerminalFocused(false)
362433
terminal.textarea?.removeEventListener('focus', reportFocused)
363434
terminal.textarea?.removeEventListener('blur', reportBlurred)
@@ -375,6 +446,12 @@ function TerminalView({
375446
// eslint-disable-next-line react-hooks/exhaustive-deps
376447
}, [terminalId])
377448

449+
// Runs after the effect above, which is what installs these.
450+
useEffect(() => {
451+
if (onscreen) showRef.current?.()
452+
else hideRef.current?.()
453+
}, [onscreen, terminalId])
454+
378455
useEffect(() => {
379456
const terminal = terminalRef.current
380457
if (!terminal) return
@@ -391,15 +468,15 @@ function TerminalView({
391468
// back to the DOM. Handing the renderer to whichever tab is visible keeps
392469
// one context for the whole panel.
393470
useEffect(() => {
394-
if (!active) return
471+
if (!onscreen) return
395472
const terminal = terminalRef.current
396473
if (!terminal) return
397474
const disposeRenderer = attachWebglRenderer(terminal)
398475
return () => disposeRenderer?.()
399-
}, [active])
476+
}, [onscreen])
400477

401478
useEffect(() => {
402-
if (!active) return
479+
if (!onscreen) return
403480
// Measure after the browser has laid the newly shown terminal out.
404481
const frame = requestAnimationFrame(() => {
405482
const host = hostRef.current
@@ -412,7 +489,7 @@ function TerminalView({
412489
}
413490
})
414491
return () => cancelAnimationFrame(frame)
415-
}, [active])
492+
}, [onscreen])
416493

417494
const {
418495
isOpen: isMenuOpen,
@@ -466,6 +543,10 @@ function TerminalView({
466543
}, [])
467544

468545
// Scoped to the terminal that was right-clicked, not the active one.
546+
// Offered even for the only terminal: closing the last one restarts its
547+
// shell in place rather than removing the tab, so there is always something
548+
// for the action to do — and hiding it here while the tab strip's own close
549+
// stays available would just be the two menus disagreeing.
469550
const closeThisTerminal = useCallback(() => {
470551
void closeTerminal(terminalId)
471552
}, [terminalId])
@@ -493,18 +574,18 @@ function TerminalView({
493574
onPaste={pasteClipboard}
494575
onClear={clearScreen}
495576
onNewTab={newTab}
496-
{...(canClose ? { onCloseTerminal: closeThisTerminal } : {})}
577+
onCloseTerminal={closeThisTerminal}
497578
/>
498579
</>
499580
)
500-
}
581+
})
501582

502583
/**
503584
* The terminal panel. Unlike the browser panel, nothing native is overlaid
504585
* here: xterm.js renders each PTY's bytes in the DOM, so the panel is an
505586
* ordinary React subtree that happens to be a set of working terminals.
506587
*/
507-
export function TerminalSession() {
588+
export function TerminalSession({ visible }: { visible: boolean }) {
508589
const { tabs, activeTerminalId } = useCopilotTerminalStore((state) => state.tabs)
509590
const settledCommands = useSettledCommands(tabs)
510591
const { removeResource } = useMothershipResources()
@@ -644,7 +725,7 @@ export function TerminalSession() {
644725
key={tab.terminalId}
645726
terminalId={tab.terminalId}
646727
active={tab.terminalId === activeTerminalId}
647-
canClose={tabs.length > 1}
728+
visible={visible}
648729
/>
649730
))}
650731
{startError && (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ export const ResourceContent = memo(function ResourceContent({
289289
return <BrowserSession key={resource.id} visible={visible} />
290290

291291
case 'terminal':
292-
return <TerminalSession key={resource.id} />
292+
return <TerminalSession key={resource.id} visible={visible} />
293293

294294
default:
295295
return null
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { TerminalTabState } from '@sim/terminal-protocol'
2+
import { beforeEach, describe, expect, it } from 'vitest'
3+
import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
4+
5+
function tab(overrides: Partial<TerminalTabState> = {}): TerminalTabState {
6+
return {
7+
terminalId: 't1',
8+
title: 'code',
9+
cwd: '/Users/me/code',
10+
running: null,
11+
interactive: false,
12+
active: true,
13+
...overrides,
14+
}
15+
}
16+
17+
describe('copilot terminal store', () => {
18+
beforeEach(() => {
19+
useCopilotTerminalStore.setState({
20+
tabs: { tabs: [], activeTerminalId: null },
21+
agentCommandIds: [],
22+
})
23+
})
24+
25+
/**
26+
* The desktop app re-pushes the whole tab list on a timer, so an identical
27+
* push has to keep its identity or the panel and every terminal in it
28+
* re-render once a second for nothing.
29+
*/
30+
it('keeps state identity when a push says nothing new', () => {
31+
const { setTabs } = useCopilotTerminalStore.getState()
32+
setTabs({ tabs: [tab()], activeTerminalId: 't1' })
33+
const first = useCopilotTerminalStore.getState().tabs
34+
35+
setTabs({ tabs: [tab()], activeTerminalId: 't1' })
36+
37+
expect(useCopilotTerminalStore.getState().tabs).toBe(first)
38+
})
39+
40+
it.each([
41+
['a command starts', { running: 'bun test' }],
42+
['the directory changes', { cwd: '/tmp' }],
43+
['the title changes', { title: 'tmp' }],
44+
['a full-screen program takes over', { interactive: true }],
45+
['the tab stops being active', { active: false }],
46+
['tmux attaches', { tmuxSession: 'main' }],
47+
])('takes the update when %s', (_case, change) => {
48+
const { setTabs } = useCopilotTerminalStore.getState()
49+
setTabs({ tabs: [tab()], activeTerminalId: 't1' })
50+
const first = useCopilotTerminalStore.getState().tabs
51+
52+
setTabs({ tabs: [tab(change)], activeTerminalId: 't1' })
53+
54+
expect(useCopilotTerminalStore.getState().tabs).not.toBe(first)
55+
expect(useCopilotTerminalStore.getState().tabs.tabs[0]).toMatchObject(change)
56+
})
57+
58+
it('takes the update when the active terminal changes', () => {
59+
const { setTabs } = useCopilotTerminalStore.getState()
60+
const tabs = [tab(), tab({ terminalId: 't2', active: false })]
61+
setTabs({ tabs, activeTerminalId: 't1' })
62+
const first = useCopilotTerminalStore.getState().tabs
63+
64+
setTabs({ tabs, activeTerminalId: 't2' })
65+
66+
expect(useCopilotTerminalStore.getState().tabs).not.toBe(first)
67+
})
68+
69+
it('takes the update when a tab opens or closes', () => {
70+
const { setTabs } = useCopilotTerminalStore.getState()
71+
setTabs({ tabs: [tab()], activeTerminalId: 't1' })
72+
const first = useCopilotTerminalStore.getState().tabs
73+
74+
setTabs({ tabs: [tab(), tab({ terminalId: 't2' })], activeTerminalId: 't1' })
75+
76+
expect(useCopilotTerminalStore.getState().tabs).not.toBe(first)
77+
expect(useCopilotTerminalStore.getState().tabs.tabs).toHaveLength(2)
78+
})
79+
80+
/**
81+
* The comparator walks keys rather than a written-out field list precisely so
82+
* that a field added to the protocol cannot quietly stop reaching the UI.
83+
*/
84+
it('takes the update when a tab carries a field the comparator never named', () => {
85+
const { setTabs } = useCopilotTerminalStore.getState()
86+
setTabs({ tabs: [tab()], activeTerminalId: 't1' })
87+
const first = useCopilotTerminalStore.getState().tabs
88+
89+
setTabs({
90+
tabs: [{ ...tab(), somethingNew: true } as TerminalTabState],
91+
activeTerminalId: 't1',
92+
})
93+
94+
expect(useCopilotTerminalStore.getState().tabs).not.toBe(first)
95+
})
96+
})

0 commit comments

Comments
 (0)