mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
perf(terminal): stop shipping every agent spinner title frame to the renderer
Main re-asserts a working OSC title per pane every 80ms (12.5/sec) while an agent works, and every frame became its own pty:sideEffect IPC message. Both renderer store writes already discard those frames via isDecorativeAgentTitleFrameChange, and paired remote clients already never see them (RuntimeClientEventBus's per-listener title gate). Only the local desktop renderer was still paying for them. Apply the same decorative gate main already computes for the mobile fan-out one hop earlier, keeping a 500ms heartbeat so the renderer's 1500ms hook-done quiet window still sees a working title and can cancel a Pi/OMP milestone 'done'.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DECORATIVE_TITLE_FACT_HEARTBEAT_MS,
|
||||
shouldEmitTitleFactForFrame
|
||||
} from './decorative-title-fact-emission'
|
||||
|
||||
const base = {
|
||||
decorativeOnly: true,
|
||||
staleWorkingTitleClear: false,
|
||||
lastEmittedAtMs: 1_000,
|
||||
nowMs: 1_000
|
||||
}
|
||||
|
||||
describe('shouldEmitTitleFactForFrame', () => {
|
||||
it('always emits a frame that is not a decorative repeat', () => {
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('emits the first frame of a pane', () => {
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses a decorative repeat inside the heartbeat window', () => {
|
||||
expect(
|
||||
shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a decorative repeat through once the heartbeat window elapses', () => {
|
||||
expect(
|
||||
shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('never throttles a timer-synthesized stale-working clear', () => {
|
||||
// Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for.
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('emits after a backwards clock step instead of parking until it catches up', () => {
|
||||
expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps at least three frames inside the renderer hook-done quiet window', () => {
|
||||
// Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done`
|
||||
// scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion.
|
||||
expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own
|
||||
* synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer
|
||||
* as its own `pty:sideEffect` message. Both renderer store writes already discard those frames
|
||||
* via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost.
|
||||
*
|
||||
* Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an
|
||||
* arriving *working* title as "still working" and cancels a scheduled hook-`done` completion
|
||||
* inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted
|
||||
* mid-turn is stopped from minting a completion notification, and the frames that carry it are
|
||||
* decorative repeats. 500ms keeps 3 frames inside that window.
|
||||
*/
|
||||
export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500
|
||||
|
||||
export type DecorativeTitleFactEmissionInput = {
|
||||
/** The frame's decorative gate key matches the previous frame's. */
|
||||
decorativeOnly: boolean
|
||||
/** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */
|
||||
staleWorkingTitleClear: boolean
|
||||
lastEmittedAtMs: number | null
|
||||
nowMs: number
|
||||
}
|
||||
|
||||
export function shouldEmitTitleFactForFrame({
|
||||
decorativeOnly,
|
||||
staleWorkingTitleClear,
|
||||
lastEmittedAtMs,
|
||||
nowMs
|
||||
}: DecorativeTitleFactEmissionInput): boolean {
|
||||
if (!decorativeOnly || staleWorkingTitleClear) {
|
||||
return true
|
||||
}
|
||||
if (lastEmittedAtMs === null) {
|
||||
return true
|
||||
}
|
||||
// A backwards clock step must not park the heartbeat until it catches up.
|
||||
return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact'
|
||||
import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature'
|
||||
import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission'
|
||||
import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records'
|
||||
import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects'
|
||||
import { detectAgentStatusFromTitle } from '../../shared/agent-detection'
|
||||
@@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
|
||||
const tracker = createTerminalTitleTracker(
|
||||
{
|
||||
onTitle: (normalizedTitle, rawTitle, meta) => {
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'title',
|
||||
normalizedTitle,
|
||||
rawTitle,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle)
|
||||
const decorativeOnly = live?.lastMobileTitleGateKey === gateKey
|
||||
if (live) {
|
||||
live.lastMobileTitleGateKey = gateKey
|
||||
}
|
||||
// Why: the same gate the mobile fan-out below already uses, applied one hop earlier —
|
||||
// a spinner frame the renderer store discards should not cost a pty:sideEffect message
|
||||
// at all. See decorative-title-fact-emission.ts for why repeats still heartbeat.
|
||||
const nowMs = Date.now()
|
||||
if (
|
||||
shouldEmitTitleFactForFrame({
|
||||
decorativeOnly,
|
||||
staleWorkingTitleClear: meta?.staleWorkingTitleClear === true,
|
||||
lastEmittedAtMs: live?.lastTitleFactAtMs ?? null,
|
||||
nowMs
|
||||
})
|
||||
) {
|
||||
if (live) {
|
||||
live.lastTitleFactAtMs = nowMs
|
||||
}
|
||||
this.recordTerminalSideEffectFact(ptyId, {
|
||||
kind: 'title',
|
||||
normalizedTitle,
|
||||
rawTitle,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
}
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const tracksReplicatedStatus =
|
||||
live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0
|
||||
const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null
|
||||
@@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime
|
||||
tracker,
|
||||
applyingChunk: false,
|
||||
lastMobileTitleGateKey: null,
|
||||
lastTitleFactAtMs: null,
|
||||
chunkTouchedSessionTabs: false,
|
||||
pendingFacts: [],
|
||||
// Why: command-code facts exist only for the pty:sideEffect channel —
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts'
|
||||
import { syncSinglePty } from '../orca-runtime-test-fixtures.spec'
|
||||
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
|
||||
import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
|
||||
|
||||
// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works.
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
const SPINNER_INTERVAL_MS = 80
|
||||
const EPOCH = 1_700_000_000_000
|
||||
|
||||
type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string }
|
||||
|
||||
function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] {
|
||||
return batches.flatMap((batch) =>
|
||||
batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title')
|
||||
)
|
||||
}
|
||||
|
||||
describe('decorative title fact throttle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date(EPOCH))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer
|
||||
for (let tick = 0; tick < ticks; tick += 1) {
|
||||
vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame(
|
||||
'pty-1',
|
||||
`\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07`
|
||||
)
|
||||
}
|
||||
|
||||
const facts = titleFacts(batches)
|
||||
// Every frame carried the same underlying title, so the renderer learns nothing new past
|
||||
// the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window.
|
||||
const elapsedMs = ticks * SPINNER_INTERVAL_MS
|
||||
expect(facts.length).toBeLessThanOrEqual(
|
||||
Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS)
|
||||
)
|
||||
expect(facts.length).toBeLessThan(ticks / 5)
|
||||
// The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window
|
||||
// needs to cancel a milestone `done` — three working frames per window.
|
||||
expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3)
|
||||
for (const fact of facts) {
|
||||
expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates a real title change on the tick it arrives, mid-heartbeat', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
|
||||
// Two more decorative ticks — still well inside the heartbeat window, so they are dropped.
|
||||
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
|
||||
vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07')
|
||||
expect(titleFacts(batches)).toHaveLength(1)
|
||||
|
||||
const beforeChange = batches.length
|
||||
vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07')
|
||||
|
||||
expect(batches.length).toBeGreaterThan(beforeChange)
|
||||
expect(titleFacts(batches.slice(beforeChange))).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' }
|
||||
])
|
||||
})
|
||||
|
||||
it('propagates a changed working label immediately even while the spinner rotates', () => {
|
||||
// Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content.
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
|
||||
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07')
|
||||
|
||||
expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([
|
||||
'⠋ Claude Code',
|
||||
'⠙ Reviewing diff — Claude Code'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps main-side tracked title state current for every suppressed frame', () => {
|
||||
// Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing
|
||||
// the fact must not freeze what a phone or a paired client is shown.
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07')
|
||||
vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS))
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07')
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
syncSinglePty
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec'
|
||||
import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission'
|
||||
|
||||
describe('terminal side-effect fact channel', () => {
|
||||
it('defers desktop-only output scanners until a headless runtime is promoted', () => {
|
||||
@@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => {
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds decorative title delivery per paired client without reducing local frames', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const firstClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.onClientEvent((event) => firstClientEvents.push(event))
|
||||
it('bounds decorative title delivery per paired client below the local heartbeat', () => {
|
||||
// Why the clock steps: main throttles decorative repeats on the local fact stream, so each
|
||||
// round must clear that heartbeat for the per-client gate to be what collapses them here.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
try {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const firstClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.onClientEvent((event) => firstClientEvents.push(event))
|
||||
|
||||
const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
firstClientEvents.length = 0
|
||||
|
||||
for (const frame of frames.slice(1)) {
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`)
|
||||
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
const stepPastHeartbeat = (): void => {
|
||||
vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS))
|
||||
}
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
firstClientEvents.length = 0
|
||||
|
||||
for (const frame of frames.slice(1)) {
|
||||
stepPastHeartbeat()
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(batches).toHaveLength(ptyIds.length * frames.length)
|
||||
|
||||
const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
|
||||
runtime.onPtyData(ptyIds[0], bellChunk, 1)
|
||||
expect(firstClientEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminalSideEffects',
|
||||
batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
|
||||
})
|
||||
])
|
||||
firstClientEvents.length = 0
|
||||
|
||||
const secondClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.onClientEvent((event) => secondClientEvents.push(event))
|
||||
stepPastHeartbeat()
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length)
|
||||
|
||||
// A real title change is never throttled — no clock step needed.
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
|
||||
}
|
||||
expect(firstClientEvents).toHaveLength(ptyIds.length)
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(batches).toHaveLength(ptyIds.length * frames.length)
|
||||
|
||||
const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07`
|
||||
runtime.onPtyData(ptyIds[0], bellChunk, 1)
|
||||
expect(firstClientEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminalSideEffects',
|
||||
batch: expect.objectContaining({ facts: [{ kind: 'bell' }] })
|
||||
})
|
||||
])
|
||||
firstClientEvents.length = 0
|
||||
|
||||
const secondClientEvents: RuntimeClientEvent[] = []
|
||||
runtime.onClientEvent((event) => secondClientEvents.push(event))
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`)
|
||||
}
|
||||
|
||||
expect(firstClientEvents).toEqual([])
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length)
|
||||
|
||||
for (const ptyId of ptyIds) {
|
||||
runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07')
|
||||
}
|
||||
expect(firstClientEvents).toHaveLength(ptyIds.length)
|
||||
expect(secondClientEvents).toHaveLength(ptyIds.length * 2)
|
||||
})
|
||||
|
||||
it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec')
|
||||
await import('./orca-runtime-tests/terminal-side-effect-facts.spec')
|
||||
await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec')
|
||||
await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec')
|
||||
await import('./orca-runtime-tests/decorative-title-fact-throttle.spec')
|
||||
await import('./orca-runtime-tests/headless-snapshots.spec')
|
||||
await import('./orca-runtime-tests/headless-snapshots-part-02.spec')
|
||||
await import('./orca-runtime-tests/agent-status-and-waits.spec')
|
||||
|
||||
@@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = {
|
||||
tracker: TerminalTitleTracker
|
||||
applyingChunk: boolean
|
||||
lastMobileTitleGateKey: string | null
|
||||
/** When the last title fact was emitted — throttles decorative-only repeats. */
|
||||
lastTitleFactAtMs: number | null
|
||||
chunkTouchedSessionTabs: boolean
|
||||
pendingFacts: TerminalSideEffectFact[]
|
||||
commandCodeDetector: { observe: (data: string) => boolean } | null
|
||||
|
||||
Reference in New Issue
Block a user