Fix remote runtime terminal rendering (#4946)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-09 00:20:25 -04:00 committed by GitHub
parent 4660a25f09
commit 699bcf6478
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 147 additions and 14 deletions

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
describe('tabGroupBodyAnchorName', () => {
it('returns a valid CSS custom anchor name for UUID-style group ids', () => {
const anchorName = tabGroupBodyAnchorName('11111111-1111-4111-8111-111111111111')
expect(anchorName).toMatch(/^--orca-tab-group-body-[0-9a-f-]+$/)
})
it('encodes remote runtime group ids that include path separators', () => {
const anchorName = tabGroupBodyAnchorName(
'headless-terminals:repo::/Users/jinwoohong/orca/workspaces/orca/branch'
)
expect(anchorName).not.toContain(':')
expect(anchorName).not.toContain('/')
expect(anchorName).toMatch(/^--orca-tab-group-body-[0-9a-f-]+$/)
})
})

View File

@ -7,9 +7,10 @@ const ANCHOR_PREFIX = '--orca-tab-group-body-'
/**
* Returns the CSS anchor name for a given tab-group id. Anchor names must be
* `<dashed-ident>`; groupIds are UUIDs (hex + `-`) so they are already safe
* as suffixes. Prefixed so they cannot collide with unrelated anchors.
* `<dashed-ident>`; remote/runtime groups can include path-like ids, so encode
* the full id into hex code points before appending it to the custom prefix.
*/
export function tabGroupBodyAnchorName(groupId: string): string {
return `${ANCHOR_PREFIX}${groupId}`
const encoded = Array.from(groupId, (char) => char.codePointAt(0)?.toString(16) ?? '').join('-')
return `${ANCHOR_PREFIX}${encoded || 'empty'}`
}

View File

@ -90,6 +90,7 @@ type StoreState = {
type ConnectCallbacks = {
onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void
onReplayData?: (data: string) => void
onError?: (msg: string) => void
}
@ -299,6 +300,7 @@ function createManager(paneCount = 1) {
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
getPanes: vi.fn(() =>
Array.from({ length: paneCount }, (_, index) => ({
id: index + 1,
@ -4027,6 +4029,42 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('rebuilds WebGL after remote buffered replay arrives on an already-open pane', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const transport = createMockTransport('remote:env-1@@terminal-1')
const capturedReplayCallback: {
current: ((data: string) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedReplayCallback.current = callbacks.onReplayData ?? null
return { id: 'remote:env-1@@terminal-1', replay: '' }
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
const refresh = vi.fn()
const terminal = pane.terminal as typeof pane.terminal & {
_core?: { refresh: typeof refresh }
}
terminal._core = { refresh }
terminal.write = vi.fn((_data: string, callback?: () => void) => {
callback?.()
})
const manager = createManager(1)
const deps = createDeps()
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
capturedReplayCallback.current?.('remote prompt\r\n$ ')
await flushAsyncTicks(6)
expect(pane.terminal.write).toHaveBeenCalledWith('remote prompt\r\n$ ', expect.any(Function))
expect(refresh).toHaveBeenCalledWith(0, 39, true)
expect(manager.rebuildPaneWebgl).toHaveBeenCalledWith(1)
disposable.dispose()
})
it('does not switch renderers for Arabic output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()

View File

@ -17,7 +17,7 @@ import type { PtyConnectionDeps } from './pty-connection-types'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
import { isPaneReplaying, replayIntoTerminal } from './replay-guard'
import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard'
import { terminalOutputPrefersRenderRefresh } from '@/lib/pane-manager/terminal-complex-script'
import {
PANE_PTY_RESIZE_HOLD_FLUSH_EVENT,
@ -1844,13 +1844,29 @@ export function connectPanePty(
replayIntoTerminal(pane, deps.replayingPanesRef, data)
}
const writeReplayDataAsync = (data: string): Promise<void> => {
// Why: WebGL must be rebuilt after xterm has parsed replay bytes, not
// merely after the write was queued.
flushTerminalOutput(pane.terminal)
return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data)
}
const replayDataCallback = (data: string): void => {
// Relay replay buffer holds the last 100 KB of output, which may
// overlap with content already rendered in xterm before the
// disconnect. Clear first to prevent duplication on SSH reconnect.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(data)
writeReplayData(POST_REPLAY_REATTACH_RESET)
void (async () => {
// Relay replay buffer holds the last 100 KB of output, which may
// overlap with content already rendered in xterm before the
// disconnect. Clear first to prevent duplication on SSH reconnect.
await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H')
await writeReplayDataAsync(data)
await writeReplayDataAsync(POST_REPLAY_REATTACH_RESET)
if (disposed) {
return
}
// Why: remote-runtime snapshots can arrive after WebGL attached to an
// empty buffer; rebuilding after replay parses seeds the glyph atlas
// from the now-populated xterm state.
manager.rebuildPaneWebgl(pane.id)
})()
}
type PendingHiddenOutputRestoreChunk = {

View File

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import {
resolveTerminalGpuAccelerationForRuntime,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
@ -125,6 +126,24 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
})
})
describe('resolveTerminalGpuAccelerationForRuntime', () => {
it('forces DOM rendering while a remote runtime is active', () => {
expect(resolveTerminalGpuAccelerationForRuntime('env-1', 'on')).toBe('off')
})
it('honors the user GPU setting for local terminals', () => {
expect(resolveTerminalGpuAccelerationForRuntime(null, 'on')).toBe('on')
})
it('defaults local terminals to auto when the setting is missing', () => {
expect(resolveTerminalGpuAccelerationForRuntime(null, undefined)).toBe('auto')
})
it('treats blank runtime ids as local', () => {
expect(resolveTerminalGpuAccelerationForRuntime(' ', 'auto')).toBe('auto')
})
})
describe('suppressIntentionalPaneCloseExit', () => {
it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => {
const suppressPtyExit = vi.fn()

View File

@ -310,6 +310,16 @@ export function shouldDetachPaneTransportOnUnmount(args: {
)
}
export function resolveTerminalGpuAccelerationForRuntime(
activeRuntimeEnvironmentId: GlobalSettings['activeRuntimeEnvironmentId'] | null | undefined,
terminalGpuAcceleration: GlobalSettings['terminalGpuAcceleration'] | null | undefined
): GlobalSettings['terminalGpuAcceleration'] {
if (activeRuntimeEnvironmentId?.trim()) {
return 'off'
}
return terminalGpuAcceleration ?? 'auto'
}
export function useTerminalPaneLifecycle({
tabId,
worktreeId,
@ -1048,7 +1058,12 @@ export function useTerminalPaneLifecycle({
// so PTYs survive navigation. Creating WebGL for those offscreen panes
// still consumes Chromium's context budget and can blank visible panes.
initialRenderingSuspended: !isVisibleRef.current,
terminalGpuAcceleration: settingsRef.current?.terminalGpuAcceleration ?? 'auto',
// Why: remote-runtime snapshots arrive after pane open; WebGL can hold an
// empty atlas/canvas while the server-side buffer is already populated.
terminalGpuAcceleration: resolveTerminalGpuAccelerationForRuntime(
settingsRef.current?.activeRuntimeEnvironmentId,
settingsRef.current?.terminalGpuAcceleration
),
debugLabel: `tab:${tabId}/wt:${worktreeId}`
})
@ -1367,8 +1382,15 @@ export function useTerminalPaneLifecycle({
}, [settings, systemPrefersDark, effectiveMacOptionAsAlt])
useEffect(() => {
managerRef.current?.setTerminalGpuAcceleration(settings?.terminalGpuAcceleration ?? 'auto')
}, [settings?.terminalGpuAcceleration, managerRef])
// Why: remote-runtime panes stay on DOM rendering; local panes still honor
// the user's GPU setting and can switch live when settings change.
managerRef.current?.setTerminalGpuAcceleration(
resolveTerminalGpuAccelerationForRuntime(
settings?.activeRuntimeEnvironmentId,
settings?.terminalGpuAcceleration
)
)
}, [settings?.activeRuntimeEnvironmentId, settings?.terminalGpuAcceleration, managerRef])
useEffect(() => {
const manager = managerRef.current

View File

@ -27,6 +27,7 @@ import {
} from './pane-tree-ops'
import { toPublicPane } from './pane-public-view'
import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
import { rebuildAttachedWebgl } from './pane-webgl-reattach'
import {
markPaneComplexScriptOutput,
resumePaneRendering,
@ -259,6 +260,14 @@ export class PaneManager {
markPaneComplexScriptOutput(this.panes, paneId)
}
rebuildPaneWebgl(paneId: number): void {
const pane = this.panes.get(paneId)
if (!pane) {
return
}
rebuildAttachedWebgl(pane)
}
suspendRendering(): void {
this.renderingSuspended = true
suspendPaneRendering(this.panes.values())

View File

@ -1,8 +1,16 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { attachWebgl } from './pane-webgl-renderer'
import { attachWebgl, disposeWebgl } from './pane-webgl-renderer'
export function reattachWebglIfNeeded(pane: ManagedPaneInternal): void {
if (pane.gpuRenderingEnabled && !pane.webglAddon && !pane.webglDisabledAfterContextLoss) {
attachWebgl(pane)
}
}
export function rebuildAttachedWebgl(pane: ManagedPaneInternal): void {
if (!pane.webglAddon || pane.webglDisabledAfterContextLoss) {
return
}
disposeWebgl(pane)
attachWebgl(pane)
}