mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): retain terminal reading position after resume (#31661)
Preserve terminal reading position on foreground and reconnect by reusing the existing scroll-preservation option. Adapted from community PR #23401 by Shahar Mor (@shaharmor), with native iOS before/after proof and 38 focused regression tests. Co-authored-by: Shahar Mor <shaharmor1@gmail.com>
This commit is contained in:
@@ -1,4 +1,16 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { createElement } from 'react'
|
||||
import { createTerminalDocumentScope } from '../terminal/document/document-scope'
|
||||
import {
|
||||
startTerminalDocument,
|
||||
stopTerminalDocument
|
||||
} from '../terminal/document/create-terminal-document'
|
||||
import { handleMsg } from '../terminal/document/host-message-router'
|
||||
import type { TerminalDocumentTerminal } from '../terminal/document/document-terminal-shape'
|
||||
import { TERMINAL_DOCUMENT_MARKUP } from '../terminal/terminal-webview-html/document-markup'
|
||||
import { recoverActiveTerminalAfterForeground } from '../terminal/terminal-foreground-recovery'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
|
||||
@@ -368,3 +380,89 @@ describe('a stream terminating while subscribe is still returning', () => {
|
||||
expect(h.fields.signalTerminalInventoryRecovery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('foreground snapshots preserve the terminal reading position', () => {
|
||||
it.each(['connected', 'reconnecting'] as const)(
|
||||
'restores the reading row after recovery from %s',
|
||||
async (connectionState) => {
|
||||
const h = subscriptionHarness({ fit: PHONE, webReady: true, viewport: PHONE })
|
||||
document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP
|
||||
let ready = Promise.resolve()
|
||||
let resolveReady = () => {}
|
||||
const scope = createTerminalDocumentScope({
|
||||
installHostTransport: () => () => {},
|
||||
hasEngine: () => true,
|
||||
createTerminal: (options) => {
|
||||
const terminal = new Terminal(options)
|
||||
terminal.open = () => {}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm implements the document API; its options are initialized by the document.
|
||||
return terminal as unknown as TerminalDocumentTerminal
|
||||
},
|
||||
createWebglAddon: () => null,
|
||||
createUnicode11Addon: () => null,
|
||||
postToHost: (message) => {
|
||||
if (message.type === 'ready') {
|
||||
resolveReady()
|
||||
}
|
||||
}
|
||||
})
|
||||
startTerminalDocument(scope)
|
||||
try {
|
||||
vi.mocked(h.terminal.init).mockImplementation((options) => {
|
||||
ready = new Promise((resolve) => {
|
||||
resolveReady = resolve
|
||||
})
|
||||
handleMsg(scope, { type: 'init', ...options })
|
||||
})
|
||||
const snapshot = Array.from({ length: 200 }, (_, row) => `LINE_${row}`).join('\r\n')
|
||||
const replay = async (serialized: unknown = snapshot) => {
|
||||
h.event(h.fields.client.subscribe.mock.calls.length - 1, {
|
||||
type: 'scrollback',
|
||||
cols: PHONE.cols,
|
||||
rows: PHONE.rows,
|
||||
serialized
|
||||
})
|
||||
await ready
|
||||
}
|
||||
h.subscribe()
|
||||
await replay()
|
||||
expect(scope.term!.buffer.active.viewportY).toBe(scope.term!.buffer.active.baseY)
|
||||
scope.term!.scrollToLine(40)
|
||||
const readingRow = scope.term!.buffer.active.getLine(40)?.translateToString(true)
|
||||
const connStateRef: { current: ConnectionState } = { current: connectionState }
|
||||
const scheduled: Array<() => void> = []
|
||||
const recover = () =>
|
||||
recoverActiveTerminalAfterForeground({
|
||||
...h.fields,
|
||||
connStateRef,
|
||||
subscribeToTerminal: h.subscribe,
|
||||
schedule: (callback) => scheduled.push(callback)
|
||||
})
|
||||
if (connectionState === 'reconnecting') {
|
||||
expect(recover()).toBe('deferred')
|
||||
connStateRef.current = 'connected'
|
||||
}
|
||||
expect(recover()).toBe('recovered')
|
||||
scheduled.splice(0).forEach((callback) => callback())
|
||||
await replay()
|
||||
expect(scope.term!.buffer.active.viewportY).toBe(40)
|
||||
expect(scope.term!.buffer.active.getLine(40)?.translateToString(true)).toBe(readingRow)
|
||||
|
||||
scope.term!.scrollToBottom()
|
||||
recover()
|
||||
scheduled.splice(0).forEach((callback) => callback())
|
||||
await replay()
|
||||
expect(scope.term!.buffer.active.viewportY).toBe(scope.term!.buffer.active.baseY)
|
||||
|
||||
scope.term!.scrollToLine(40)
|
||||
recover()
|
||||
scheduled.splice(0).forEach((callback) => callback())
|
||||
await replay('')
|
||||
expect(scope.term!.buffer.active.viewportY).toBe(0)
|
||||
expect(scope.term!.buffer.active.baseY).toBe(0)
|
||||
} finally {
|
||||
stopTerminalDocument(scope)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -192,10 +192,7 @@ export function useMobileSessionTerminalSubscription(
|
||||
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
|
||||
const cols = hostCols ?? viewportRef.current?.cols ?? 80
|
||||
const rows = hostRows ?? viewportRef.current?.rows ?? 24
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
: ''
|
||||
const initialData = typeof data.serialized === 'string' ? data.serialized : ''
|
||||
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
|
||||
const ref = getTerminalRef(handle)
|
||||
// Why: only mark initialized once init() reaches the WebView, else later scrollback is dropped and the terminal stays blank.
|
||||
@@ -207,7 +204,8 @@ export function useMobileSessionTerminalSubscription(
|
||||
})
|
||||
return
|
||||
}
|
||||
ref.init({ cols, rows, initialData, oscLinks, frame: terminalFrameRef.current })
|
||||
const frame = terminalFrameRef.current
|
||||
ref.init({ cols, rows, initialData, preserveScroll: true, oscLinks, frame })
|
||||
initializedHandlesRef.current.add(handle)
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
|
||||
Reference in New Issue
Block a user