From 699bcf64786cd64a8a07eab525a3e7f40957d9ef Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:20:25 -0400 Subject: [PATCH] Fix remote runtime terminal rendering (#4946) Co-authored-by: Orca --- .../tab-group/tab-group-body-anchor.test.ts | 20 ++++++++++ .../tab-group/tab-group-body-anchor.ts | 7 ++-- .../terminal-pane/pty-connection.test.ts | 38 +++++++++++++++++++ .../terminal-pane/pty-connection.ts | 30 +++++++++++---- .../use-terminal-pane-lifecycle.test.ts | 19 ++++++++++ .../use-terminal-pane-lifecycle.ts | 28 ++++++++++++-- .../src/lib/pane-manager/pane-manager.ts | 9 +++++ .../lib/pane-manager/pane-webgl-reattach.ts | 10 ++++- 8 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts diff --git a/src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts b/src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts new file mode 100644 index 000000000..621abdc84 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-body-anchor.test.ts @@ -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-]+$/) + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-group-body-anchor.ts b/src/renderer/src/components/tab-group/tab-group-body-anchor.ts index 9f06f10e0..249575a38 100644 --- a/src/renderer/src/components/tab-group/tab-group-body-anchor.ts +++ b/src/renderer/src/components/tab-group/tab-group-body-anchor.ts @@ -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 - * ``; groupIds are UUIDs (hex + `-`) so they are already safe - * as suffixes. Prefixed so they cannot collide with unrelated anchors. + * ``; 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'}` } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index b277b7a11..c7da1a731 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1a67063d3..8abacd50e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 => { + // 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 = { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 8c7400939..8aee66514 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 66679e18b..ecb8e7827 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 457272c3c..41267fcab 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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()) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts index 21a839fce..a975a66f5 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-reattach.ts @@ -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) +}