Fix remote runtime terminal rendering (#4946)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4660a25f09
commit
699bcf6478
|
|
@ -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-]+$/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue