mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(terminal): restore remote replay grid on reveal
This commit is contained in:
@@ -495,6 +495,10 @@ function createPaneContainer(): HTMLElement {
|
||||
configurable: true,
|
||||
value: {}
|
||||
})
|
||||
Object.defineProperties(container, {
|
||||
clientWidth: { configurable: true, value: 1200 },
|
||||
clientHeight: { configurable: true, value: 800 }
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
@@ -17244,6 +17248,57 @@ describe('connectPanePty', () => {
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('paints remote replay at its snapshot grid before returning to the live pane grid', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
const transport = createMockTransport('remote:env-1@@terminal-grid')
|
||||
const capturedReplayCallback: {
|
||||
current:
|
||||
| ((data: string, meta?: { snapshotCols?: number; snapshotRows?: number }) => void)
|
||||
| null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedReplayCallback.current = callbacks.onReplayData ?? null
|
||||
return { id: 'remote:env-1@@terminal-grid', replay: '' }
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const operations: string[] = []
|
||||
pane.terminal.resize = vi.fn((cols: number, rows: number) => {
|
||||
operations.push(`resize:${cols}x${rows}`)
|
||||
pane.terminal.cols = cols
|
||||
pane.terminal.rows = rows
|
||||
})
|
||||
pane.fitAddon.fit = vi.fn(() => pane.terminal.resize(180, 50))
|
||||
pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 180, rows: 50 }))
|
||||
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
|
||||
operations.push(`write:${data}`)
|
||||
callback?.()
|
||||
}) as typeof pane.terminal.write
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps()
|
||||
const disposable = connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
capturedReplayCallback.current?.('snapshot payload', {
|
||||
snapshotCols: 60,
|
||||
snapshotRows: 20
|
||||
})
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(operations).toContain('resize:60x20')
|
||||
expect(operations.indexOf('resize:60x20')).toBeLessThan(
|
||||
operations.indexOf('write:snapshot payload')
|
||||
)
|
||||
expect(operations.lastIndexOf('resize:180x50')).toBeGreaterThan(
|
||||
operations.indexOf('write:snapshot payload')
|
||||
)
|
||||
expect(pane.terminal.cols).toBe(180)
|
||||
expect(pane.terminal.rows).toBe(50)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
enableActiveRuntimeEnvironment()
|
||||
@@ -25689,7 +25744,7 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips remote-runtime PTYs (their size lives outside the local ptySizes map)', async () => {
|
||||
it('re-fits and re-reports a remote viewport on reveal even when its grid is unchanged', async () => {
|
||||
const getSize = vi.mocked(window.api.pty.getSize)
|
||||
getSize.mockClear()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
@@ -25702,17 +25757,68 @@ describe('connectPanePty', () => {
|
||||
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
|
||||
})
|
||||
const pane = createPane(2)
|
||||
pane.fitAddon.fit = vi.fn()
|
||||
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
|
||||
noteVisibilityResume: () => void
|
||||
}
|
||||
transport.resize.mockClear()
|
||||
pane.fitAddon.proposeDimensions.mockClear()
|
||||
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks()
|
||||
|
||||
// Never even queries size for a remote pane, and never re-asserts.
|
||||
expect(getSize).not.toHaveBeenCalled()
|
||||
expect(pane.fitAddon.proposeDimensions).toHaveBeenCalled()
|
||||
expect(transport.resize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
|
||||
it('does not fit or report a remote viewport from a zero-sized reveal box', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('remote:env-1@@terminal-zero-box')
|
||||
const capturedReplayCallback: {
|
||||
current:
|
||||
| ((data: string, meta?: { snapshotCols?: number; snapshotRows?: number }) => void)
|
||||
| null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedReplayCallback.current = callbacks.onReplayData ?? null
|
||||
return { id: 'remote:env-1@@terminal-zero-box', replay: '' }
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(2)
|
||||
Object.defineProperties(pane.container, {
|
||||
clientWidth: { configurable: true, value: 0 },
|
||||
clientHeight: { configurable: true, value: 0 }
|
||||
})
|
||||
pane.fitAddon.fit = vi.fn()
|
||||
const manager = createManager(2)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_2,
|
||||
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
|
||||
})
|
||||
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
|
||||
noteVisibilityResume: () => void
|
||||
}
|
||||
transport.resize.mockClear()
|
||||
pane.fitAddon.proposeDimensions.mockClear()
|
||||
pane.terminal.write.mockClear()
|
||||
|
||||
capturedReplayCallback.current?.('must wait for a box', {
|
||||
snapshotCols: 60,
|
||||
snapshotRows: 20
|
||||
})
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(
|
||||
'must wait for a box',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(transport.resize).not.toHaveBeenCalled()
|
||||
expect(transport.claimViewport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => {
|
||||
|
||||
@@ -1136,6 +1136,7 @@ export function connectPanePty(
|
||||
let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {}
|
||||
let cleanupStartupDraftPasteTimers = (): void => {}
|
||||
let unregisterE2ePtyDataInjection = (): void => {}
|
||||
let resumePendingReplayDataDrain = (): void => {}
|
||||
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -4349,6 +4350,28 @@ export function connectPanePty(
|
||||
}
|
||||
}
|
||||
}
|
||||
const reportRemoteViewportOnReveal = (): void => {
|
||||
const revealedPtyId = transport.getPtyId()
|
||||
if (
|
||||
!revealedPtyId ||
|
||||
!isRemoteRuntimePtyId(revealedPtyId) ||
|
||||
pane.container.clientWidth <= 0 ||
|
||||
pane.container.clientHeight <= 0 ||
|
||||
shouldSuppressDesktopPtyResize()
|
||||
) {
|
||||
return
|
||||
}
|
||||
safeFitAndThen(pane, 'remote-reveal-viewport', () => {
|
||||
if (disposed || transport.getPtyId() !== revealedPtyId || shouldSuppressDesktopPtyResize()) {
|
||||
return
|
||||
}
|
||||
const cols = pane.terminal.cols
|
||||
const rows = pane.terminal.rows
|
||||
if (Number.isFinite(cols) && Number.isFinite(rows) && cols > 0 && rows > 0) {
|
||||
transport.resize(cols, rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
let pendingForegroundGridDriftCheckRaf: number | null = null
|
||||
let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY
|
||||
const readProposedTerminalGrid = (): { cols: number; rows: number } | null => {
|
||||
@@ -4422,6 +4445,9 @@ export function connectPanePty(
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (pane.container.clientWidth > 0 && pane.container.clientHeight > 0) {
|
||||
resumePendingReplayDataDrain()
|
||||
}
|
||||
if (
|
||||
deferTerminalGeometryMutationDuringRebuild(
|
||||
pane.terminal,
|
||||
@@ -5608,6 +5634,8 @@ export function connectPanePty(
|
||||
type PendingReplayData = {
|
||||
data: string
|
||||
clearBeforeReplay: boolean
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
ptyId: string | null
|
||||
generation: number
|
||||
streamGeneration: number
|
||||
@@ -5658,6 +5686,22 @@ export function connectPanePty(
|
||||
continue
|
||||
}
|
||||
}
|
||||
const snapshotDimensions = resolvePositiveTerminalDimensions(
|
||||
payload.snapshotCols,
|
||||
payload.snapshotRows
|
||||
)
|
||||
if (
|
||||
snapshotDimensions &&
|
||||
(pane.terminal.cols !== snapshotDimensions.cols ||
|
||||
pane.terminal.rows !== snapshotDimensions.rows)
|
||||
) {
|
||||
suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows)
|
||||
} finally {
|
||||
suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
if (clearBeforeReplay || data.length > 0) {
|
||||
// Why: an empty clearing frame is still an authoritative repaint and
|
||||
// must clear a stale agent signal from an earlier payload.
|
||||
@@ -5699,7 +5743,13 @@ export function connectPanePty(
|
||||
return appliedCurrentPayload
|
||||
}
|
||||
const scheduleReplayDataDrain = (): void => {
|
||||
if (replayDrainQueued) {
|
||||
if (
|
||||
replayDrainQueued ||
|
||||
pendingReplayData === null ||
|
||||
pane.container.clientWidth <= 0 ||
|
||||
pane.container.clientHeight <= 0 ||
|
||||
!safeFit(pane)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const scheduledPtyId = pendingReplayData?.ptyId ?? null
|
||||
@@ -5724,7 +5774,12 @@ export function connectPanePty(
|
||||
shouldRestore: () =>
|
||||
!disposed &&
|
||||
transport.getPtyId() === scheduledPtyId &&
|
||||
transportStreamGeneration === scheduledStreamGeneration
|
||||
transportStreamGeneration === scheduledStreamGeneration,
|
||||
afterRestore: () => {
|
||||
if (replayCompleted) {
|
||||
safeFit(pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -5741,6 +5796,7 @@ export function connectPanePty(
|
||||
finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration)
|
||||
})
|
||||
}
|
||||
resumePendingReplayDataDrain = scheduleReplayDataDrain
|
||||
const replayDataCallback = (
|
||||
data: string,
|
||||
meta: PtyReplayDataMeta = {},
|
||||
@@ -5749,6 +5805,8 @@ export function connectPanePty(
|
||||
pendingReplayData = {
|
||||
data,
|
||||
clearBeforeReplay: meta.clearBeforeReplay !== false,
|
||||
...(meta.snapshotCols !== undefined ? { snapshotCols: meta.snapshotCols } : {}),
|
||||
...(meta.snapshotRows !== undefined ? { snapshotRows: meta.snapshotRows } : {}),
|
||||
ptyId: transport.getPtyId(),
|
||||
generation: (replayPayloadGeneration += 1),
|
||||
streamGeneration,
|
||||
@@ -9283,6 +9341,8 @@ export function connectPanePty(
|
||||
noteVisibilityResume() {
|
||||
armVisibleRemoteViewportClaim()
|
||||
claimPendingVisibleRemoteViewport()
|
||||
reportRemoteViewportOnReveal()
|
||||
resumePendingReplayDataDrain()
|
||||
ptySizeReassertion.request({ fit: false })
|
||||
consumeHibernatedAgentWake()
|
||||
requestKnownWindowsShiftEnterReconfirmation()
|
||||
|
||||
@@ -49,6 +49,10 @@ export type PtyBufferSnapshot = {
|
||||
/** Metadata for one authoritative replay payload. */
|
||||
export type PtyReplayDataMeta = {
|
||||
clearBeforeReplay?: boolean
|
||||
/** Source grid for this replay payload. Zero means the producer could not
|
||||
* determine a size and must never reach xterm.resize(). */
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Kitty flags the snapshot's owner PROVED at `snapshotSeq`. Absent means
|
||||
* unknown; the pane tracker must stay unproven rather than assume zero. */
|
||||
|
||||
@@ -103,6 +103,8 @@ type ProcessPtyOutputOptions = {
|
||||
replayingBufferedData?: boolean
|
||||
suppressAttentionEvents?: boolean
|
||||
clearBeforeReplay?: boolean
|
||||
snapshotCols?: number
|
||||
snapshotRows?: number
|
||||
// Why: a mid-escape tail; the replay consumer writes it LAST (after the post-replay reset) so the next live chunk completes it, not renders it literally (#7329).
|
||||
pendingEscapeTailAnsi?: string
|
||||
/** Kitty flags the snapshot owner proved at `snapshotSeq`. */
|
||||
@@ -498,6 +500,8 @@ export function createPtyOutputProcessor({
|
||||
if (options.replayingBufferedData && callbacks.onReplayData) {
|
||||
const replayMeta = {
|
||||
...(options.clearBeforeReplay === false ? { clearBeforeReplay: false } : {}),
|
||||
...(options.snapshotCols !== undefined ? { snapshotCols: options.snapshotCols } : {}),
|
||||
...(options.snapshotRows !== undefined ? { snapshotRows: options.snapshotRows } : {}),
|
||||
...(options.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: options.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
|
||||
@@ -6099,6 +6099,32 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
expect(onConnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards the source grid with an initial replay payload', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
|
||||
|
||||
await transport.connect({ url: '', callbacks: { onReplayData } })
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotStart,
|
||||
encodeTerminalStreamJson({ kind: 'scrollback', cols: 60, rows: 20 })
|
||||
)
|
||||
emitSnapshotFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.SnapshotChunk,
|
||||
encodeTerminalStreamText('source-grid replay')
|
||||
)
|
||||
emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
|
||||
|
||||
expect(onReplayData).toHaveBeenCalledWith('source-grid replay', {
|
||||
snapshotCols: 60,
|
||||
snapshotRows: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves explicit binary snapshot requests without replaying into xterm', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
|
||||
@@ -1768,6 +1768,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
outputProcessor.processData(data, storedCallbacks, {
|
||||
replayingBufferedData: true,
|
||||
suppressAttentionEvents: true,
|
||||
...(meta?.cols !== undefined ? { snapshotCols: meta.cols } : {}),
|
||||
...(meta?.rows !== undefined ? { snapshotRows: meta.rows } : {}),
|
||||
...(meta?.pendingEscapeTailAnsi
|
||||
? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi }
|
||||
: {}),
|
||||
|
||||
@@ -38,6 +38,7 @@ class ScriptedSnapshotServer {
|
||||
private cursorUnits = 0
|
||||
requestIds: (number | undefined)[] = []
|
||||
nextRequestedReply: RequestedReply = { kind: 'buffer', data: 'MANUAL' }
|
||||
omitNextSize = false
|
||||
dropNextOutput = false
|
||||
holdResyncReplies = false
|
||||
private heldRequestId: number | null = null
|
||||
@@ -112,11 +113,12 @@ class ScriptedSnapshotServer {
|
||||
}
|
||||
|
||||
private sendStart(meta: { requestId?: number; truncated?: boolean; unavailable?: string }): void {
|
||||
const size = this.omitNextSize ? {} : { cols: 80, rows: 24 }
|
||||
this.omitNextSize = false
|
||||
this.send(
|
||||
TerminalStreamOpcode.SnapshotStart,
|
||||
encodeTerminalStreamJson({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...size,
|
||||
seq: meta.truncated ? undefined : this.cursorUnits,
|
||||
...meta
|
||||
})
|
||||
@@ -197,6 +199,16 @@ describe('remote terminal snapshot outcome reasons', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses 0x0 as the unknown-size sentinel for an older snapshot producer', async () => {
|
||||
const stream = await subscribeClient()
|
||||
server.nextRequestedReply = { kind: 'buffer', data: 'LEGACY' }
|
||||
server.omitNextSize = true
|
||||
|
||||
await expect(stream.serializeBufferOutcome({ scrollbackRows: 100 })).resolves.toMatchObject({
|
||||
snapshot: { data: 'LEGACY', cols: 0, rows: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a proven-empty buffer as a snapshot, not as an unavailable reply', async () => {
|
||||
const stream = await subscribeClient()
|
||||
server.nextRequestedReply = { kind: 'buffer', data: '' }
|
||||
|
||||
@@ -64,6 +64,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
|
||||
onSnapshot: (
|
||||
data: string,
|
||||
meta?: {
|
||||
cols?: number
|
||||
rows?: number
|
||||
pendingEscapeTailAnsi?: string
|
||||
seq?: number
|
||||
kittyKeyboardFlags?: number
|
||||
@@ -888,8 +890,8 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
availability: classifySnapshotAvailability(stream.snapshotOverflowed, info),
|
||||
snapshot: {
|
||||
data: data ?? '',
|
||||
cols: info?.cols ?? 80,
|
||||
rows: info?.rows ?? 24,
|
||||
cols: info?.cols ?? 0,
|
||||
rows: info?.rows ?? 0,
|
||||
seq: info?.seq,
|
||||
source: info?.source,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags,
|
||||
@@ -899,6 +901,8 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
clearPendingSnapshotRequest(stream)
|
||||
} else if (target === 'initial') {
|
||||
stream.callbacks.onSnapshot(data ?? '', {
|
||||
cols: info?.cols,
|
||||
rows: info?.rows,
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
|
||||
seq: info?.seq,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags
|
||||
@@ -909,6 +913,8 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
// An empty snapshot is still applied so stale dropped output does
|
||||
// not linger on a terminal the model says is blank.
|
||||
stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, {
|
||||
cols: info?.cols,
|
||||
rows: info?.rows,
|
||||
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
|
||||
seq: info?.seq,
|
||||
kittyKeyboardFlags: info?.kittyKeyboardFlags
|
||||
|
||||
@@ -572,6 +572,8 @@ describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
|
||||
injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state')
|
||||
expect(onSnapshot).toHaveBeenCalledWith('initial state', {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
pendingEscapeTailAnsi: undefined
|
||||
})
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
@@ -590,6 +592,8 @@ describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
// clears screen and scrollback first and must not replay the subscribe
|
||||
// lifecycle.
|
||||
expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
pendingEscapeTailAnsi: undefined
|
||||
})
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
@@ -607,6 +611,8 @@ describe('remote runtime terminal multiplex ACK gate', () => {
|
||||
''
|
||||
)
|
||||
expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
pendingEscapeTailAnsi: undefined
|
||||
})
|
||||
expect(onSubscribed).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -13,7 +13,10 @@ export type RemoteRuntimePtyIdParts = {
|
||||
|
||||
export type RuntimeTerminalDataSubscriptionOptions = {
|
||||
startAtLiveTail?: boolean
|
||||
onSnapshot?: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
|
||||
onSnapshot?: (
|
||||
data: string,
|
||||
meta?: { cols?: number; rows?: number; pendingEscapeTailAnsi?: string }
|
||||
) => void
|
||||
onEnd?: () => void
|
||||
onError?: (message: string) => void
|
||||
onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void
|
||||
|
||||
Reference in New Issue
Block a user