Merge remote-tracking branch 'origin/main' into fix/watcher-termination-safety-followup

This commit is contained in:
Brennan Benson
2026-07-14 22:13:46 -07:00
85 changed files with 7237 additions and 300 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.3m">
<title>downloads: 5.3m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.4m">
<title>downloads: 5.4m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.3m</text>
<text x="93.5" y="14">5.3m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.4m</text>
<text x="93.5" y="14">5.4m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.27",
"version": "0.0.30",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
+4
View File
@@ -178,6 +178,10 @@ export default function RootLayout() {
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen
name="notification-opt-in"
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
+83 -64
View File
@@ -184,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
@@ -197,8 +198,14 @@ import {
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import {
activateMobileSessionTab,
focusMobileTerminal
} from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import {
getRepoIdFromMobileWorktreeId,
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
MOBILE_SESSION_STATUS_LABELS,
@@ -206,7 +213,8 @@ import {
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES,
TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
@@ -245,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryI
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function getActiveTabIdForHandle(
tabs: MobileSessionTab[],
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find(
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === terminalHandle
)?.id ?? terminalHandle
)
}
function MarkdownReader({
documentId,
doc,
@@ -800,21 +793,6 @@ function FileReader({
return renderSourceText(doc.content)
}
function updateTerminalCwdFromStreamEvent(
handle: string,
data: Record<string, unknown>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
export default function SessionScreen() {
const {
hostId,
@@ -1031,6 +1009,7 @@ export default function SessionScreen() {
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews
// (opacity:0) may have delayed JS execution on iOS. We must not subscribe
// until the WebView has fired web-ready, otherwise init() messages queue
@@ -1315,6 +1294,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
@@ -1329,6 +1309,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1350,6 +1331,7 @@ export default function SessionScreen() {
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current)
if (dims) {
viewportRef.current = dims
viewportMeasuredRef.current = true
@@ -1360,25 +1342,34 @@ export default function SessionScreen() {
const subscribeToTerminal = useCallback(
(handle: string) => {
const diagnostics = terminalDiagnosticsRef.current
const logSkippedGate = (reason: string) =>
diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current)
if (!client) {
logSkippedGate('no-client')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
}
if (subscribingHandlesRef.current.has(handle)) {
logSkippedGate('subscribe-in-flight')
return
}
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
}
subscribingHandlesRef.current.add(handle)
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.current)
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
// The viewport is embedded in the subscribe params so the server resizes
@@ -1397,6 +1388,7 @@ export default function SessionScreen() {
return
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
@@ -1429,6 +1421,7 @@ export default function SessionScreen() {
return
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
return
}
@@ -1520,6 +1513,7 @@ export default function SessionScreen() {
// server still has a null viewport for THIS subscriber
// record — we MUST resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
@@ -1564,6 +1558,7 @@ export default function SessionScreen() {
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
if (serialized != null) {
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
@@ -1733,6 +1728,7 @@ export default function SessionScreen() {
const applySessionTabs = useCallback(
(result: SessionTabsResult) => {
const diagnostics = terminalDiagnosticsRef.current
// Reject out-of-order snapshots, then suppress just-closed tabs until the
// publisher confirms their absence. See session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
@@ -1789,15 +1785,21 @@ export default function SessionScreen() {
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
let active = snapshotActive
let selectionSource = 'snapshot'
if (pendingActiveSessionTabId) {
if (snapshotActive?.id === pendingActiveSessionTabId) {
pendingActiveSessionTabIdRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveSessionTabIdRef.current = null
} else {
selectionSource = 'pending-tab-local-ack'
}
} else {
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
if (pendingTab) {
// Why: desktop tab snapshots can lag a mobile tap while activate RPC
// is in flight. Keep the locally selected tab to avoid snapping back.
active = pendingTab
selectionSource = 'pending-tab'
} else {
pendingActiveSessionTabIdRef.current = null
}
@@ -1815,12 +1817,17 @@ export default function SessionScreen() {
snapshotActive?.type === 'terminal' &&
snapshotActive.terminal === pendingActiveTerminalHandle
) {
pendingActiveTerminalHandleRef.current = null
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
pendingActiveTerminalHandleRef.current = null
} else {
selectionSource = 'pending-handle-local-ack'
}
} else if (pendingTerminalTab) {
// Why: desktop active flags can lag a mobile terminal tap. Key by
// terminal handle too, because fallback PTY tabs may not yet have a
// stable session tab id during new-worktree startup.
active = pendingTerminalTab
selectionSource = 'pending-handle-tab'
} else if (pendingTerminalExists) {
const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle)
activeSessionTabIdRef.current = nextActiveTabId
@@ -1833,6 +1840,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
}
}
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
activeSessionTabTypeRef.current = active?.type ?? null
activeSessionTabIdRef.current = active?.id ?? null
setActiveSessionTabId(active?.id ?? null)
@@ -2377,20 +2385,25 @@ export default function SessionScreen() {
const fetchSessionTabs = useCallback(async () => {
if (!client) {
terminalDiagnosticsRef.current.tabsFetchSkipped('no-client')
return
}
if (fetchSessionTabsInFlightRef.current) {
terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight')
return
}
fetchSessionTabsInFlightRef.current = true
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
try {
const response = await client.sendRequest('session.tabs.list', {
worktree: `id:${worktreeId}`
})
if (!response.ok) {
terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code)
return
}
const result = (response as RpcSuccess).result as SessionTabsResult
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
applySessionTabs(result)
// Focus a just-opened browser tab once it appears in the snapshot, via the
// normal activate path so it sticks and the user can still switch away.
@@ -2404,7 +2417,8 @@ export default function SessionScreen() {
switchSessionTabRef.current?.(browserTab)
}
}
} catch {
} catch (error) {
terminalDiagnosticsRef.current.tabsFetchErrored(error)
// Keep the last tab snapshot visible during reconnect/backoff.
} finally {
fetchSessionTabsInFlightRef.current = false
@@ -2689,6 +2703,7 @@ export default function SessionScreen() {
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
@@ -2912,6 +2927,7 @@ export default function SessionScreen() {
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
tab.type === 'terminal' && tab.terminal === handle
)
terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle)
pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null
pendingActiveTerminalHandleRef.current = handle
activeSessionTabTypeRef.current = 'terminal'
@@ -2931,15 +2947,15 @@ export default function SessionScreen() {
}
subscribeToTerminal(handle)
if (client) {
void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {})
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
})
.catch(() => {})
// Why: persist selection for headless hosts; the snapshot gate keeps
// this phone-local acknowledgement from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
}).catch(() => {})
}
}
},
@@ -2960,6 +2976,7 @@ export default function SessionScreen() {
switchTab(tab.terminal)
return
}
terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true)
triggerSelection()
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
@@ -2973,18 +2990,17 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
return
}
triggerSelection()
terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false)
pendingActiveSessionTabIdRef.current = tab.id
pendingActiveTerminalHandleRef.current = null
activeSessionTabTypeRef.current = tab.type
@@ -2997,13 +3013,11 @@ export default function SessionScreen() {
activeHandleRef.current = null
setActiveHandle(null)
if (client) {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
}).catch(() => {})
}
if (tab.type === 'browser') {
return
@@ -3031,6 +3045,7 @@ export default function SessionScreen() {
// init messages. This prevents the blank terminal race where init() was
// queued before the WebView loaded.
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
if (ref) {
terminalRefs.current.set(handle, ref)
} else {
@@ -3049,6 +3064,11 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
handle === activeHandleRef.current
)
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
// Why: the native WebView reloaded (Metro hot reload or Android
// process churn). The old xterm buffer is gone, so force a fresh
@@ -3085,7 +3105,7 @@ export default function SessionScreen() {
})()
}
},
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -4302,13 +4322,12 @@ export default function SessionScreen() {
// Why: a hydrated headless/server-owned tab can already be active but still
// pending; activation is the RPC that materializes or focuses its PTY handle.
pendingTerminalActivationAttemptRef.current = activationKey
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
.then((response) => {
if (!response.ok) {
if (pendingTerminalActivationAttemptRef.current === activationKey) {
+15 -4
View File
@@ -36,6 +36,7 @@ import {
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
@@ -317,6 +318,7 @@ export default function HomeScreen() {
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const notificationOptInCheckedRef = useRef(false)
// Why: read shared clients from the per-host store. Replaces the prior
// pattern of opening N independent WebSockets here. See
@@ -395,9 +397,18 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
let stale = false
void loadHosts().then((h) => {
if (!stale) {
setHosts(h)
void loadHosts().then(async (h) => {
if (stale) {
return
}
setHosts(h)
if (h.length === 0 || notificationOptInCheckedRef.current) {
return
}
notificationOptInCheckedRef.current = true
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!stale && showNotificationOptIn) {
router.replace('/notification-opt-in')
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
@@ -419,7 +430,7 @@ export default function HomeScreen() {
return () => {
stale = true
}
}, [])
}, [router])
)
const sortedHosts = useMemo(
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useState } from 'react'
import {
ActivityIndicator,
BackHandler,
Pressable,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
import { BellRing } from 'lucide-react-native'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
import { savePushNotificationsEnabled } from '../src/storage/preferences'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
export default function NotificationOptInScreen() {
const router = useRouter()
const params = useLocalSearchParams<{ hostId?: string | string[] }>()
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null)
const [error, setError] = useState<string | null>(null)
// Why: this one-time screen requires an explicit Enable or Not now choice;
// disabling back gestures alone would still leave Android hardware back open.
useFocusEffect(
useCallback(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
return () => subscription.remove()
}, [])
)
const continueToApp = useCallback(() => {
router.replace(hostId ? `/h/${hostId}` : '/')
}, [hostId, router])
const choose = useCallback(
async (choice: 'enable' | 'skip') => {
if (busyChoice) {
return
}
setBusyChoice(choice)
setError(null)
try {
const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
await savePushNotificationsEnabled(enabled)
continueToApp()
} catch {
setError('Notification settings could not be updated. Try again.')
setBusyChoice(null)
}
},
[busyChoice, continueToApp]
)
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<View style={styles.brandRow}>
<OrcaLogo size={22} />
<Text style={styles.brandName}>Orca</Text>
</View>
<View style={styles.content}>
<View style={styles.iconSurface}>
<BellRing size={30} color={colors.textPrimary} />
</View>
<Text style={styles.eyebrow}>Notifications</Text>
<Text style={styles.title}>Stay updated while away</Text>
<Text style={styles.body}>
Get notified on this device when an agent needs your input or finishes a task.
</Text>
</View>
<View style={styles.footer}>
{error ? (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Enable agent notifications"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.primaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('enable')}
>
{busyChoice === 'enable' ? (
<ActivityIndicator color={colors.bgBase} />
) : (
<Text style={styles.primaryButtonText}>Enable notifications</Text>
)}
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busyChoice !== null}
style={({ pressed }) => [
styles.secondaryButton,
pressed && styles.buttonPressed,
busyChoice !== null && styles.buttonDisabled
]}
onPress={() => void choose('skip')}
>
{busyChoice === 'skip' ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Text style={styles.secondaryButtonText}>Not now</Text>
)}
</Pressable>
<Text style={styles.footerNote}>You can change this any time in Settings.</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.xl
},
// Why: this decision screen cannot be dismissed with Back, so every action
// must remain reachable in landscape and with accessibility text scaling.
scrollContent: {
flexGrow: 1
},
brandRow: {
minHeight: 52,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
brandName: {
color: colors.textPrimary,
fontSize: 17,
fontWeight: '700'
},
content: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xl
},
iconSurface: {
width: 64,
height: 64,
borderRadius: radii.card,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginBottom: spacing.xl
},
eyebrow: {
color: colors.textMuted,
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.55,
textTransform: 'uppercase',
marginBottom: spacing.sm
},
title: {
maxWidth: 420,
color: colors.textPrimary,
fontSize: 26,
fontWeight: '700',
letterSpacing: -0.3,
textAlign: 'center'
},
body: {
maxWidth: 420,
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 21,
textAlign: 'center',
marginTop: spacing.md
},
footer: {
width: '100%',
maxWidth: 420,
alignSelf: 'center',
paddingBottom: spacing.lg
},
primaryButton: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.surfaceBright,
paddingVertical: spacing.sm
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
secondaryButton: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginTop: spacing.xs,
paddingVertical: spacing.sm
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
buttonPressed: {
opacity: 0.72
},
buttonDisabled: {
opacity: 0.58
},
footerNote: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginTop: spacing.sm
},
error: {
color: colors.statusRed,
fontSize: typography.metaSize,
lineHeight: 18,
textAlign: 'center',
marginBottom: spacing.sm
}
})
+4 -3
View File
@@ -17,7 +17,8 @@ import {
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
granted: false,
status: 'undetermined',
canAskAgain: true
canAskAgain: true,
authorizationReflectsUserChoice: false
}
export default function NotificationsScreen() {
@@ -69,7 +70,7 @@ export default function NotificationsScreen() {
const notificationsBlocked = permissionState.status === 'denied'
const hint = notificationsBlocked
? 'Notifications are disabled in system settings.'
: 'Receive notifications when an agent task completes on your desktop.'
: 'Get notified on this device when an agent needs your input or finishes a task.'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -82,7 +83,7 @@ export default function NotificationsScreen() {
<View style={styles.section}>
<View style={styles.row}>
<Text style={styles.rowLabel}>Push Notifications</Text>
<Text style={styles.rowLabel}>Agent notifications</Text>
<Switch
value={switchEnabled}
disabled={notificationsBlocked}
+10 -1
View File
@@ -11,6 +11,7 @@ import {
import type { ConnectionLogEntry } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
type Status = 'awaiting-confirm' | 'connecting' | 'error'
@@ -103,7 +104,15 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
}
router.replace(
showNotificationOptIn
? { pathname: '/notification-opt-in', params: { hostId } }
: `/h/${hostId}`
)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
+10 -1
View File
@@ -21,6 +21,7 @@ import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -147,7 +148,15 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
}
router.replace(
showNotificationOptIn
? { pathname: '/notification-opt-in', params: { hostId } }
: `/h/${hostId}`
)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import {
getNotificationPermissionState,
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
@@ -18,7 +20,7 @@ vi.mock('expo-notifications', () => ({
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
Platform: { OS: 'ios', Version: 18 }
}))
// Why: mobile-notifications now persists the catch-up watermark to
@@ -35,6 +37,32 @@ vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
beforeEach(() => {
Object.assign(Platform, { OS: 'ios', Version: 18 })
})
describe('getNotificationPermissionState', () => {
it.each([
{ os: 'android', version: 32, expected: false },
{ os: 'android', version: 33, expected: true },
{ os: 'ios', version: 18, expected: true }
])(
'reports whether a granted $os $version authorization reflects user choice',
async ({ os, version, expected }) => {
Object.assign(Platform, { OS: os, Version: version })
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
await expect(getNotificationPermissionState()).resolves.toMatchObject({
granted: true,
authorizationReflectsUserChoice: expected
})
}
)
})
describe('subscribeToDesktopNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -82,6 +82,7 @@ export type NotificationPermissionState = {
granted: boolean
status: string
canAskAgain: boolean
authorizationReflectsUserChoice: boolean
}
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
@@ -89,7 +90,11 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
return {
granted: status === 'granted',
status,
canAskAgain
canAskAgain,
// Why: Android before API 33 has no runtime notification permission, so
// Expo's default "granted" state is capability evidence, not user consent.
authorizationReflectsUserChoice:
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
}
}
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
vi.mock('../storage/preferences', () => ({
readPushNotificationsPreference: vi.fn(),
savePushNotificationsEnabled: vi.fn()
}))
vi.mock('./mobile-notifications', () => ({
getNotificationPermissionState: vi.fn()
}))
describe('notification opt-in gate', () => {
beforeEach(() => {
vi.mocked(readPushNotificationsPreference).mockReset()
vi.mocked(savePushNotificationsEnabled).mockReset()
vi.mocked(getNotificationPermissionState).mockReset()
})
it('presents only when the local preference and system decision are both unset', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'undetermined',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(getNotificationPermissionState).not.toHaveBeenCalled()
})
it('adopts existing system authorization without prompting', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: true
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
})
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: true,
status: 'granted',
canAskAgain: true,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
})
it('skips the gate when iOS has already denied permission', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockResolvedValue({
granted: false,
status: 'denied',
canAskAgain: false,
authorizationReflectsUserChoice: false
})
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
})
it('does not block startup when storage or permission checks fail', async () => {
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
})
})
@@ -0,0 +1,36 @@
import {
readPushNotificationsPreference,
savePushNotificationsEnabled
} from '../storage/preferences'
import { getNotificationPermissionState } from './mobile-notifications'
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
if (!preference.loaded || preference.value !== null) {
return false
}
try {
const permission = await getNotificationPermissionState()
if (permission.granted) {
if (!permission.authorizationReflectsUserChoice) {
return true
}
// Why: an already-authorized device should inherit the useful default
// without seeing an onboarding decision it has effectively made.
await savePushNotificationsEnabled(true)
return false
}
if (permission.status === 'denied' || !permission.canAskAgain) {
// Why: iOS cannot show its authorization prompt again, so a blocking
// onboarding screen would be a dead end; Settings remains the recovery.
await savePushNotificationsEnabled(false)
return false
}
return permission.status === 'undetermined'
} catch {
// Why: permission or persistence failures must not trap startup behind a
// decision screen whose result cannot be applied reliably.
return false
}
}
@@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode(
): boolean {
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
}
export function getActiveTabIdForHandle(
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
terminalHandle: string | null
): string | null {
if (!terminalHandle) {
return null
}
return (
tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ??
terminalHandle
)
}
export function updateTerminalCwdFromStreamEvent(
handle: string,
data: Readonly<Record<string, unknown>>,
terminalCwd: Map<string, string>
): void {
if (!('cwd' in data)) {
return
}
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
terminalCwd.set(handle, data.cwd)
return
}
terminalCwd.delete(handle)
}
@@ -58,7 +58,7 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain(
'pendingTerminalActivationAttemptRef.current === activationKey'
)
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,')
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
@@ -68,8 +68,19 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('keeps mobile session tab activation local to the phone', () => {
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
const readyTerminalSwitch = sliceBetween(
'const switchTab = useCallback(',
'const switchSessionTab = useCallback('
)
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
expect(readyTerminalSwitch).toContain('notifyClients: false')
})
it('keeps background and pending session-tab activation local to the phone', () => {
const activationRequests = source.split('activateMobileSessionTab(client,').slice(1)
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
function success(): RpcResponse {
return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
}
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
return { sendRequest }
}
describe('mobile session tab activation', () => {
it('retries terminal focus once on the authenticated replacement after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject(
{
ok: true
}
)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
})
it('retries session-tab activation with the same target after cutover', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(success())
const params = {
worktree: 'id:worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
notifyClients: false as const
}
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
ok: true
})
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params)
})
it('does not retry unrelated transport failures', async () => {
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockRejectedValue(new Error('offline'))
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow(
'offline'
)
expect(sendRequest).toHaveBeenCalledOnce()
})
it('retries at most once when consecutive cutovers interrupt activation', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockRejectedValue(new LogicalClientCutoverError())
await expect(
activateMobileSessionTab(clientWith(sendRequest), {
worktree: 'id:worktree-1',
tabId: 'tab-1',
notifyClients: false
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,90 @@
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcResponse } from '../transport/types'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
type MobileSessionTabActivationParams = {
worktree: string
tabId: string
leafId?: string
notifyClients: false
}
async function retryIdempotentActivationAfterCutover(
request: () => Promise<RpcResponse>,
operation: 'terminal.focus' | 'session.tabs.activate',
target: string
): Promise<RpcResponse> {
const diagnosticTarget = shortenMobileTerminalDiagnosticId(target)
logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget })
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(error)
})
throw error
}
logMobileTerminalDiagnostic('activation-cutover-retry', {
operation,
target: diagnosticTarget
})
// Why: cutover rejects ambiguous in-flight work after the replacement is
// active; these state-setting requests are idempotent and safe to repeat once.
try {
const response = await request()
logMobileTerminalDiagnostic('activation-result', {
operation,
target: diagnosticTarget,
ok: response.ok,
rpcCode: response.ok ? null : response.error.code
})
return response
} catch (retryError) {
logMobileTerminalDiagnostic('activation-error', {
operation,
target: diagnosticTarget,
errorName: getMobileTerminalDiagnosticErrorName(retryError)
})
throw retryError
}
}
}
export function focusMobileTerminal(
client: ActivationClient,
terminal: string
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('terminal.focus', { terminal }),
'terminal.focus',
terminal
)
}
export function activateMobileSessionTab(
client: ActivationClient,
params: MobileSessionTabActivationParams
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('session.tabs.activate', params),
'session.tabs.activate',
params.tabId
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
MobileTerminalDiagnostics,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
describe('mobile terminal diagnostics', () => {
it('keeps only the correlatable suffix of identifiers', () => {
expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678')
expect(shortenMobileTerminalDiagnosticId('short')).toBe('short')
expect(shortenMobileTerminalDiagnosticId(null)).toBeNull()
})
it('reports thrown error types without copying potentially sensitive messages', () => {
expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe(
'TypeError'
)
expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string')
})
it('uses one filterable structured log tag', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 })
expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', {
handle: '12345678',
seq: 2
})
log.mockRestore()
})
it('forgets first-event state when a terminal unsubscribes', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const diagnostics = new MobileTerminalDiagnostics()
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
diagnostics.terminalUnsubscribed('terminal-1')
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
})
@@ -0,0 +1,291 @@
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
export type MobileTerminalDiagnosticDetails = Readonly<
Record<string, MobileTerminalDiagnosticValue>
>
type DiagnosticTab = {
readonly id: string
readonly type: string
readonly isActive: boolean
readonly terminal?: string | null
}
type DiagnosticTabsSnapshot = {
readonly publicationEpoch?: string
readonly snapshotVersion: number
readonly tabs: readonly DiagnosticTab[]
}
type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined
// Why: full runtime identifiers make shared logs unnecessarily sensitive; the
// suffix is enough to correlate lifecycle events within one reproduction.
export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null {
if (!value) {
return null
}
return value.slice(-8)
}
export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
if (error instanceof Error && error.name) {
return error.name
}
return typeof error
}
export function logMobileTerminalDiagnostic(
event: string,
details: MobileTerminalDiagnosticDetails = {}
): void {
// Why: lifecycle diagnostics are intentionally available for HMR repros,
// but high-frequency WebView events must not add production log overhead.
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
return
}
// Keep this structured and content-free so users can safely share a filtered log.
console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details)
}
export class MobileTerminalDiagnostics {
private readonly streamGateByHandle = new Map<string, string>()
private readonly firstStreamEventSeqByHandle = new Map<string, number>()
private lastFetchedTabsSignature: string | null = null
private lastAppliedTabsSignature: string | null = null
private lastTabsFetchStartAt = 0
private tabsFetchSkipLogged = false
clearTerminalCache(): void {
this.streamGateByHandle.clear()
this.firstStreamEventSeqByHandle.clear()
}
resetRoute(): void {
this.clearTerminalCache()
this.lastFetchedTabsSignature = null
this.lastAppliedTabsSignature = null
this.lastTabsFetchStartAt = 0
this.tabsFetchSkipLogged = false
}
terminalUnsubscribed(handle: string): void {
this.streamGateByHandle.delete(handle)
this.firstStreamEventSeqByHandle.delete(handle)
}
viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void {
logMobileTerminalDiagnostic('viewport-measure', {
handle: shortenMobileTerminalDiagnosticId(handle),
ok: dims != null,
cols: dims?.cols,
rows: dims?.rows,
frameHeight: Math.round(frameHeight)
})
}
streamSkipped(handle: string, reason: string, isActive: boolean): void {
if (this.streamGateByHandle.get(handle) === reason) {
return
}
this.streamGateByHandle.set(handle, reason)
logMobileTerminalDiagnostic('stream-skipped', {
handle: shortenMobileTerminalDiagnosticId(handle),
reason,
isActive
})
}
streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void {
this.streamGateByHandle.delete(handle)
logMobileTerminalDiagnostic('stream-armed', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
hasViewport: viewport != null,
viewportCols: viewport?.cols,
viewportRows: viewport?.rows
})
}
firstStreamEvent(handle: string, seq: number, type: unknown): void {
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
return
}
this.firstStreamEventSeqByHandle.set(handle, seq)
logMobileTerminalDiagnostic('stream-first-event', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
type: typeof type === 'string' ? type : 'unknown'
})
}
streamScrollback(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>
): void {
logMobileTerminalDiagnostic('stream-scrollback', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
displayMode: typeof data.displayMode === 'string' ? data.displayMode : null,
source: typeof data.source === 'string' ? data.source : null,
scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null,
truncated: data.truncated === true || data.truncatedByByteBudget === true
})
}
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
cols: dims.cols,
rows: dims.rows
})
}
streamResized(
handle: string,
seq: number,
eventSeq: number | null,
data: Readonly<Record<string, unknown>>,
hasRef: boolean
): void {
logMobileTerminalDiagnostic('stream-resized', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
eventSeq,
cols: typeof data.cols === 'number' ? data.cols : null,
rows: typeof data.rows === 'number' ? data.rows : null,
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
hasRef
})
}
tabsApplied(
snapshot: DiagnosticTabsSnapshot,
tabs: readonly DiagnosticTab[],
activeTab: DiagnosticTab | null,
selectionSource: string
): void {
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
const appliedSnapshot = { ...snapshot, tabs }
const signature = [
appliedSnapshot.publicationEpoch ?? '',
appliedSnapshot.snapshotVersion,
activeTab?.id ?? '',
activeHandle ?? '',
selectionSource
].join(':')
if (this.lastAppliedTabsSignature === signature) {
return
}
this.lastAppliedTabsSignature = signature
this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource })
}
tabsFetchSkipped(reason: string): void {
if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) {
return
}
this.tabsFetchSkipLogged = reason === 'already-in-flight'
logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason })
}
tabsFetchStarted(worktreeId: string): void {
this.tabsFetchSkipLogged = false
const now = Date.now()
if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) {
return
}
this.lastTabsFetchStartAt = now
logMobileTerminalDiagnostic('tabs-fetch-start', {
worktree: shortenMobileTerminalDiagnosticId(worktreeId)
})
}
tabsFetchFailed(rpcCode: string): void {
logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode })
}
tabsFetchErrored(error: unknown): void {
logMobileTerminalDiagnostic('tabs-fetch-error', {
errorName: getMobileTerminalDiagnosticErrorName(error)
})
}
tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void {
const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null
const signature = [
snapshot.publicationEpoch ?? '',
snapshot.snapshotVersion,
snapshot.tabs.length,
activeTab?.id ?? '',
activeTab?.type ?? ''
].join(':')
if (this.lastFetchedTabsSignature === signature) {
return
}
this.lastFetchedTabsSignature = signature
const activeHandle =
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
? activeTab.terminal
: null
this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle)
}
tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void {
logMobileTerminalDiagnostic('tab-switch', {
tabType,
tab: shortenMobileTerminalDiagnosticId(tabId),
handle: shortenMobileTerminalDiagnosticId(handle),
pending
})
}
webViewRef(handle: string, attached: boolean): void {
logMobileTerminalDiagnostic('webview-ref', {
handle: shortenMobileTerminalDiagnosticId(handle),
attached
})
}
webViewReady(handle: string, reload: boolean, isActive: boolean): void {
logMobileTerminalDiagnostic('webview-ready', {
handle: shortenMobileTerminalDiagnosticId(handle),
reload,
isActive
})
}
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
activeTab: DiagnosticTab | null,
activeHandle: string | null,
extra: MobileTerminalDiagnosticDetails = {}
): void {
logMobileTerminalDiagnostic(event, {
publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch),
snapshotVersion: snapshot.snapshotVersion,
tabCount: snapshot.tabs.length,
terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length,
pendingTerminalCount: snapshot.tabs.filter(
(tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string'
).length,
activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id),
activeType: activeTab?.type ?? null,
activeHandle: shortenMobileTerminalDiagnosticId(activeHandle),
...extra
})
}
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from './session-tab-snapshot-gate'
@@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => {
})
})
describe('confirmsMirroredTabSelection', () => {
it('does not treat phone-local persistence as desktop focus confirmation', () => {
expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false)
})
it('accepts renderer, headless, and legacy publications as confirmation', () => {
expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true)
expect(confirmsMirroredTabSelection('headless:abc')).toBe(true)
expect(confirmsMirroredTabSelection()).toBe(true)
})
})
describe('applyClosedTabTombstones', () => {
const tab = (id: string): { id: string } => ({ id })
@@ -33,6 +33,12 @@ export function acceptSessionSnapshot(
return true
}
export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean {
// Why: phone-local persistence acknowledges the mutation, not host focus;
// keep the pending guard until any host publication confirms the selection.
return !publicationEpoch?.startsWith('mobile-local:')
}
/**
* Drops tabs the user just closed locally (tombstoned) until the publisher's
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
+43
View File
@@ -10,11 +10,14 @@ import {
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readPushNotificationsPreference,
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
@@ -26,6 +29,46 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('push notification preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('distinguishes an unset preference from an explicit disabled choice', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: true
})
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
vi.mocked(AsyncStorage.getItem).mockResolvedValue('false')
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: false,
loaded: true
})
})
it('reports storage failures without enabling notifications', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(readPushNotificationsPreference()).resolves.toEqual({
value: null,
loaded: false
})
await expect(loadPushNotificationsEnabled()).resolves.toBe(false)
})
it('persists the onboarding decision in the existing mobile toggle', async () => {
await savePushNotificationsEnabled(true)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'true')
await savePushNotificationsEnabled(false)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pushNotificationsEnabled', 'false')
})
})
describe('terminal autocomplete preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
+17 -11
View File
@@ -3,23 +3,29 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Why: default-off so the iOS notification permission prompt never
// fires until the user explicitly opts in via Settings → Notifications.
// Apple's review guideline 4.5.4 and HIG both prefer user-initiated
// permission prompts; default-on would fire the prompt the moment the
// desktop sent its first notification, which can read as unsolicited.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
export type PushNotificationsPreference = {
readonly value: boolean | null
readonly loaded: boolean
}
// Why: null distinguishes people who have never made the one-time onboarding
// decision from people who explicitly chose Not now or disabled notifications.
export async function readPushNotificationsPreference(): Promise<PushNotificationsPreference> {
try {
const raw = await AsyncStorage.getItem(NOTIF_KEY)
if (raw === null) {
return false
}
return raw === 'true'
return { value: raw === null ? null : raw === 'true', loaded: true }
} catch {
return false
return { value: null, loaded: false }
}
}
// Why: default-off prevents background notification events from opening the
// system prompt; only the onboarding CTA or Settings switch requests permission.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
const preference = await readPushNotificationsPreference()
return preference.value ?? false
}
export async function savePushNotificationsEnabled(enabled: boolean): Promise<void> {
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
}
+7 -24
View File
@@ -6,6 +6,7 @@ import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
@@ -217,6 +218,7 @@ window.onerror = function(msg) {
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -713,22 +715,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var oldTerm = term;
var oldSurface = surface;
var nextSurface = null;
disposeTermObservers();
if (oldTerm) {
nextSurface = document.createElement('div');
nextSurface.id = 'terminal-surface';
nextSurface.style.visibility = 'hidden';
nextSurface.style.position = 'absolute';
nextSurface.style.left = '0';
nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(nextSurface);
surface = nextSurface;
attachSurfaceEventHandlers(surface);
oldSurface.removeAttribute('id');
}
var surfaceSwap = beginTerminalSurfaceSwap();
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
term = new Terminal({
@@ -749,6 +737,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
convertEol: false,
allowProposedApi: true
});
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
@@ -768,14 +758,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
if (nextSurface && oldSurface) {
nextSurface.style.visibility = 'visible';
nextSurface.style.position = '';
nextSurface.style.left = '';
nextSurface.style.top = '';
oldSurface.remove();
if (oldTerm) oldTerm.dispose();
}
commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
const start = XTERM_HTML.indexOf('(function() {')
const end = XTERM_HTML.lastIndexOf('})();')
return XTERM_HTML.slice(start, end + '})();'.length)
}
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
type TerminalStub = ReturnType<typeof makeTerminal>
function makeTerminal(writeCallbacks: Array<() => void>) {
const terminal = {
cols: 80,
rows: 24,
options: { fontSize: 13 },
modes: {},
element: null as HTMLElement | null,
disposed: false,
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } },
buffer: {
active: {
viewportY: 0,
baseY: 0,
length: 1,
cursorY: 0,
type: 'normal' as const,
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
if (callback) {
writeCallbacks.push(callback)
}
},
open(surface: HTMLElement) {
terminal.element = surface
},
loadAddon() {},
resize(cols: number, rows: number) {
terminal.cols = cols
terminal.rows = rows
},
clear() {},
reset() {},
refresh() {},
selectAll() {},
clearSelection() {},
select() {},
scrollLines() {},
scrollToBottom() {},
scrollToLine() {},
getSelection: () => '',
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
dispose() {
terminal.disposed = true
}
}
return terminal
}
function dispatchInit(cols: number, initialData: string): void {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols, rows: 40, initialData })
})
)
}
describe('terminal WebView init surface replacement', () => {
let animationFrames: Array<() => void>
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
beforeEach(() => {
animationFrames = []
terminals = []
writeCallbacks = []
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
const terminal = makeTerminal(writeCallbacks)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage: vi.fn() }
document.body.innerHTML = bodyMarkup()
// eslint-disable-next-line no-new-func
new Function(iifeSource())()
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.
dispatchInit(120, 'desktop')
animationFrames.shift()?.()
dispatchInit(51, 'phone-resize')
animationFrames.shift()?.()
dispatchInit(51, 'phone-scrollback')
animationFrames.shift()?.()
expect(terminals).toHaveLength(3)
expect(writeCallbacks).toHaveLength(3)
writeCallbacks[2]?.()
writeCallbacks[0]?.()
writeCallbacks[1]?.()
const surfaces = document.querySelectorAll('#terminal-container > div')
expect(surfaces).toHaveLength(1)
expect(surfaces[0]?.id).toBe('terminal-surface')
expect((surfaces[0] as HTMLElement).style.visibility).toBe('visible')
expect(terminals.map((terminal) => terminal.disposed)).toEqual([true, true, false])
})
})
@@ -0,0 +1,49 @@
export const TERMINAL_SURFACE_SWAP_JS = String.raw`
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
var committedTerm = null;
var committedSurface = surface;
var pendingTerm = null;
var pendingSurface = null;
function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
try { pendingSurface.remove(); } catch (e) {}
if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {}
pendingSurface = null;
pendingTerm = null;
}
var swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
nextSurface: document.createElement('div')
};
disposeTermObservers();
swap.nextSurface.id = 'terminal-surface';
swap.nextSurface.style.visibility = 'hidden';
swap.nextSurface.style.position = 'absolute';
swap.nextSurface.style.left = '0';
swap.nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(swap.nextSurface);
surface = swap.nextSurface;
pendingSurface = swap.nextSurface;
attachSurfaceEventHandlers(surface);
swap.oldSurface.removeAttribute('id');
return swap;
}
function commitTerminalSurfaceSwap(swap, nextTerm) {
swap.nextSurface.style.visibility = 'visible';
swap.nextSurface.style.position = '';
swap.nextSurface.style.left = '';
swap.nextSurface.style.top = '';
swap.oldSurface.remove();
if (swap.oldTerm) swap.oldTerm.dispose();
committedTerm = nextTerm;
committedSurface = swap.nextSurface;
pendingTerm = null;
pendingSurface = null;
}
`
@@ -130,7 +130,7 @@ describe('mobile relay pairing journal store', () => {
expect(metadataRaw).toBeNull()
})
it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => {
it('cleans mismatched secret records', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
@@ -142,6 +142,137 @@ describe('mobile relay pairing journal store', () => {
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
})
it('replaces a journal that never selected an authorization path', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
const replacementSave = saveMobileRelayPairingJournal(replacement)
const staleUpdate = updateMobileRelayPairingJournal(
journal.metadata.journalId,
(metadata) => metadata
)
await expect(replacementSave).resolves.toBeUndefined()
await expect(staleUpdate).rejects.toThrow(/stale/)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
})
it('serializes a replacement behind an in-flight journal snapshot', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
let releaseSecretRead!: () => void
const secretReadGate = new Promise<void>((resolve) => {
releaseSecretRead = resolve
})
secureStore.getItemAsync.mockImplementationOnce(async () => {
await secretReadGate
return secretRaw
})
const loading = loadMobileRelayPairingJournal()
await vi.waitFor(() => expect(secureStore.getItemAsync).toHaveBeenCalled())
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
let replacementSaved = false
const saving = saveMobileRelayPairingJournal(replacement).then(() => {
replacementSaved = true
})
await Promise.resolve()
expect(replacementSaved).toBe(false)
releaseSecretRead()
await expect(loading).resolves.toEqual(journal)
await expect(saving).resolves.toBeUndefined()
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
expect(asyncStorage.removeItem).not.toHaveBeenCalled()
})
it('cleans a replacement whose secret write fails', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable'))
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/)
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
})
it('blocks replacement when the old authorization update wins the mutation race', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
const authorizationUpdate = updateMobileRelayPairingJournal(
journal.metadata.journalId,
(metadata) => ({
...metadata,
winner: 'direct',
authorizationMode: 'authenticated-direct'
})
)
const replacementSave = saveMobileRelayPairingJournal(replacement)
await expect(authorizationUpdate).resolves.toBeUndefined()
await expect(replacementSave).rejects.toThrow(/recovery pending/)
})
it.each([
['winner', { winner: 'direct' as const }],
['authorization mode', { authorizationMode: 'authenticated-direct' as const }]
])('refuses replacement once the durable %s exists', async (_name, authorization) => {
const created = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
const journal = {
...created,
metadata: { ...created.metadata, ...authorization }
}
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
@@ -27,9 +27,15 @@ export async function saveMobileRelayPairingJournal(
const mutation = journalMutation.then(async () => {
const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const existing = existingRaw ? parseMetadata(existingRaw) : null
if (existing && existing.journalId !== metadata.journalId) {
if (
existing &&
existing.journalId !== metadata.journalId &&
(existing.winner !== undefined || existing.authorizationMode !== undefined)
) {
throw new Error('mobile relay pairing recovery pending')
}
// Why: no install RPC can run before winner+authorization are durable, so
// a new user-initiated scan may safely supersede a pre-authorization attempt.
// Why: metadata-first makes a crash before the keychain write recover as
// an incomplete journal, never as an untracked bearer secret.
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata))
@@ -41,28 +47,36 @@ export async function saveMobileRelayPairingJournal(
export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairingJournal | null> {
requireNativeSecretStore()
await journalMutation
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
const load = journalMutation.then(async () => {
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
})
// Why: recovery may load while a new scan saves; serialize the complete
// metadata/secret snapshot and any cleanup so it cannot delete the new journal.
journalMutation = load.then(
() => undefined,
() => undefined
)
return load
}
async function removeIncompleteJournal(): Promise<void> {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.142-rc.1",
"version": "1.4.142-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -132,6 +132,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
const result = await adapter.spawn({ cols: 80, rows: 24 })
expect(result.id).toBeDefined()
expect(typeof result.id).toBe('string')
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
it('uses worktreeId as session prefix when provided', async () => {
@@ -506,6 +507,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(second.launchAgent).toBe('droid')
expect(second.snapshot).toBeDefined()
expect(second.snapshot).toContain('hello from shell')
expect(second.providerSequence).toEqual({
value: 'hello from shell\r\n'.length,
generation: 'continued'
})
})
it('includes rehydrateSequences in snapshot when terminal modes are active', async () => {
@@ -528,6 +533,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(result.id).toBe('brand-new')
expect(result.isReattach).toBeUndefined()
expect(result.snapshot).toBeUndefined()
expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' })
})
})
+21 -1
View File
@@ -47,6 +47,17 @@ function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null {
: restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi
}
function providerSequenceForSpawn(
result: CreateOrAttachResult
): PtySpawnResult['providerSequence'] {
if (result.isNew) {
return { value: 0, generation: 'reset' }
}
return typeof result.snapshot?.outputSequence === 'number'
? { value: result.snapshot.outputSequence, generation: 'continued' }
: undefined
}
export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
@@ -322,6 +333,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
const wasAlreadyManaged = this.activeSessionIds.has(sessionId)
this.activeSessionIds.add(sessionId)
const providerSequence = providerSequenceForSpawn(result)
// Cold restore: daemon created a new session but disk history shows
// an unclean shutdown → return saved scrollback so the renderer can
@@ -353,10 +365,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
pid,
...launchIdentity(),
coldRestore,
...(providerSequence ? { providerSequence } : {}),
...(!result.isNew ? { isReattach: true } : {})
}
}
return { id: sessionId, pid, ...launchIdentity() }
return {
id: sessionId,
pid,
...launchIdentity(),
...(providerSequence ? { providerSequence } : {})
}
}
if (this.historyManager && result.isNew) {
@@ -394,6 +412,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
id: sessionId,
pid,
...launchIdentity(),
...(providerSequence ? { providerSequence } : {}),
...(isReattach ? { isReattach: true } : {})
}
}
@@ -415,6 +434,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
...(providerSequence ? { providerSequence } : {}),
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
+16 -8
View File
@@ -899,7 +899,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for focused active-worktree notifications', async () => {
it('dispatches one mobile notification when the active worktree is focused on desktop', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
@@ -922,17 +922,25 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(
await handler(
{},
{ source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true }
)
).toEqual({
const focusedNotification = {
source: 'agent-task-complete' as const,
worktreeId: 'repo::wt1',
isActiveWorktree: true
}
expect(await handler({}, focusedNotification)).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(await handler({}, focusedNotification)).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(dispatchMobileNotification).not.toHaveBeenCalled()
expect(dispatchMobileNotification).toHaveBeenCalledTimes(1)
expect(dispatchMobileNotification).toHaveBeenCalledWith(
expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' })
)
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => {
+36 -26
View File
@@ -312,8 +312,24 @@ function pruneRecentNotifications(recentNotifications: Map<string, number>, now:
}
}
function reserveNotificationCooldown(
recentNotifications: Map<string, number>,
dedupeKey: string,
now: number
): boolean {
const lastSentAt = recentNotifications.get(dedupeKey) ?? 0
if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) {
return false
}
recentNotifications.delete(dedupeKey)
recentNotifications.set(dedupeKey, now)
pruneRecentNotifications(recentNotifications, now)
return true
}
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const recentNotifications = new Map<string, number>()
const recentDesktopNotifications = new Map<string, number>()
const recentMobileNotifications = new Map<string, number>()
// Why: handler registration marks a fresh session — permission evidence
// from a previous registration must not leak into the new one.
lastObservedDeliveryOutcome = null
@@ -441,6 +457,24 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
return { delivered: false, reason: 'source-disabled' }
}
const notificationOptions = buildNotificationOptions(args)
// Why: desktop focus only means this computer has the worktree visible;
// the paired phone may be locked or elsewhere and still needs the alert.
if (runtime && args.source !== 'test') {
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) {
runtime.dispatchMobileNotification({
type: 'notification',
source: args.source,
title: notificationOptions.title,
body: notificationOptions.body,
worktreeId: args.worktreeId,
...(args.notificationId ? { notificationId: args.notificationId } : {})
})
}
}
const browserWindow =
BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null
if (
@@ -458,33 +492,9 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
// Dedupe by worktree, not by source — an agent finishing and a terminal bell
// often fire within the same data chunk so only the first one should surface.
const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global'
const now = Date.now()
const lastSentAt = recentNotifications.get(dedupeKey) ?? 0
if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) {
if (!reserveNotificationCooldown(recentDesktopNotifications, dedupeKey, Date.now())) {
return { delivered: false, reason: 'cooldown' }
}
recentNotifications.delete(dedupeKey)
recentNotifications.set(dedupeKey, now)
// Why: a storm across many worktrees should not make every
// notification dispatch scan an ever-growing cooldown table.
pruneRecentNotifications(recentNotifications, now)
}
const notificationOptions = buildNotificationOptions(args)
// Why: paired mobile clients should follow the same user-facing
// notification gates as desktop delivery, while still working on hosts
// where Electron native notifications are unavailable.
if (runtime && args.source !== 'test') {
runtime.dispatchMobileNotification({
type: 'notification',
source: args.source,
title: notificationOptions.title,
body: notificationOptions.body,
worktreeId: args.worktreeId,
...(args.notificationId ? { notificationId: args.notificationId } : {})
})
}
if (!Notification.isSupported()) {
+75 -1
View File
@@ -10601,6 +10601,44 @@ describe('registerPtyHandlers', () => {
expect(mockProc.proc.write).not.toHaveBeenCalled()
})
it('synchronizes runtime output sequencing from a provider reattach snapshot', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({
id: 'pty-restored',
isReattach: true,
providerSequence: { value: 900, generation: 'continued' as const }
})),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const runtime = {
setPtyController: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
getPtyOutputSequence: vi.fn().mockReturnValue(7),
synchronizePtyOutputSequenceFromProvider: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => null),
preAllocateHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(runtime.synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith(
'pty-restored',
{ value: 900, generation: 'continued' },
7
)
})
it('seeds headless terminal state with cold-restore cwd metadata', async () => {
const oscLinks = [{ row: 0, startCol: 0, endCol: 8, uri: 'https://example.com/restored' }]
const coldRestore = {
@@ -10638,7 +10676,7 @@ describe('registerPtyHandlers', () => {
'pty-cold-restore',
'restored history\r\n',
undefined,
{ cwd: '/projects/restored', oscLinks }
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
)
})
@@ -11495,6 +11533,42 @@ describe('registerPtyHandlers', () => {
})
})
describe('provider buffer snapshot dispatch', () => {
it('exposes daemon history when no renderer pane is mounted', async () => {
const provider = installObservableDaemonTestProvider()
provider.getBufferSnapshot.mockResolvedValue({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
serializeProviderBuffer(ptyId: string, opts?: { scrollbackRows?: number }): Promise<unknown>
}
await expect(
controller.serializeProviderBuffer('daemon-restored', { scrollbackRows: 5000 })
).resolves.toEqual({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-restored', {
scrollbackRows: 5000
})
})
})
describe('main buffer snapshot dispatch', () => {
it('returns a hidden-output recovery snapshot with clamped scrollback', async () => {
const runtime = {
+33 -1
View File
@@ -3178,7 +3178,18 @@ export function registerPtyHandlers(
if (args.preAllocatedHandle) {
trustedTerminalHandleEnv.add(args.preAllocatedHandle)
}
const expectedPtyId = effectiveSessionAppId ?? sessionId
const sequenceBeforeProviderSpawn = expectedPtyId
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
result.providerSequence,
sequenceBeforeProviderSpawn
)
}
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
@@ -3521,6 +3532,15 @@ export function registerPtyHandlers(
// state and dimensions before live TUI chunks can render correctly.
return requestSerializedBuffer(ptyId, opts)
},
serializeProviderBuffer: async (ptyId, opts) => {
try {
// Why: restored daemon PTYs can be live while their desktop pane stays
// unmounted; query the provider model so phone-local navigation works.
return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null
} catch {
return null
}
},
hasRendererSerializer: (ptyId) => {
// Why: the runtime needs a synchronous probe so it can decide whether to
// skip the daemon-snapshot seed (the renderer will hydrate it) or run the
@@ -4097,7 +4117,18 @@ export function registerPtyHandlers(
)
}
spawnTiming.mark('options')
const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId
const sequenceBeforeProviderSpawn = expectedPtyId
? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0)
: 0
result = await provider.spawn(spawnOptions)
if (result.providerSequence) {
runtime?.synchronizePtyOutputSequenceFromProvider?.(
result.id,
result.providerSequence,
sequenceBeforeProviderSpawn
)
}
spawnTiming.mark('provider_spawn')
} catch (err) {
// Why: a failed spawn must not leave a stale hidden mark on a session
@@ -4332,7 +4363,8 @@ export function registerPtyHandlers(
) {
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, seedSize, {
cwd: result.coldRestore.cwd,
oscLinks: result.coldRestore.oscLinks
oscLinks: result.coldRestore.oscLinks,
preferProviderIfExisting: true
})
}
}
+6
View File
@@ -117,6 +117,12 @@ export type PtySpawnResult = {
* writing the snapshot so ANSI cursor positions land correctly. */
snapshotCols?: number
snapshotRows?: number
/** Provider sequence at the attach boundary. `reset` starts a new provider
* generation; `continued` resumes the existing absolute domain. */
providerSequence?: {
value: number
generation: 'continued' | 'reset'
}
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
* (terminal-query-authority.md §kitty). Never replayed into a renderer
+84 -2
View File
@@ -107,13 +107,95 @@ describe('fetchGrokRateLimits', () => {
expect(netFetchMock).not.toHaveBeenCalled()
})
it('returns unavailable when billing has no credit usage', async () => {
it('returns unavailable when neither billing view has usage', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
netFetchMock
.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
.mockResolvedValueOnce(jsonResponse({ config: { subscriptionTier: 'Enterprise' } }))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('unavailable')
expect(result.weekly).toBeNull()
expect(result.monthly).toBeUndefined()
})
it('maps monthly included usage for unified-billing accounts without weekly credits', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(
jsonResponse({
config: {
currentPeriod: {
type: 'USAGE_PERIOD_TYPE_WEEKLY',
start: '2026-07-10T19:38:56.948570+00:00',
end: '2026-07-17T19:38:56.948570+00:00'
},
isUnifiedBillingUser: true,
subscriptionTier: 'SuperGrok'
}
})
)
.mockResolvedValueOnce(
jsonResponse({
config: {
monthlyLimit: { val: 150000 },
used: { val: 837 },
billingPeriodStart: '2026-07-01T00:00:00+00:00',
billingPeriodEnd: '2026-08-01T00:00:00+00:00'
}
})
)
const result = await fetchGrokRateLimits()
expect(result.status).toBe('ok')
expect(result.error).toBeNull()
expect(result.weekly).toBeNull()
expect(result.monthly?.usedPercent).toBeCloseTo((837 / 150000) * 100, 5)
expect(result.monthly?.windowMinutes).toBe(43_200)
expect(result.monthly?.resetsAt).toBe(Date.parse('2026-08-01T00:00:00+00:00'))
expect(result.usageMetadata?.authProvenance).toContain('SuperGrok')
expect(netFetchMock).toHaveBeenNthCalledWith(
2,
'https://cli-chat-proxy.grok.com/v1/billing',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
})
)
})
// Why: 'unavailable' would make applyStalePolicy discard the last good
// monthly snapshot; transient fallback failures must present like transient
// credits-view failures so stale data survives.
it('surfaces an error when the monthly fallback request fails', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } }))
.mockResolvedValueOnce(jsonResponse({}, 500))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('Grok usage request failed (HTTP 500)')
})
it('surfaces an error when the monthly fallback request throws', async () => {
authState.file = freshAuthJson()
netFetchMock
.mockResolvedValueOnce(jsonResponse({ config: { isUnifiedBillingUser: true } }))
.mockRejectedValueOnce(new Error('network down'))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('error')
expect(result.error).toBe('network down')
})
it('does not request the default billing view when weekly credits are present', async () => {
authState.file = freshAuthJson()
netFetchMock.mockResolvedValueOnce(jsonResponse(BILLING_RESPONSE))
const result = await fetchGrokRateLimits()
expect(result.status).toBe('ok')
expect(netFetchMock).toHaveBeenCalledTimes(1)
})
it('returns unavailable when billing response has no config', async () => {
+114 -29
View File
@@ -12,8 +12,12 @@ const GROK_CLI_PROXY_BASE =
process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim().replace(/\/$/, '') ||
'https://cli-chat-proxy.grok.com/v1'
const BILLING_CREDITS_URL = `${GROK_CLI_PROXY_BASE}/billing?format=credits`
// Why: unified-billing accounts have no weekly credits; their included monthly
// budget is only present in the default (format-less) billing view.
const BILLING_DEFAULT_URL = `${GROK_CLI_PROXY_BASE}/billing`
const API_TIMEOUT_MS = 10_000
const WEEKLY_WINDOW_MINUTES = 10_080
const MONTHLY_WINDOW_MINUTES = 43_200
const GROK_CLI_AUTH_HEADER = 'xai-grok-cli'
@@ -31,6 +35,8 @@ type GrokBillingConfig = {
billingPeriodStart?: string
billingPeriodEnd?: string
subscriptionTier?: string
monthlyLimit?: GrokMoneyVal
used?: GrokMoneyVal
onDemandCap?: GrokMoneyVal
onDemandUsed?: GrokMoneyVal
prepaidBalance?: GrokMoneyVal
@@ -81,6 +87,28 @@ function mapWeeklyCredits(config: GrokBillingConfig): RateLimitWindow | null {
}
}
function parseMoneyVal(value: GrokMoneyVal | undefined): number | null {
const raw = value?.val
const num = typeof raw === 'string' ? Number.parseFloat(raw) : raw
return typeof num === 'number' && Number.isFinite(num) ? num : null
}
function mapMonthlyUsage(config: GrokBillingConfig): RateLimitWindow | null {
const limit = parseMoneyVal(config.monthlyLimit)
const used = parseMoneyVal(config.used)
if (limit === null || used === null || limit <= 0) {
return null
}
const periodEnd = config.currentPeriod?.end ?? config.billingPeriodEnd
const resetsAt = periodEnd ? Date.parse(periodEnd) : null
return {
usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)),
windowMinutes: MONTHLY_WINDOW_MINUTES,
resetsAt: resetsAt !== null && Number.isFinite(resetsAt) ? resetsAt : null,
resetDescription: parseResetDescription(periodEnd)
}
}
function grokRequestHeaders(session: GrokAuthSession): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${session.accessToken}`,
@@ -103,28 +131,22 @@ function resolveBillingConfig(data: GrokBillingResponse): GrokBillingConfig | nu
return null
}
function mapBillingResponse(
data: GrokBillingResponse,
function billingUsageResult(
windows: { weekly?: RateLimitWindow | null; monthly?: RateLimitWindow | null },
config: GrokBillingConfig,
session: GrokAuthSession
): ProviderRateLimits {
const config = resolveBillingConfig(data)
// Why: a 200 without credit usage means the plan has no weekly credits —
// 'unavailable' hides the bar (like Claude on API-key billing); 'error'
// would paint a permanent alert for a signed-in account that has no quota.
if (!config) {
return result('unavailable', 'Grok billing response did not include config')
}
const weekly = mapWeeklyCredits(config)
const tier = config.subscriptionTier?.trim()
const authLabel = session.email?.trim() || session.userId || 'Grok account'
const provenance = tier ? `${authLabel} (${tier})` : authLabel
return {
provider: 'grok',
session: null,
weekly,
weekly: windows.weekly ?? null,
...(windows.monthly ? { monthly: windows.monthly } : {}),
updatedAt: Date.now(),
error: weekly ? null : 'Grok billing response did not include credit usage',
status: weekly ? 'ok' : 'unavailable',
error: null,
status: 'ok',
usageMetadata: {
source: 'oauth',
authProvenance: provenance
@@ -132,6 +154,61 @@ function mapBillingResponse(
}
}
type GrokBillingFetchOutcome =
| { kind: 'data'; data: GrokBillingResponse }
| { kind: 'result'; result: ProviderRateLimits }
async function fetchBillingData(
url: string,
session: GrokAuthSession,
signal?: AbortSignal
): Promise<GrokBillingFetchOutcome> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
const res = await net.fetch(url, {
headers: grokRequestHeaders(session),
signal: requestSignal
})
if (res.status === 401 || res.status === 403) {
return {
kind: 'result',
result: result('error', `Grok usage request unauthorized (HTTP ${res.status})`)
}
}
if (!res.ok) {
return {
kind: 'result',
result: result('error', `Grok usage request failed (HTTP ${res.status})`)
}
}
const data: unknown = await res.json()
return {
kind: 'data',
data: typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {}
}
}
type GrokMonthlyFallbackOutcome =
| { kind: 'window'; window: RateLimitWindow | null }
| { kind: 'result'; result: ProviderRateLimits }
// Why: request failures propagate as 'error' (thrown errors reach the caller's
// catch) so the stale policy keeps the last good monthly snapshot — the
// 'unavailable' status would discard it. Only a successful response without
// monthly fields means the account truly has no visible quota.
async function fetchMonthlyUsageFallback(
session: GrokAuthSession,
signal?: AbortSignal
): Promise<GrokMonthlyFallbackOutcome> {
const outcome = await fetchBillingData(BILLING_DEFAULT_URL, session, signal)
if (outcome.kind === 'result') {
return outcome
}
const config = outcome.data.config ?? outcome.data
return { kind: 'window', window: mapMonthlyUsage(config) }
}
// Why: Orca never runs grok login; it only reads the session file the CLI updates.
export async function fetchGrokRateLimits(
options: { signal?: AbortSignal; authReadResult?: GrokAuthReadResult } = {}
@@ -152,24 +229,32 @@ export async function fetchGrokRateLimits(
}
try {
const signal = options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)])
: AbortSignal.timeout(API_TIMEOUT_MS)
const res = await net.fetch(BILLING_CREDITS_URL, {
headers: grokRequestHeaders(session),
signal
})
if (res.status === 401 || res.status === 403) {
return result('error', `Grok usage request unauthorized (HTTP ${res.status})`)
const outcome = await fetchBillingData(BILLING_CREDITS_URL, session, options.signal)
if (outcome.kind === 'result') {
return outcome.result
}
if (!res.ok) {
return result('error', `Grok usage request failed (HTTP ${res.status})`)
const config = resolveBillingConfig(outcome.data)
// Why: a 200 without credit usage means the plan has no weekly credits —
// 'unavailable' hides the bar (like Claude on API-key billing); 'error'
// would paint a permanent alert for a signed-in account that has no quota.
if (!config) {
return result('unavailable', 'Grok billing response did not include config')
}
const data: unknown = await res.json()
return mapBillingResponse(
typeof data === 'object' && data !== null ? (data as GrokBillingResponse) : {},
session
)
const weekly = mapWeeklyCredits(config)
if (weekly) {
return billingUsageResult({ weekly }, config, session)
}
// Why: unified-billing accounts report a monthly included-usage budget
// instead of weekly credits; the credits view omits creditUsagePercent
// for them, so read the default billing view before giving up.
const fallback = await fetchMonthlyUsageFallback(session, options.signal)
if (fallback.kind === 'result') {
return fallback.result
}
if (fallback.window) {
return billingUsageResult({ monthly: fallback.window }, config, session)
}
return result('unavailable', 'Grok billing response did not include credit usage')
} catch (err) {
return result('error', err instanceof Error ? err.message : 'Grok usage request failed')
}
+230 -1
View File
@@ -120,6 +120,19 @@ function unavailableProvider(
}
}
// Why: beforeEach caches snapshot objects whose updatedAt is pinned at suite
// start, so after 5 fake minutes every healthy provider looks stale and every
// activation degrades to a full fetch. Backoff tests that reason about the
// individual retry lane need healthy providers minted fresh at fetch time.
function mockFreshBackgroundProviderFetches(): void {
vi.mocked(fetchCodexRateLimits).mockImplementation(async () => okProvider('codex', 24))
vi.mocked(fetchGeminiRateLimits).mockImplementation(async () => okProvider('gemini', 0))
vi.mocked(fetchOpenCodeGoRateLimits).mockImplementation(async () => okProvider('opencode-go', 0))
vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0))
vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0))
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
}
function serviceInternals(service: RateLimitService): { fetchAll: () => Promise<void> } {
return service as unknown as { fetchAll: () => Promise<void> }
}
@@ -393,7 +406,7 @@ describe('RateLimitService', () => {
}
})
it('throttles repeated active-window retries while Claude is still failing', async () => {
it('backs off repeated active-window retries while Claude is still failing', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(errorProvider('claude', 'still failing'))
@@ -407,6 +420,7 @@ describe('RateLimitService', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
// First activation recovers immediately (retry timestamps start at 0).
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
@@ -415,11 +429,175 @@ describe('RateLimitService', () => {
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
// Two consecutive failures: the retry window doubled to 60s, so an
// activation at +30s must not hammer the endpoint again.
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('restore')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
// Three consecutive failures: 120s window.
await vi.advanceTimersByTimeAsync(60 * 1000)
window.emit('show')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(60 * 1000)
window.emit('restore')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('resets the retry backoff once Claude recovers', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits)
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
.mockResolvedValueOnce(okProvider('claude', 12))
.mockResolvedValue(errorProvider('claude', 'failing again'))
mockFreshBackgroundProviderFetches()
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
// Recovery fetch succeeds and must clear the failure streak.
await vi.advanceTimersByTimeAsync(60 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
expect(service.getState().claude?.status).toBe('ok')
// The stale-ok snapshot forces a full refresh, which fails again.
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4)
expect(service.getState().claude?.status).toBe('error')
// Consume the stale active-retry timestamp so the next windows measure
// the post-recovery streak rather than time elapsed before recovery.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(5)
// Post-recovery streak is 2 (not the pre-recovery 4): the window must be
// 60s, so +30s stays throttled and +60s retries.
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(5)
await vi.advanceTimersByTimeAsync(30 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(6)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('counts a stale-driven full fetch as the failing provider retry attempt', async () => {
vi.useFakeTimers()
try {
vi.mocked(fetchClaudeRateLimits).mockImplementation(async () =>
errorProvider('claude', 'still failing')
)
mockFreshBackgroundProviderFetches()
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(2)
// Healthy providers go stale after 5 minutes, so this activation runs a
// full fetch that also retries failing Claude (streak now 3 → 120s).
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
// Why: the full fetch was itself a retry. An activation moments later
// must not fire the individual failure lane ahead of the backoff window.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(3)
// Once the 120s window elapses, the individual retry lane fires again.
await vi.advanceTimersByTimeAsync(120 * 1000)
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(4)
service.stop()
} finally {
vi.useRealTimers()
}
})
it('keeps a settled error chip settled during background refetches instead of flashing fetching', async () => {
vi.useFakeTimers()
try {
const secondClaude = deferred<ProviderRateLimits>()
vi.mocked(fetchClaudeRateLimits)
.mockResolvedValueOnce(errorProvider('claude', 'still failing'))
.mockImplementationOnce(() => secondClaude.promise)
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24))
const service = new RateLimitService()
const window = new FakeRateLimitWindow()
const claudeStatuses: string[] = []
service.onStateChange((state) => {
if (state.claude) {
claudeStatuses.push(state.claude.status)
}
})
service.attach(asRateLimitWindow(window))
service.start({ fetchImmediately: false })
await vi.advanceTimersByTimeAsync(1000)
expect(service.getState().claude?.status).toBe('error')
// Why: the retry must not repaint the settled error chip as a loading
// "…" chip while the refetch is in flight — that is the flash users see
// every cycle when a provider is stuck failing.
window.emit('focus')
await vi.advanceTimersByTimeAsync(0)
expect(service.getState().claude?.status).toBe('error')
secondClaude.resolve(okProvider('claude', 12))
await vi.advanceTimersByTimeAsync(0)
expect(service.getState().claude?.status).toBe('ok')
const statusesAfterFirstSettle = claudeStatuses.slice(claudeStatuses.indexOf('error'))
expect(statusesAfterFirstSettle).not.toContain('fetching')
service.stop()
} finally {
vi.useRealTimers()
@@ -586,6 +764,39 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
})
it('does not refetch fresh provider data for replayed mobile subscriptions', async () => {
const service = new RateLimitService()
vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 10))
vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20))
await service.refreshIfStale()
await service.refreshIfStale()
await service.refreshIfStale()
expect(fetchClaudeRateLimits).toHaveBeenCalledOnce()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
})
it('does not queue a follow-up fetch when a mobile subscription replays mid-fetch', async () => {
const service = new RateLimitService()
const claude = deferred<ProviderRateLimits>()
const codex = deferred<ProviderRateLimits>()
vi.mocked(fetchClaudeRateLimits).mockReturnValue(claude.promise)
vi.mocked(fetchCodexRateLimits).mockReturnValue(codex.promise)
const firstRefresh = service.refreshIfStale()
await Promise.resolve()
const replayedRefresh = service.refreshIfStale()
claude.resolve(okProvider('claude', 10))
codex.resolve(okProvider('codex', 20))
await firstRefresh
await replayedRefresh
expect(fetchClaudeRateLimits).toHaveBeenCalledOnce()
expect(fetchCodexRateLimits).toHaveBeenCalledOnce()
})
it('waits for a queued explicit refresh when another fetch is already in flight', async () => {
const service = new RateLimitService()
const firstClaude = deferred<ProviderRateLimits>()
@@ -1107,6 +1318,24 @@ describe('RateLimitService', () => {
)
})
it('does not start overlapping inactive Claude preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
service.setInactiveClaudeAccountsResolver(() => [
{ id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
])
vi.mocked(fetchManagedAccountUsage).mockReturnValueOnce(accountFetch.promise)
const firstFetch = service.fetchInactiveClaudeAccountsOnOpen()
await Promise.resolve()
await service.fetchInactiveClaudeAccountsOnOpen()
expect(fetchManagedAccountUsage).toHaveBeenCalledTimes(1)
accountFetch.resolve(okProvider('claude', 50, Date.now()))
await firstFetch
})
it('does not start overlapping inactive Codex preview fetches', async () => {
const service = new RateLimitService()
const accountFetch = deferred<ProviderRateLimits>()
+99 -1
View File
@@ -77,6 +77,12 @@ const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never crea
const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms.
const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts
const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS
// Why: a persistent failure (bad auth, unsupported plan) retried at the 30s
// floor hammers provider endpoints — Claude's tight-budget usage endpoint
// starts returning 429s — without ever recovering. Back off per consecutive
// failure, capped at the background poll cadence.
const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS
const MAX_ACTIVE_FAILURE_STREAK = 8
// Why: these providers have a dedicated fetch cycle, so an activation retry can
// refresh just the failing one. Providers without one force a full fetchAll, so
// their error retries stay on the 5-minute cadence to protect Claude's budget.
@@ -152,6 +158,18 @@ export class RateLimitService {
grok: 0,
antigravity: 0
}
// Why: consecutive applied failures per provider drive exponential backoff of
// the fast activation-retry lane; reset on any successful/unavailable result.
private activeFailureStreakByProvider: Record<ActiveRateLimitProvider, number> = {
claude: 0,
codex: 0,
gemini: 0,
'opencode-go': 0,
kimi: 0,
minimax: 0,
grok: 0,
antigravity: 0
}
private mainWindow: BrowserWindow | null = null
private detachWindowListeners: (() => void) | null = null
private isFetching = false
@@ -329,6 +347,14 @@ export class RateLimitService {
return this.getState()
}
async refreshIfStale(): Promise<RateLimitState> {
// Why: reconnecting mobile subscribers need fresh backgrounded-desktop data,
// but replaying a subscription must not queue another forced provider fetch.
const plan = this.getActiveWindowRefreshPlan(Date.now())
await this.runActiveWindowRefreshPlan(plan)
return this.getState()
}
async refreshGrok(): Promise<RateLimitState> {
await this.fetchGrokOnly({ force: true })
return this.getState()
@@ -358,6 +384,8 @@ export class RateLimitService {
}
this.codexFetchTarget = nextTarget
this.codexFetchGeneration += 1
// Why: a new account/target starts with a clean retry schedule.
this.activeFailureStreakByProvider.codex = 0
this.inactiveCodexAccountsGeneration += 1
this.pruneInactiveCodexState()
this.lastInactiveCodexFetchAt = 0
@@ -377,6 +405,7 @@ export class RateLimitService {
const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget)
this.codexFetchTarget = nextTarget
this.codexFetchGeneration += 1
this.activeFailureStreakByProvider.codex = 0
this.updateState({
...this.state,
codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex')
@@ -426,6 +455,8 @@ export class RateLimitService {
this.inactiveClaudeAccountsGeneration += 1
this.pruneInactiveClaudeState()
this.claudeFetchGeneration += 1
// Why: a new account/target starts with a clean retry schedule.
this.activeFailureStreakByProvider.claude = 0
this.lastInactiveClaudeFetchAt = 0
this.updateState({
...this.state,
@@ -440,6 +471,7 @@ export class RateLimitService {
const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget)
this.claudeFetchTarget = nextTarget
this.claudeFetchGeneration += 1
this.activeFailureStreakByProvider.claude = 0
this.updateState({
...this.state,
claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude')
@@ -453,6 +485,9 @@ export class RateLimitService {
return
}
this.pruneInactiveClaudeState()
if (this.inactiveClaudeFetching.size > 0) {
return
}
const accounts = this.inactiveClaudeAccountsResolver?.() ?? []
if (accounts.length === 0) {
return
@@ -764,7 +799,11 @@ export class RateLimitService {
if (limits.status === 'error') {
const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider]
const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider)
? ACTIVE_FAILURE_REFETCH_MS
? Math.min(
ACTIVE_FAILURE_REFETCH_MS *
2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1),
MAX_ACTIVE_FAILURE_REFETCH_MS
)
: MIN_REFETCH_MS
if (now - lastRetryAt >= throttleMs) {
retryableFailures.push(provider)
@@ -783,6 +822,18 @@ export class RateLimitService {
return
}
if (plan.kind === 'full') {
// Why: a full fetch retries failing providers too. Restart their retry
// clocks so the individual failure lane doesn't fire again right after,
// ahead of its backoff window. Skip when a fetch is already in flight —
// fetchAll would no-op and the throttle must not be consumed for free.
if (!this.isFetching) {
const now = Date.now()
for (const { provider, limits } of this.getActiveProviderState()) {
if (limits?.status === 'error') {
this.lastActiveFailureRetryAtByProvider[provider] = now
}
}
}
await this.fetchAll()
return
}
@@ -1242,6 +1293,22 @@ export class RateLimitService {
}
}
private trackActiveFailureStreak(
provider: ActiveRateLimitProvider,
fresh: ProviderRateLimits
): void {
if (fresh.status === 'error') {
this.activeFailureStreakByProvider[provider] = Math.min(
this.activeFailureStreakByProvider[provider] + 1,
MAX_ACTIVE_FAILURE_STREAK
)
return
}
if (fresh.status === 'ok' || fresh.status === 'unavailable') {
this.activeFailureStreakByProvider[provider] = 0
}
}
private withFetchingStatus(
current: ProviderRateLimits | null,
provider:
@@ -1264,6 +1331,13 @@ export class RateLimitService {
status: 'fetching'
}
}
// Why: repainting a settled chip as "fetching" on every background refetch
// makes the status bar flash "…" → error each retry cycle when a provider
// is persistently failing. Keep the settled state visible until the new
// result lands; only providers with no settled state show a loading chip.
if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') {
return current
}
return { ...current, status: 'fetching' }
}
@@ -1481,6 +1555,22 @@ export class RateLimitService {
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
if (shouldApplyClaude) {
this.trackActiveFailureStreak('claude', claude)
}
if (shouldApplyCodex) {
this.trackActiveFailureStreak('codex', codex)
}
this.trackActiveFailureStreak('gemini', gemini)
this.trackActiveFailureStreak('antigravity', antigravity)
if (shouldApplyOpencode) {
this.trackActiveFailureStreak('opencode-go', opencodeGo)
}
this.trackActiveFailureStreak('kimi', kimi)
if (shouldApplyMiniMax) {
this.trackActiveFailureStreak('minimax', miniMax)
}
// Why: account switches can race in-flight Codex fetches. Only apply a
// Codex result if both the selected-account provenance and the request
// generation still match, otherwise an old account could overwrite the
@@ -1523,6 +1613,7 @@ export class RateLimitService {
error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error',
status: 'error'
} satisfies ProviderRateLimits)
this.trackActiveFailureStreak('grok', grok)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
@@ -1575,6 +1666,9 @@ export class RateLimitService {
const shouldApplyCodex =
codexGeneration === this.codexFetchGeneration && codexProvenance === latestCodexProvenance
if (shouldApplyCodex) {
this.trackActiveFailureStreak('codex', codex)
}
this.updateState({
...this.state,
codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex
@@ -1630,6 +1724,9 @@ export class RateLimitService {
claudeProvenance === latestClaudeProvenance &&
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
if (shouldApplyClaude) {
this.trackActiveFailureStreak('claude', claude)
}
this.updateState({
...this.state,
claude: shouldApplyClaude
@@ -1669,6 +1766,7 @@ export class RateLimitService {
return
}
this.trackActiveFailureStreak('grok', grok)
this.updateState({
...this.state,
grok: this.applyStalePolicy(grok, previousState.grok)
+577
View File
@@ -7163,6 +7163,153 @@ describe('OrcaRuntimeService', () => {
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
})
it('aligns a restored session and pre-response bytes to the provider sequence', async () => {
const { runtime } = createSideEffectRuntime()
// Simulate a stream-socket byte winning the race with the spawn response.
runtime.onPtyData('pty-restored', 'queued', 100)
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restored',
{ value: 900, generation: 'continued' },
0
)
).toBe(906)
runtime.onPtyData('pty-restored', 'fresh', 101)
expect(runtime.getPtyOutputSequence('pty-restored')).toBe(911)
await expect(runtime.serializeMainTerminalBuffer('pty-restored')).resolves.toMatchObject({
data: expect.stringContaining('queuedfresh'),
seq: 911,
source: 'headless'
})
})
it('does not double a fresh daemon sequence on same-main reattach', () => {
const { runtime } = createSideEffectRuntime()
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-fresh',
{ value: 0, generation: 'reset' },
0
)
).toBe(0)
runtime.onPtyData('pty-fresh', 'fresh output', 100)
const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-fresh')
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-fresh',
{ value: sequenceBeforeReattach, generation: 'continued' },
sequenceBeforeReattach
)
).toBe(sequenceBeforeReattach)
})
it('does not jump ahead of delayed bytes covered by a reattach snapshot', () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-delayed',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-delayed', 'before', 100)
const sequenceBeforeReattach = runtime.getPtyOutputSequence('pty-delayed')
const delayedCoveredBytes = 'queued'
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-delayed',
{
value: sequenceBeforeReattach + delayedCoveredBytes.length,
generation: 'continued'
},
sequenceBeforeReattach
)
).toBe(sequenceBeforeReattach)
runtime.onPtyData('pty-delayed', delayedCoveredBytes, 101)
expect(runtime.getPtyOutputSequence('pty-delayed')).toBe(
sequenceBeforeReattach + delayedCoveredBytes.length
)
})
it('retains bytes emitted before a fresh daemon spawn resolves', async () => {
const { runtime } = createSideEffectRuntime()
const earlyOutput = '\x1b]0;Fresh shell\x07early prompt'
runtime.onPtyData('pty-fresh', earlyOutput, 100)
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-fresh',
{ value: 0, generation: 'reset' },
0
)
).toBe(earlyOutput.length)
await expect(runtime.serializeMainTerminalBuffer('pty-fresh')).resolves.toMatchObject({
data: expect.stringContaining('early prompt'),
lastTitle: 'Fresh shell',
seq: earlyOutput.length
})
})
it('drops stale headless state without rewinding the runtime sequence', async () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restarted',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-restarted', 'old generation', 100)
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-restarted')
expect(
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restarted',
{ value: 0, generation: 'reset' },
sequenceBeforeRespawn
)
).toBe(sequenceBeforeRespawn)
runtime.onPtyData('pty-restarted', 'new generation', 101)
await expect(runtime.serializeMainTerminalBuffer('pty-restarted')).resolves.toMatchObject({
data: expect.not.stringContaining('old generation'),
seq: sequenceBeforeRespawn + 'new generation'.length
})
})
it('keeps active listener sequences monotonic across a daemon reset', () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-reset',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-reset', 'old', 100)
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-reset')
const observedSequences: number[] = []
runtime.subscribeToTerminalData('pty-reset', (_data, meta) => {
if (typeof meta?.seq === 'number') {
observedSequences.push(meta.seq)
}
})
runtime.onPtyData('pty-reset', 'early', 101)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-reset',
{ value: 0, generation: 'reset' },
sequenceBeforeRespawn
)
runtime.onPtyData('pty-reset', 'later', 102)
expect(observedSequences).toEqual([
sequenceBeforeRespawn + 'early'.length,
sequenceBeforeRespawn + 'early'.length + 'later'.length
])
})
it('carries the synthetic permission BEL as a bell fact', () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
@@ -7263,6 +7410,318 @@ describe('OrcaRuntimeService', () => {
expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent')
})
it('falls back to the provider snapshot for a restored PTY with no mounted renderer', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(serializeBuffer).not.toHaveBeenCalled()
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot).toEqual({
data: 'restored screen\r\n',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
cwd: '/projects/restored',
seq: 900,
source: 'headless'
})
})
it('falls back to provider history when a mounted renderer has not hydrated yet', async () => {
const { runtime } = createSideEffectRuntime()
const serializeBuffer = vi.fn().mockResolvedValue({
data: '',
cols: 80,
rows: 24
})
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: 'restored history\r\n',
cols: 120,
rows: 40,
seq: 900,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer,
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(serializeBuffer).toHaveBeenCalledOnce()
expect(serializeProviderBuffer).toHaveBeenCalledWith('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot).toMatchObject({
data: '',
scrollbackAnsi: 'restored history\r\n',
source: 'headless'
})
})
it('keeps an empty renderer snapshot when the provider has no retained content', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi: '',
cols: 120,
rows: 40,
seq: 0,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeBuffer: vi.fn().mockResolvedValue({ data: '', cols: 51, rows: 40 }),
serializeProviderBuffer,
hasRendererSerializer: () => true
})
const snapshot = await runtime.serializeTerminalBuffer('pty-new')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
expect(snapshot).toMatchObject({ data: '', cols: 51, rows: 40, source: 'renderer' })
})
it('does not let pre-response bytes hide restored provider history', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored history\r\nqueued',
cols: 80,
rows: 24,
seq: 906,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.onPtyData('pty-restored', 'queued', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restored',
{ value: 900, generation: 'continued' },
0
)
const snapshot = await runtime.serializeTerminalBuffer('pty-restored', {
scrollbackRows: 5000
})
expect(snapshot?.data).toContain('restored history')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('keeps restored provider history authoritative after later live output', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored history\r\nlater output',
cols: 80,
rows: 24,
seq: 912,
source: 'headless'
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-restored',
{ value: 900, generation: 'continued' },
0
)
runtime.onPtyData('pty-restored', 'later output', 100)
const snapshot = await runtime.serializeTerminalBuffer('pty-restored')
expect(snapshot?.data).toContain('restored history')
expect(serializeProviderBuffer).toHaveBeenCalledOnce()
})
it('uses provider alternate-screen state while a partial model is unsafe', async () => {
const { runtime } = createSideEffectRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer: vi.fn().mockResolvedValue({
data: 'restored tui',
cols: 80,
rows: 24,
seq: 903,
source: 'headless',
alternateScreen: true
}),
hasRendererSerializer: () => false
})
runtime.onPtyData('pty-tui', 'tui', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-tui',
{ value: 900, generation: 'continued' },
0
)
await runtime.serializeTerminalBuffer('pty-tui')
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
})
it('tracks live alternate-screen transitions after a provider snapshot', async () => {
const { runtime } = createSideEffectRuntime()
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: 'restored tui',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-tui',
{ value: 900, generation: 'continued' },
0
)
await runtime.serializeTerminalBuffer('pty-tui')
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
runtime.onPtyData('pty-tui', '\x1b[?1049l', 100)
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(false)
runtime.onPtyData('pty-tui', '\x1b[?1049h', 101)
expect(runtime.isTerminalAlternateScreen('pty-tui')).toBe(true)
})
it('keeps mode transitions that race a provider snapshot response', async () => {
const { runtime } = createSideEffectRuntime()
let resolveProviderSnapshot:
| ((snapshot: {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}) => void)
| undefined
const serializeProviderBuffer = vi.fn(
() =>
new Promise<{
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}>((resolve) => {
resolveProviderSnapshot = resolve
})
)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-tui-race',
{ value: 900, generation: 'continued' },
0
)
const snapshotPromise = runtime.serializeTerminalBuffer('pty-tui-race')
await vi.waitFor(() => expect(resolveProviderSnapshot).toBeDefined())
runtime.onPtyData('pty-tui-race', '\x1b[?1049l', 100)
resolveProviderSnapshot?.({
data: 'captured alt screen',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
await snapshotPromise
expect(runtime.isTerminalAlternateScreen('pty-tui-race')).toBe(false)
})
it('translates reset provider snapshots without retaining the old title', async () => {
const { runtime } = createSideEffectRuntime()
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-replaced',
{ value: 0, generation: 'reset' },
0
)
runtime.onPtyData('pty-replaced', '\x1b]0;Old process\x07old', 100)
const sequenceBeforeRespawn = runtime.getPtyOutputSequence('pty-replaced')
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer: vi.fn().mockResolvedValue({
data: 'new process',
cols: 80,
rows: 24,
seq: 'new process'.length,
source: 'headless',
lastTitle: 'New process'
}),
hasRendererSerializer: () => false
})
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-replaced',
{ value: 0, generation: 'reset' },
sequenceBeforeRespawn
)
await expect(runtime.serializeTerminalBuffer('pty-replaced')).resolves.toMatchObject({
lastTitle: 'New process',
seq: sequenceBeforeRespawn + 'new process'.length
})
})
it('prefers the tracked title over the headless emulator lastTitle', async () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
@@ -22174,6 +22633,124 @@ describe('OrcaRuntimeService', () => {
})
})
it('attributes live legacy PTYs from saved layout bindings when their panes are hidden', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...session,
tabsByWorktree: {
[TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({
...tab,
ptyId: null
}))
}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
// Legacy local PTYs have opaque ids and the local provider cannot recover cwd.
listProcesses: vi.fn(async () => [{ id: 'persisted-pty', cwd: '', title: 'shell' }])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true,
hasAttachedPty: true,
liveTerminalCount: 1
})
})
it('prefers migrated layout ownership over a worktree id frozen in the PTY id', async () => {
const priorWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-before-rename`
const migratedPtyId = `${priorWorktreeId}@@daemon-controller-pty`
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...session,
tabsByWorktree: {
[TEST_WORKTREE_ID]: session.tabsByWorktree[TEST_WORKTREE_ID]!.map((tab) => ({
...tab,
ptyId: null
}))
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: migratedPtyId })
}
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
listProcesses: vi.fn(async () => [{ id: migratedPtyId, cwd: '', title: 'shell' }])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true,
hasAttachedPty: true,
liveTerminalCount: 1
})
})
it('preserves deferred startup activity before restored terminal panes mount', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal({
activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID]
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
write: vi.fn(() => true),
kill: vi.fn(() => true),
getForegroundProcess: vi.fn(async () => null),
listProcesses: vi.fn(async () => [])
})
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true
})
})
it('marks saved browser tabs as host sidebar activity like desktop', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'browser-1',
worktreeId: TEST_WORKTREE_ID,
url: 'https://example.com',
title: 'Example',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}
]
}
})
)
const runtime = new OrcaRuntimeService(runtimeStore as never)
const { worktrees } = await runtime.getWorktreePs()
expect(worktrees[0]).toMatchObject({
worktreeId: TEST_WORKTREE_ID,
hasHostSidebarActivity: true
})
})
it('falls back to the path-keyed GitHub cache entry', async () => {
const runtimeStore = {
...store,
+310 -6
View File
@@ -25,6 +25,7 @@ import type {
TerminalSideEffectFact
} from '../../shared/terminal-side-effect-facts'
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
@@ -351,6 +352,7 @@ import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher'
import type { EmulatorBridge } from '../emulator/emulator-bridge'
import { RuntimeFileCommands } from './orca-runtime-files'
import { RuntimeGitCommands } from './orca-runtime-git'
import type { PtyProviderBufferSnapshot } from '../providers/types'
import { ClaudeAgentTeamsService } from './claude-agent-teams-service'
import type {
AgentTeamsTmuxCompatRequest,
@@ -1191,6 +1193,8 @@ type RuntimeHeadlessTerminal = {
type HeadlessSeedMetadata = {
cwd?: string | null
oscLinks?: TerminalOscLinkRange[]
/** Cold restore history must outrank a model that only saw new-generation bytes. */
preferProviderIfExisting?: boolean
/** Persisted kitty flags from the daemon snapshot, re-applied to the fresh
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
* (terminal-query-authority.md §kitty). */
@@ -1231,6 +1235,11 @@ type RuntimePtyController = {
ptyId: string,
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
/** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */
serializeProviderBuffer?(
ptyId: string,
opts?: { scrollbackRows?: number }
): Promise<PtyProviderBufferSnapshot | null>
// Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip
// hydration when no renderer is authoritative for this PTY. See
// docs/mobile-prefer-renderer-scrollback.md.
@@ -2260,6 +2269,14 @@ export class OrcaRuntimeService {
private titleObservationSequence = 0
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
private ptyOutputSequenceById = new Map<string, number>()
private providerSequenceInitializedPtys = new Set<string>()
private providerSequenceOffsetByPtyId = new Map<string, number>()
private providerSnapshotPreferredPtys = new Set<string>()
private providerModeTrackersByPtyId = new Map<string, TerminalKittyKeyboardModeTracker>()
private providerModeSnapshotScansByPtyId = new Map<
string,
Set<TerminalKittyKeyboardModeTracker>
>()
private recentPtyPathCandidatesById = new Map<string, string[]>()
// Why: OSC 9999 status can span PTY chunks. Keeping parser state in the
// runtime lets hidden/model-owned terminals observe agent state without a
@@ -5810,6 +5827,10 @@ export class OrcaRuntimeService {
onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number {
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars
this.ptyOutputSequenceById.set(ptyId, outputSequence)
this.providerModeTrackersByPtyId.get(ptyId)?.scan(data)
for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) {
tracker.scan(data)
}
const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data)
const cwd = osc7Metadata.cwd
const cwdChanged = osc7Metadata.cwdChanged
@@ -6534,6 +6555,29 @@ export class OrcaRuntimeService {
this.ptyTitleTrackersByPtyId.delete(ptyId)
}
private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void {
// Why: a replacement daemon session can reuse the PTY id, but title/parser
// state from the prior process must not bleed into its snapshots or chunks.
this.disposePtyTitleTracker(ptyId)
this.oscTitleScanTailByPtyId.delete(ptyId)
this.osc7ScanTailByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
const pty = this.ptysById.get(ptyId)
if (pty) {
pty.lastOscTitle = null
pty.lastOscTitleAt = null
pty.lastAgentStatus = null
pty.managementTitle = null
pty.managementTitleAt = null
}
for (const leaf of this.getLeavesForPty(ptyId)) {
leaf.lastOscTitle = null
leaf.lastOscTitleAt = null
leaf.lastAgentStatus = null
}
this.clearAgentRowSnapshotsForPty(ptyId)
}
private setTerminalSideEffectConsumerAvailable(available: boolean): void {
const nextAvailable = available && this.onTerminalSideEffects !== null
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
@@ -6724,6 +6768,80 @@ export class OrcaRuntimeService {
return this.ptyOutputSequenceById.get(ptyId) ?? 0
}
synchronizePtyOutputSequenceFromProvider(
ptyId: string,
providerSequence: { value: number; generation: 'continued' | 'reset' },
runtimeSequenceAtSpawnStart = 0
): number {
if (
!Number.isFinite(providerSequence.value) ||
providerSequence.value < 0 ||
!Number.isFinite(runtimeSequenceAtSpawnStart) ||
runtimeSequenceAtSpawnStart < 0
) {
return this.getPtyOutputSequence(ptyId)
}
const baseline = Math.floor(providerSequence.value)
const currentSequence = this.getPtyOutputSequence(ptyId)
const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart))
const postSpawnSequence = currentSequence - sequenceAtSpawnStart
const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId)
const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0
const providerOffset =
providerSequence.generation === 'reset'
? sequenceAtSpawnStart
: (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0)
const providerBaseline = providerOffset + baseline
if (providerSequence.generation === 'reset') {
// Why: daemon respawn/cold restore starts a new absolute domain. Old
// emulator state cannot remain authoritative over the replacement.
if (replacesExistingRuntimeGeneration) {
this.disposeHeadlessTerminal(ptyId)
}
this.providerModeTrackersByPtyId.delete(ptyId)
if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) {
this.resetTrackedTerminalStateForProviderGeneration(ptyId)
}
}
const synchronizedSequence =
providerSequence.generation === 'reset'
? currentSequence
: wasInitialized
? currentSequence
: providerBaseline + postSpawnSequence
this.ptyOutputSequenceById.set(ptyId, synchronizedSequence)
this.providerSequenceInitializedPtys.add(ptyId)
this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset)
const snapshotMayCoverMissingState =
(providerSequence.generation === 'continued' && !wasInitialized) ||
(postSpawnSequence > 0 &&
providerSequence.generation === 'reset' &&
replacesExistingRuntimeGeneration) ||
(providerSequence.generation === 'continued' &&
wasInitialized &&
providerBaseline > currentSequence)
if (snapshotMayCoverMissingState) {
// Why: bytes can cross the control/stream sockets around attach. Until a
// full renderer/provider snapshot is available, a partial model is unsafe.
this.providerSnapshotPreferredPtys.add(ptyId)
} else if (providerSequence.generation === 'reset') {
this.providerSnapshotPreferredPtys.delete(ptyId)
}
const headless = this.headlessTerminals.get(ptyId)
if (headless && !wasInitialized && providerSequence.generation === 'continued') {
// Why: daemon bytes can reach main just before spawn resolves. Queue the
// baseline behind those writes so their emulator sequence is rebased too.
headless.writeChain = headless.writeChain.then(() => {
headless.outputSequence = synchronizedSequence
})
}
return synchronizedSequence
}
subscribeToTerminalData(
ptyId: string,
listener: (data: string, meta?: { seq?: number; rawLength?: number; cwd?: string }) => void
@@ -6896,7 +7014,8 @@ export class OrcaRuntimeService {
// Why: hidden-output recovery is initiated by the desktop renderer. If the
// runtime has not built headless state yet, the mounted xterm is still the
// best available state and avoids a false "snapshot unavailable" result.
return this.serializeRendererTerminalBuffer(ptyId, opts)
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts)
}
async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> {
@@ -6921,10 +7040,17 @@ export class OrcaRuntimeService {
// scrollback to mobile so it rewraps at the new cols, but alternate-screen
// TUIs (vim, Claude Code) own their repaint and have no scrollback — for
// those the mobile client just resizes xterm geometry and consumes the
// TUI's own redraw, so the resize re-stream must be skipped. Returns false
// when there is no headless emulator (resize falls back to geometry-only).
// TUI's own redraw, so the resize re-stream must be skipped. Provider state
// covers restored PTYs whose main-side emulator is only a partial suffix.
isTerminalAlternateScreen(ptyId: string): boolean {
return this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? false
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false
}
return (
this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ??
this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ??
false
)
}
// Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch
@@ -6948,10 +7074,14 @@ export class OrcaRuntimeService {
if (existing) {
// Why: emulator already has live data — re-seeding would duplicate
// every byte. The seed is only valid when the emulator is fresh.
if (metadata.preferProviderIfExisting) {
this.providerSnapshotPreferredPtys.add(ptyId)
}
return
}
const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
state.outputSequence = this.getPtyOutputSequence(ptyId)
this.headlessTerminals.set(ptyId, state)
this.recordOsc7MetadataForPty(ptyId, data)
this.recordRecentPtyOutputForPathProvenance(ptyId, data)
@@ -6973,6 +7103,7 @@ export class OrcaRuntimeService {
if (metadata.oscLinks !== undefined) {
state.emulator.setRestoredOscLinks(metadata.oscLinks)
}
this.providerSnapshotPreferredPtys.delete(ptyId)
})
.catch(() => {
// Seeding is best-effort; live data will continue to populate the
@@ -6990,7 +7121,8 @@ export class OrcaRuntimeService {
if (this.headlessHydrationState.has(ptyId)) {
return
}
if (this.headlessTerminals.has(ptyId)) {
const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId)
if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) {
// Daemon-snapshot seed already populated the emulator — skip hydration.
this.headlessHydrationState.set(ptyId, 'done')
return
@@ -7005,11 +7137,18 @@ export class OrcaRuntimeService {
return
}
if (providerSnapshotPreferred) {
// Why: a stream byte can create a partial model before restored history
// arrives. A mounted renderer snapshot can safely replace that model.
this.disposeHeadlessTerminal(ptyId)
}
this.headlessHydrationState.set(ptyId, 'pending')
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
// Why: hydration writes below never set forwardQueryReplies (main-side
// replay guard) — renderer-buffer snapshots can embed stale queries.
const state = this.createPtyHeadlessTerminalState(ptyId, dims)
state.outputSequence = this.getPtyOutputSequence(ptyId)
this.headlessTerminals.set(ptyId, state)
// Why: append the seed work to writeChain so live writes queued by
@@ -7048,6 +7187,7 @@ export class OrcaRuntimeService {
state.emulator.setLastTitle(seedTitle)
this.applySeededAgentStatus(ptyId, seedTitle)
}
this.providerSnapshotPreferredPtys.delete(ptyId)
} catch {
// Hydration is best-effort. Live writes continue via the same
// writeChain that this catch-arm leaves intact.
@@ -7240,12 +7380,39 @@ export class OrcaRuntimeService {
alternateScreen?: boolean
pendingEscapeTailAnsi?: string
} | null> {
if (this.providerSnapshotPreferredPtys.has(ptyId)) {
// Why: pre-attach stream bytes only form a suffix of restored state. A
// sequenced provider snapshot safely reconciles live bytes; renderer is
// the fallback when an older provider cannot expose that boundary.
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
if (providerSnapshot) {
return providerSnapshot
}
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
if (rendererSnapshot) {
return rendererSnapshot
}
}
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
if (headlessSnapshot) {
return headlessSnapshot
}
return this.serializeRendererTerminalBuffer(ptyId, opts)
const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts)
if (!rendererSnapshot) {
return this.serializeProviderTerminalBuffer(ptyId, opts)
}
if (rendererSnapshot.data.length > 0) {
return rendererSnapshot
}
// Why: parked desktop panes register serializers before their xterm has
// hydrated. Treat that empty shell as provisional so retained provider
// history can restore mobile without forcing the desktop pane to mount.
const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts)
return providerSnapshot &&
(providerSnapshot.data.length > 0 || Boolean(providerSnapshot.scrollbackAnsi))
? providerSnapshot
: rendererSnapshot
}
private async serializeRendererTerminalBuffer(
@@ -7260,6 +7427,9 @@ export class OrcaRuntimeService {
source?: 'renderer'
oscLinks?: TerminalOscLinkRange[]
} | null> {
if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) {
return null
}
let rendererSnapshot: {
data: string
cols: number
@@ -7290,6 +7460,49 @@ export class OrcaRuntimeService {
: null
}
private async serializeProviderTerminalBuffer(
ptyId: string,
opts: { scrollbackRows?: number } = {}
): Promise<PtyProviderBufferSnapshot | null> {
const liveModeTracker = new TerminalKittyKeyboardModeTracker()
let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId)
if (!liveModeTrackers) {
liveModeTrackers = new Set()
this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers)
}
liveModeTrackers.add(liveModeTracker)
try {
// Why: daemon PTYs survive an app relaunch before any renderer mounts.
// Mobile still needs their retained history without navigating desktop.
const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts)
if (typeof snapshot?.alternateScreen === 'boolean') {
const modeTracker = new TerminalKittyKeyboardModeTracker()
if (snapshot.alternateScreen) {
modeTracker.scan('\x1b[?1049h')
}
if (liveModeTracker.hasObservedAlternateScreenSwitch) {
modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l')
}
this.providerModeTrackersByPtyId.set(ptyId, modeTracker)
}
if (!snapshot) {
return null
}
const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0
return this.preferTrackedLastTitle(ptyId, {
...snapshot,
seq: providerOffset + snapshot.seq
})
} catch {
return null
} finally {
liveModeTrackers.delete(liveModeTracker)
if (liveModeTrackers.size === 0) {
this.providerModeSnapshotScansByPtyId.delete(ptyId)
}
}
}
private async withVisibleSnapshotFallback(
ptyId: string,
read: RuntimeTerminalRead,
@@ -8063,6 +8276,17 @@ export class OrcaRuntimeService {
])
}
// Why: connection migration replays subscriptions; use the stale-aware lane
// so a reconnect cannot turn one mobile viewer into continuous forced fetches.
async refreshAccountsForMobileSubscriber(): Promise<void> {
const { rateLimits } = this.requireAccountServices()
await Promise.allSettled([
rateLimits.refreshIfStale(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
selectClaudeAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.selectAccount(accountId)
}
@@ -8447,6 +8671,11 @@ export class OrcaRuntimeService {
this.clearWaitBlockedCheckState(ptyId)
this.recentPtyPathCandidatesById.delete(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
this.providerSequenceInitializedPtys.delete(ptyId)
this.providerSequenceOffsetByPtyId.delete(ptyId)
this.providerSnapshotPreferredPtys.delete(ptyId)
this.providerModeTrackersByPtyId.delete(ptyId)
this.providerModeSnapshotScansByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
@@ -11491,6 +11720,7 @@ export class OrcaRuntimeService {
const previousLastOutputAt = summary.lastOutputAt
summary.liveTerminalCount += 1
summary.hasAttachedPty = true
summary.hasHostSidebarActivity = true
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
summary.status = mergeWorktreeStatus(summary.status, 'active')
if (
@@ -11502,6 +11732,19 @@ export class OrcaRuntimeService {
}
const session = this.store?.getWorkspaceSession?.()
for (const worktreeId of session?.activeWorktreeIdsOnShutdown ?? []) {
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (summary) {
// Why: desktop advertises deferred reattach ids as live before their
// panes mount; mobile must preserve the same startup activity view.
summary.hasHostSidebarActivity = true
}
}
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
if (tabs.length === 0) {
continue
@@ -11531,6 +11774,23 @@ export class OrcaRuntimeService {
}
}
for (const [worktreeId, tabs] of Object.entries(session?.browserTabsByWorktree ?? {})) {
if (tabs.length === 0) {
continue
}
const summary = this.getSummaryForRuntimeWorktreeId(
summaries,
runtimeWorktreeSummaryPathIndex,
missingRuntimeWorktreeIds,
worktreeId
)
if (summary) {
// Why: desktop's sleeping predicate treats any open browser workspace
// as active, so the mobile host projection must preserve that parity.
summary.hasHostSidebarActivity = true
}
}
// Why: surface the desktop's focused worktree so mobile can scroll it into
// view and highlight it. Resolve through getSummaryForRuntimeWorktreeId so
// SSH/remote path-projected ids match the same way tabsByWorktree does.
@@ -20657,10 +20917,16 @@ export class OrcaRuntimeService {
return null
}
const sessions = sessionsResult.value
const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings(
this.store?.getWorkspaceSession?.()
)
const livePtyIds = new Set(sessions.map((session) => session.id))
for (const session of sessions) {
this.adoptControllerTerminalHandle(session.id, session.terminalHandle)
// Why: workspace identity migration rekeys persisted ownership while a
// running daemon PTY keeps the worktree id minted into its session id.
const worktreeId =
persistedWorktreeIdByPtyId.get(session.id) ??
inferWorktreeIdFromPtyId(session.id) ??
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
if (targetWorktreeId && worktreeId !== targetWorktreeId) {
@@ -20725,6 +20991,11 @@ export class OrcaRuntimeService {
this.clearWaitBlockedCheckState(ptyId)
this.recentPtyPathCandidatesById.delete(ptyId)
this.ptyOutputSequenceById.delete(ptyId)
this.providerSequenceInitializedPtys.delete(ptyId)
this.providerSequenceOffsetByPtyId.delete(ptyId)
this.providerSnapshotPreferredPtys.delete(ptyId)
this.providerModeTrackersByPtyId.delete(ptyId)
this.providerModeSnapshotScansByPtyId.delete(ptyId)
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
this.terminalSpawnCommandsByPtyId.delete(ptyId)
this.disposePtyTitleTracker(ptyId)
@@ -26956,6 +27227,39 @@ function inferWorktreeIdFromPtyId(ptyId: string): string | null {
return parsePtySessionId(ptyId).worktreeId
}
function indexPersistedPtyWorktreeBindings(
session: WorkspaceSessionState | null | undefined
): ReadonlyMap<string, string> {
const worktreeIdByPtyId = new Map<string, string>()
const ambiguousPtyIds = new Set<string>()
const bind = (ptyId: string | null | undefined, worktreeId: string): void => {
if (!ptyId || ambiguousPtyIds.has(ptyId)) {
return
}
const existingWorktreeId = worktreeIdByPtyId.get(ptyId)
if (existingWorktreeId && existingWorktreeId !== worktreeId) {
// Why: corrupt/stale duplicate bindings must not attribute a live PTY to
// whichever workspace happened to be visited first.
worktreeIdByPtyId.delete(ptyId)
ambiguousPtyIds.add(ptyId)
return
}
worktreeIdByPtyId.set(ptyId, worktreeId)
}
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
bind(tab.ptyId, worktreeId)
bind(session?.remoteSessionIdsByTabId?.[tab.id], worktreeId)
const layout = session?.terminalLayoutsByTabId[tab.id]
for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) {
bind(ptyId, worktreeId)
}
}
}
return worktreeIdByPtyId
}
function setsEqual<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
if (a.size !== b.size) {
return false
@@ -337,6 +337,11 @@ describe('remote runtime request connection integration', () => {
listener({ claude: null, codex: null })
}
},
refreshAccountsForMobileSubscriber: async () => {
for (const listener of accountsListeners) {
listener({ claude: null, codex: null })
}
},
onAccountsChanged: (listener: (snapshot: unknown) => void) => {
accountsListeners.add(listener)
return () => accountsListeners.delete(listener)
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod } from '../core'
import { ACCOUNT_METHODS } from './accounts'
function method(name: string) {
const found = ACCOUNT_METHODS.find((candidate) => candidate.name === name)
if (!found) {
throw new Error(`Missing method ${name}`)
}
return found
}
describe('account RPC methods', () => {
it('keeps explicit account-list refreshes on the forced refresh lane', async () => {
const snapshot = { claude: null, codex: null }
const runtime = {
refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined),
getAccountsSnapshot: vi.fn(() => snapshot)
} as unknown as OrcaRuntimeService
const list = method('accounts.list')
if (isStreamingMethod(list)) {
throw new Error('accounts.list must be a request method')
}
await expect(list.handler(undefined, { runtime })).resolves.toBe(snapshot)
expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce()
})
it('uses a stale-aware refresh when a connection replays the subscription', async () => {
const snapshot = { claude: null, codex: null }
let cleanup: (() => void) | undefined
const runtime = {
getAccountsSnapshot: vi.fn(() => snapshot),
onAccountsChanged: vi.fn(() => vi.fn()),
registerSubscriptionCleanup: vi.fn((_id: string, nextCleanup: () => void) => {
cleanup = nextCleanup
}),
refreshAccountsForMobile: vi.fn().mockResolvedValue(undefined),
refreshAccountsForMobileSubscriber: vi.fn().mockResolvedValue(undefined)
} as unknown as OrcaRuntimeService
const subscribe = method('accounts.subscribe')
if (!isStreamingMethod(subscribe)) {
throw new Error('accounts.subscribe must be a streaming method')
}
const emit = vi.fn()
const running = subscribe.handler(undefined, { runtime, connectionId: 'connection-1' }, emit)
await vi.waitFor(() => {
expect(runtime.refreshAccountsForMobileSubscriber).toHaveBeenCalledOnce()
})
expect(runtime.refreshAccountsForMobile).not.toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready', snapshot }))
cleanup?.()
await running
})
})
+4 -4
View File
@@ -92,11 +92,11 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
)
// Why: emit the current snapshot synchronously so the phone has
// something to render immediately, then kick a forced refresh that
// will broadcast a fresh snapshot through the listener once each
// provider fetch completes.
// something to render immediately, then refresh only stale data.
// Connection cutovers replay this subscription and must not turn the
// manual-force lane into an unbounded provider-fetch loop.
emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() })
void runtime.refreshAccountsForMobile()
void runtime.refreshAccountsForMobileSubscriber()
})
}
}),
@@ -0,0 +1,187 @@
import { expect, it, vi } from 'vitest'
import { RpcDispatcher } from './dispatcher'
import type { RpcRequest } from './core'
import { TERMINAL_METHODS } from './methods/terminal'
import type { OrcaRuntimeService } from '../orca-runtime'
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamText
} from '../../../shared/terminal-stream-protocol'
const request: RpcRequest = {
id: 'req-provider-sequence',
authToken: 'tok',
method: 'terminal.subscribe',
params: {
terminal: 'terminal-restored',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}
}
it('replays post-capture output in the provider snapshot sequence domain', async () => {
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let dataListener:
| ((data: string, meta?: { seq?: number; rawLength?: number }) => void)
| undefined
let resolveSnapshot:
| ((snapshot: { data: string; cols: number; rows: number; seq: number }) => void)
| undefined
const runtime = {
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-restored' }),
handleMobileSubscribe: vi.fn().mockResolvedValue(true),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
dataListener = listener
return vi.fn()
}),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn(
() =>
new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => {
resolveSnapshot = resolve
})
),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
cleanups.get(id)?.()
cleanups.delete(id)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(request, vi.fn(), {
connectionId: 'conn-restored',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
}
})
await vi.waitFor(() => expect(resolveSnapshot).toBeDefined())
resolveSnapshot?.({ data: 'restored snapshot', cols: 80, rows: 24, seq: 900 })
// Why: provider attach anchoring makes this first new chunk continue at
// 905 instead of restarting at 5, so snapshot reconciliation keeps it.
dataListener?.('fresh', { seq: 905, rawLength: 5 })
await vi.waitFor(() =>
expect(
binaryFrames.some((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.opcode === TerminalStreamOpcode.Output &&
decodeTerminalStreamText(frame.payload) === 'fresh'
)
})
).toBe(true)
)
runtime.cleanupSubscription('terminal-restored:phone-1')
await dispatchPromise
})
it('keeps provider-backed alternate-screen resizes geometry-only', async () => {
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let resizeListener:
| ((event: {
cols: number
rows: number
displayMode: string
reason: string
seq: number
}) => void)
| undefined
const serializeTerminalBuffer = vi
.fn()
.mockResolvedValue({ data: 'restored tui', cols: 80, rows: 24 })
const runtime = {
getRuntimeId: () => 'test-runtime',
registerRemoteTerminalViewSubscriber: () => () => {},
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-tui' }),
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer,
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(true),
handleMobileSubscribe: vi.fn().mockResolvedValue(undefined),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn((_ptyId, listener) => {
resizeListener = listener
return vi.fn()
}),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
const cleanup = cleanups.get(id)
cleanups.delete(id)
cleanup?.()
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
{
...request,
id: 'req-provider-alt-screen',
params: {
terminal: 'terminal-tui',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}
},
vi.fn(),
{
connectionId: 'conn-tui-resize',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
}
}
)
await vi.waitFor(() => expect(resizeListener).toBeDefined())
binaryFrames.splice(0)
resizeListener?.({
cols: 100,
rows: 30,
displayMode: 'auto',
reason: 'apply-layout',
seq: 2
})
await vi.waitFor(() =>
expect(
binaryFrames.some(
(frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.Resized
)
).toBe(true)
)
expect(serializeTerminalBuffer).toHaveBeenCalledOnce()
expect(
binaryFrames.some(
(frame) => decodeTerminalStreamFrame(frame)?.opcode === TerminalStreamOpcode.SnapshotStart
)
).toBe(false)
runtime.cleanupSubscription('terminal-tui:phone-1')
await dispatchPromise
})
+9 -7
View File
@@ -1,5 +1,7 @@
import type { SpeechModelManifest } from '../../shared/speech-types'
// Why: sizeBytes must be the exact upstream asset size — it is the UI size
// label and the download-progress denominator when content-length is missing.
export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
{
id: 'parakeet-tdt-0.6b-v3-int8',
@@ -9,7 +11,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'multilingual',
sizeBytes: 180_000_000,
sizeBytes: 487_170_055,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2',
archiveSha256: '5793d0fd397c5778d2cf2126994d58e9d56b1be7c04d13c7a15bb1b4eafb16bf',
@@ -28,7 +30,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'en',
sizeBytes: 170_000_000,
sizeBytes: 482_468_385,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2',
archiveSha256: '157c157bc51155e03e37d2466522a3a737dd9c72bb25f36eb18912964161e1ad',
@@ -45,7 +47,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'zh-en',
sizeBytes: 130_000_000,
sizeBytes: 511_274_346,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20.tar.bz2',
archiveSha256: '27ffbd9ee24ad186d99acc2f6354d7992b27bcab490812510665fa8f9389c5f8',
@@ -68,7 +70,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'paraformer',
provider: 'local',
language: 'zh-en',
sizeBytes: 115_000_000,
sizeBytes: 1_047_319_737,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-paraformer-bilingual-zh-en.tar.bz2',
archiveSha256: '5462a1fce42693deae572af1e8c4687124b12aa85fe61ff4d3168bb5280e205f',
@@ -84,7 +86,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'en',
sizeBytes: 128_000_000,
sizeBytes: 127_887_156,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2',
archiveSha256: '9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9',
@@ -106,7 +108,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'transducer',
provider: 'local',
language: 'zh',
sizeBytes: 74_000_000,
sizeBytes: 74_004_050,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-zh-14M-2023-02-23.tar.bz2',
archiveSha256: '2cbd71b640d9c37d3784f29367333a4577b0398b62e9deeed418170b081cba8b',
@@ -128,7 +130,7 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [
type: 'whisper',
provider: 'local',
language: 'multilingual',
sizeBytes: 116_000_000,
sizeBytes: 116_204_861,
downloadUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-tiny.tar.bz2',
archiveSha256: 'c46116994e539aa165266d96b325252728429c12535eb9d8b6a2b10f129e66b1',
@@ -0,0 +1,671 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelManager } from './model-manager'
const { netRequestMock } = vi.hoisted(() => ({
netRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-speech-models-test'
},
net: {
request: netRequestMock
}
}))
type ModelManagerInternals = {
downloadArchiveWithRetry: (
url: string,
archivePath: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean,
signal: AbortSignal
) => Promise<void>
downloadFile: (
url: string,
archivePath: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean,
signal?: AbortSignal,
redirectCount?: number,
resumeOffset?: number
) => Promise<void>
}
type ScriptedResponse = {
statusCode: number
headers?: Record<string, string>
chunks?: Buffer[]
failWith?: string
}
type ScriptedResponseFactory = (sentHeaders: Record<string, string>) => ScriptedResponse
// Emulates Electron's ClientRequest/IncomingMessage closely enough for the
// download pipeline: a real Readable body so stream.pipeline semantics
// (including mid-body destroy) match production behavior.
function scriptRequest(factory: ScriptedResponseFactory): {
sentHeaders: Record<string, string>
abortMock: ReturnType<typeof vi.fn>
} {
const sentHeaders: Record<string, string> = {}
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
const addListener = (event: string, cb: (...args: unknown[]) => void): void => {
const set = listeners.get(event) ?? new Set()
set.add(cb)
listeners.set(event, set)
}
const abortMock = vi.fn(() => request)
const request = {
setHeader: vi.fn((name: string, value: string) => {
sentHeaders[name.toLowerCase()] = value
}),
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
addListener(event, cb)
return request
}),
off: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
listeners.get(event)?.delete(cb)
return request
}),
abort: abortMock,
end: vi.fn(() => {
queueMicrotask(() => {
const spec = factory(sentHeaders)
const response = Object.assign(new Readable({ read() {} }), {
statusCode: spec.statusCode,
headers: spec.headers ?? {}
})
for (const cb of listeners.get('response') ?? []) {
cb(response)
}
setTimeout(() => {
for (const chunk of spec.chunks ?? []) {
response.push(chunk)
}
// Why: fail on a later tick so pushed chunks flush to the file
// stream first, mirroring a transfer that dies mid-body.
setTimeout(() => {
if (spec.failWith) {
response.destroy(new Error(spec.failWith))
} else {
response.push(null)
}
}, 20)
}, 20)
})
return request
})
}
netRequestMock.mockImplementationOnce(() => request)
return { sentHeaders, abortMock }
}
const PAYLOAD = Buffer.from('0123456789abcdefghij')
describe('ModelManager download resume', () => {
beforeEach(() => {
netRequestMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('resumes an interrupted download with a Range request and assembles the full file', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD.subarray(0, 10)],
failWith: 'net::ERR_CONTENT_LENGTH_MISMATCH'
}))
const second = scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': String(PAYLOAD.length - 10),
'content-range': `bytes 10-${PAYLOAD.length - 1}/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(10)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(second.sentHeaders.range).toBe('bytes=10-')
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('uses a complete archive after a late transport failure without requesting past EOF', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD],
failWith: 'net::ERR_CONNECTION_RESET'
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(1)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('requests the remaining bytes when a clean range response ends before the archive total', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const first = scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': `bytes 10-14/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(10, 15)]
}
})
const second = scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=15-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': `bytes 15-19/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(15)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 10))
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(first.sentHeaders.range).toBe('bytes=10-')
expect(second.sentHeaders.range).toBe('bytes=15-')
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('continues through more than eight advancing range segments', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
for (let offset = 1; offset <= 9; offset += 1) {
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe(`bytes=${offset}-`)
return {
statusCode: 206,
headers: {
'content-length': '1',
'content-range': `bytes ${offset}-${offset}/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(offset, offset + 1)]
}
})
}
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': '10',
'content-range': `bytes 10-19/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(10)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 1))
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(10)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('bounds a server that advances by pathologically tiny segments forever', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
let bytesWritten = 0
// Advances one byte per request against a total larger than the request
// ceiling, so it makes forward progress forever without ever completing.
const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => {
bytesWritten += 1
writeFileSync(archivePath, Buffer.alloc(bytesWritten))
return Promise.resolve()
})
await expect(
manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
1_000_000,
'm',
() => false,
new AbortController().signal
)
).rejects.toThrow(/too many download requests/)
// The absolute request ceiling (MAX_TOTAL_DOWNLOAD_REQUESTS) bounds it.
expect(downloadFileMock).toHaveBeenCalledTimes(4_096)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('keeps resuming a download that advances across many mid-stream drops', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
// Every attempt delivers a small slice then drops mid-stream — the
// classic "dies partway, resumes" pattern. Because each attempt makes
// forward progress, the download must complete no matter how many drops
// it takes (regression guard: a fixed failure budget used to abandon a
// still-advancing large download around attempt 8).
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
const SLICE = 2
let delivered = 0
const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockImplementation(() => {
delivered = Math.min(delivered + SLICE, PAYLOAD.length)
writeFileSync(archivePath, PAYLOAD.subarray(0, delivered))
return Promise.reject(new Error('net::ERR_CONNECTION_RESET'))
})
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
const outcome = download.then(
() => 'resolved',
(error: unknown) => (error instanceof Error ? error.message : String(error))
)
// Drive the per-attempt backoff (1s each, since progress resets the
// stall counter) well past the ten attempts this needs.
await vi.advanceTimersByTimeAsync(30_000)
await expect(outcome).resolves.toBe('resolved')
expect(downloadFileMock).toHaveBeenCalledTimes(PAYLOAD.length / SLICE)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
}
})
it('keeps the known archive total when Content-Range omits it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=10-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': 'bytes 10-14/*'
},
chunks: [PAYLOAD.subarray(10, 15)]
}
})
scriptRequest((sentHeaders) => {
expect(sentHeaders.range).toBe('bytes=15-')
return {
statusCode: 206,
headers: {
'content-length': '5',
'content-range': 'bytes 15-19/*'
},
chunks: [PAYLOAD.subarray(15)]
}
})
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 10))
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('rejects and discards a partial when Content-Range does not match the offset', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const mismatched = scriptRequest(() => ({
statusCode: 206,
headers: {
'content-length': '10',
'content-range': `bytes 0-9/${PAYLOAD.length}`
},
chunks: [PAYLOAD.subarray(0, 10)]
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
writeFileSync(archivePath, PAYLOAD.subarray(0, 10))
const error = await manager
.downloadFile(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal,
0,
10
)
.catch((cause: unknown) => cause)
expect(error).toMatchObject({
message: 'Invalid Content-Range for resume at byte 10',
retryable: true
})
expect(existsSync(archivePath)).toBe(false)
expect(mismatched.abortMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('restarts from scratch when the server ignores the Range request', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD.subarray(0, 10)],
failWith: 'net::ERR_CONNECTION_RESET'
}))
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
chunks: [PAYLOAD]
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
await manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
expect(netRequestMock).toHaveBeenCalledTimes(2)
expect(readFileSync(archivePath)).toEqual(PAYLOAD)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not retry non-transient failures', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({ statusCode: 404 }))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
await expect(
manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
).rejects.toThrow('HTTP 404')
expect(netRequestMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('aborts an HTTP error response instead of draining it after rejection', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const scripted = scriptRequest(() => ({
statusCode: 429,
headers: { 'retry-after': '3' }
}))
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const error = await manager
.downloadFile(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false
)
.catch((cause: unknown) => cause)
expect(error).toMatchObject({ message: 'HTTP 429', httpStatusCode: 429, retryAfterMs: 3_000 })
expect(scripted.abortMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('honors Retry-After before issuing another request', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const archivePath = join(dir, 'model.tar.bz2')
const rateLimitError = Object.assign(new Error('HTTP 429'), {
httpStatusCode: 429,
retryAfterMs: 3_000
})
const downloadFileMock = vi
.spyOn(manager, 'downloadFile')
.mockRejectedValueOnce(rateLimitError)
.mockImplementationOnce(() => {
writeFileSync(archivePath, PAYLOAD)
return Promise.resolve()
})
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
archivePath,
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
await vi.advanceTimersByTimeAsync(2_999)
expect(downloadFileMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await expect(download).resolves.toBeUndefined()
expect(downloadFileMock).toHaveBeenCalledTimes(2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('does not retry before an excessively long Retry-After window', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const rateLimitError = Object.assign(new Error('HTTP 429'), {
httpStatusCode: 429,
retryAfterMs: 300_000
})
const downloadFileMock = vi.spyOn(manager, 'downloadFile').mockRejectedValue(rateLimitError)
await expect(
manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
).rejects.toThrow('server requested retry after 300 seconds')
expect(downloadFileMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('gives up after repeated zero-progress failures with a diagnosable error', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const downloadFileMock = vi
.spyOn(manager, 'downloadFile')
.mockRejectedValue(new Error('net::ERR_CONNECTION_RESET'))
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
new AbortController().signal
)
const outcome = download.then(
() => 'resolved',
(error: unknown) => (error instanceof Error ? error.message : String(error))
)
// Let the first rejection schedule its backoff before advancing timers;
// this avoids racing fake time against stream/file I/O under full-suite load.
await vi.advanceTimersByTimeAsync(0)
expect(downloadFileMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(999)
expect(downloadFileMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(downloadFileMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1_999)
expect(downloadFileMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(downloadFileMock).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(3_999)
expect(downloadFileMock).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(1)
await expect(outcome).resolves.toMatch(
/Model download interrupted at 0% \(0 of 20 bytes\) after 4 attempts: .*net::ERR_CONNECTION_RESET/
)
expect(downloadFileMock).toHaveBeenCalledTimes(4)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops retrying once the download is aborted', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-resume-'))
try {
scriptRequest(() => ({
statusCode: 200,
headers: { 'content-length': String(PAYLOAD.length) },
failWith: 'net::ERR_CONNECTION_RESET'
}))
const controller = new AbortController()
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
const download = manager.downloadArchiveWithRetry(
'https://example.com/model.tar.bz2',
join(dir, 'model.tar.bz2'),
PAYLOAD.length,
'm',
() => false,
controller.signal
)
const outcome = download.then(
() => 'resolved',
(error: unknown) => (error instanceof Error ? error.message : String(error))
)
// Abort after the transfer failure enters backoff; the next attempt must
// settle as Aborted without issuing another request.
setTimeout(() => controller.abort(), 100)
const message = await outcome
expect(message).toBe('Aborted')
expect(netRequestMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+323 -16
View File
@@ -1,7 +1,14 @@
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
import { app, net } from 'electron'
import { join, resolve, relative } from 'node:path'
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'node:fs'
import {
existsSync,
mkdirSync,
createWriteStream,
createReadStream,
rmSync,
statSync
} from 'node:fs'
import { readdir, rm } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { pipeline } from 'node:stream/promises'
@@ -28,11 +35,129 @@ type ProgressCallback = (modelId: string, progress: number) => void
type DownloadIncomingMessage = Electron.IncomingMessage &
NodeJS.ReadableStream & {
headers: Record<string, string | string[] | undefined>
resume: () => void
destroy?: () => void
}
type HttpStatusError = Error & {
httpStatusCode?: number
retryAfterMs?: number
retryable?: boolean
}
type DownloadTotals = { totalBytes: number }
type ContentRange = { start: number; end: number; totalBytes?: number }
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
// Why: flaky networks and scanning proxies commonly kill long CDN transfers
// near the end (reported as "stuck at 90%, then fails"); resuming with a Range
// request lets those connections finish instead of failing the same way on
// every full restart.
const DOWNLOAD_RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
// Why: abandon a download only after this many CONSECUTIVE attempts make no
// forward progress. A resume that keeps advancing (even across many mid-stream
// drops) is never given up on — that is the whole point of resumable downloads
// for large models over flaky networks, and a fixed all-time failure budget
// would wrongly kill a steadily-progressing large download.
const MAX_NO_PROGRESS_ATTEMPTS = DOWNLOAD_RETRY_DELAYS_MS.length + 1
// Why: an absolute backstop so a server handing back pathologically tiny (or
// zero-net) segments cannot loop forever; sized to cover the largest (~1GB)
// model even if a proxy caps each range response near ~256KB, far below any
// real CDN chunk.
const MAX_TOTAL_DOWNLOAD_REQUESTS = 4_096
// Why: a longer server window should be surfaced for a later manual retry,
// rather than leaving the settings download looking stuck for minutes.
const MAX_RETRY_AFTER_MS = 120_000
const RETRYABLE_NET_ERROR =
/net::ERR_(CONTENT_LENGTH_MISMATCH|INCOMPLETE_CHUNKED_ENCODING|CONNECTION_(RESET|CLOSED|ABORTED|REFUSED|TIMED_OUT)|EMPTY_RESPONSE|NETWORK_CHANGED|TIMED_OUT|INTERNET_DISCONNECTED|ADDRESS_UNREACHABLE|NAME_NOT_RESOLVED|SOCKET_NOT_CONNECTED|HTTP2_PROTOCOL_ERROR|QUIC_PROTOCOL_ERROR)\b/
const RETRYABLE_HTTP_STATUSES = new Set([408, 416, 425, 429, 500, 502, 503, 504])
function isRetryableDownloadError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const downloadError = error as HttpStatusError
if (downloadError.retryable === true) {
return true
}
const statusCode = downloadError.httpStatusCode
if (statusCode !== undefined) {
return RETRYABLE_HTTP_STATUSES.has(statusCode)
}
return (
RETRYABLE_NET_ERROR.test(error.message) || error.message.includes('without network activity')
)
}
function getHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
function parseContentRange(value: string | string[] | undefined): ContentRange | null {
const match = getHeaderValue(value)
?.trim()
.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i)
if (!match) {
return null
}
const start = Number.parseInt(match[1], 10)
const end = Number.parseInt(match[2], 10)
const totalBytes = match[3] === '*' ? undefined : Number.parseInt(match[3], 10)
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
end < start ||
(totalBytes !== undefined && (!Number.isSafeInteger(totalBytes) || totalBytes <= end))
) {
return null
}
return { start, end, totalBytes }
}
function parseRetryAfterMs(value: string | string[] | undefined): number | undefined {
const header = getHeaderValue(value)?.trim()
if (!header) {
return undefined
}
if (/^\d+$/.test(header)) {
const seconds = Number.parseInt(header, 10)
const delayMs = seconds * 1_000
return Number.isSafeInteger(delayMs) ? delayMs : undefined
}
const retryAt = Date.parse(header)
return Number.isNaN(retryAt) ? undefined : Math.max(0, retryAt - Date.now())
}
function describeInterruptedDownload(
cause: unknown,
receivedBytes: number,
totalBytes: number,
attempts: number
): Error {
const causeMessage = cause instanceof Error ? cause.message : String(cause)
const received =
totalBytes > 0
? `${Math.min(99, Math.floor((receivedBytes / totalBytes) * 100))}% (${receivedBytes} of ${totalBytes} bytes)`
: `${receivedBytes} bytes`
return new Error(
`Model download interrupted at ${received} after ${attempts} attempts: ${causeMessage}`
)
}
function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve()
return
}
const onAbort = (): void => {
clearTimeout(timer)
resolve()
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
export class ModelManager {
private modelsDir: string
@@ -177,6 +302,15 @@ export class ModelManager {
this.updateState(modelId, 'downloading', 0)
const archivePath = join(this.modelsDir, `${modelId}.tar.bz2`)
// Why: resume appends to the partial archive from this download run only;
// a leftover file from a crashed run would corrupt the resumed archive.
try {
if (existsSync(archivePath)) {
rmSync(archivePath)
}
} catch {
// best-effort; the first (non-resumed) attempt truncates on write
}
let aborted = false
const abortController = new AbortController()
@@ -191,7 +325,7 @@ export class ModelManager {
this.activeDownloads.set(modelId, handle)
try {
await this.downloadFile(
await this.downloadArchiveWithRetry(
manifest.downloadUrl,
archivePath,
manifest.sizeBytes,
@@ -239,6 +373,7 @@ export class ModelManager {
this.updateState(modelId, 'ready')
} catch (err) {
if (!aborted) {
console.error('[speech] Model download failed:', modelId, err)
this.updateState(modelId, 'error', undefined, String(err))
}
this.cleanup(modelId, archivePath)
@@ -309,6 +444,120 @@ export class ModelManager {
}
}
private getPartialArchiveBytes(archivePath: string): number {
try {
return statSync(archivePath).size
} catch {
return 0
}
}
private async downloadArchiveWithRetry(
url: string,
archivePath: string,
expectedSize: number,
modelId: string,
isAborted: () => boolean,
signal: AbortSignal
): Promise<void> {
let requestCount = 0
let noProgressStreak = 0
const totals: DownloadTotals = { totalBytes: expectedSize }
for (;;) {
requestCount += 1
const offset = this.getPartialArchiveBytes(archivePath)
// Why: the transport can fail after the final byte reached disk; the
// caller's SHA-256 check is the authoritative completion test.
if (offset === totals.totalBytes) {
return
}
// Why: absolute backstop against a server that never lets the download
// finish; the stall and abort paths handle the common cases first.
if (requestCount > MAX_TOTAL_DOWNLOAD_REQUESTS) {
throw describeInterruptedDownload(
new Error('too many download requests'),
offset,
totals.totalBytes,
requestCount - 1
)
}
try {
// Why: each attempt restarts from the canonical URL rather than the
// last redirect target, because CDN redirect URLs are signed and expire.
await this.downloadFile(
url,
archivePath,
expectedSize,
modelId,
isAborted,
signal,
0,
offset,
totals
)
const receivedBytes = this.getPartialArchiveBytes(archivePath)
if (receivedBytes === totals.totalBytes) {
return
}
if (receivedBytes > totals.totalBytes) {
throw new Error(
`Model download exceeded its expected size (${receivedBytes} of ${totals.totalBytes} bytes)`
)
}
const incompleteResponse = new Error(
`Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes`
)
if (receivedBytes > offset) {
// Why: some proxies return a valid but bounded range segment; request
// the next segment immediately instead of failing checksum or waiting.
// Forward progress resets the stall counter; the total-request ceiling
// at the loop top bounds a pathological tiny-segment server.
noProgressStreak = 0
continue
}
const retryableIncompleteResponse = incompleteResponse as HttpStatusError
retryableIncompleteResponse.retryable = true
throw retryableIncompleteResponse
} catch (err) {
if (isAborted() || signal.aborted) {
throw err
}
const receivedBytes = this.getPartialArchiveBytes(archivePath)
if (receivedBytes === totals.totalBytes) {
return
}
noProgressStreak = receivedBytes > offset ? 0 : noProgressStreak + 1
if (!isRetryableDownloadError(err)) {
throw err
}
// Why: give up only on a genuine stall (repeated no forward progress);
// a download that keeps advancing across drops is allowed to continue.
if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) {
throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount)
}
const retryAfterMs = (err as HttpStatusError).retryAfterMs
if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
const statusCode = (err as HttpStatusError).httpStatusCode
throw new Error(
`HTTP ${statusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds`
)
}
console.warn(
`[speech] Model download attempt ${requestCount} failed, retrying:`,
modelId,
err
)
await sleepUnlessAborted(
retryAfterMs ??
DOWNLOAD_RETRY_DELAYS_MS[
Math.min(Math.max(0, noProgressStreak - 1), DOWNLOAD_RETRY_DELAYS_MS.length - 1)
],
signal
)
}
}
}
private downloadFile(
url: string,
dest: string,
@@ -316,7 +565,9 @@ export class ModelManager {
modelId: string,
isAborted: () => boolean,
signal?: AbortSignal,
redirectCount = 0
redirectCount = 0,
resumeOffset = 0,
totals?: DownloadTotals
): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@@ -425,28 +676,81 @@ export class ModelManager {
modelId,
isAborted,
signal,
redirectCount + 1
redirectCount + 1,
resumeOffset,
totals
)
.then(resolveOnce)
.catch(rejectOnce)
}
const onResponse = (incoming: Electron.IncomingMessage): void => {
const response = incoming as DownloadIncomingMessage
if (response.statusCode !== 200) {
response.resume()
rejectOnce(new Error(`HTTP ${response.statusCode}`))
const contentLength = response.headers['content-length']
const headerLength = Number.parseInt(getHeaderValue(contentLength) || '0', 10)
const parsedLength =
Number.isSafeInteger(headerLength) && headerLength > 0 ? headerLength : 0
const contentRange = parseContentRange(response.headers['content-range'])
const resumed =
resumeOffset > 0 &&
response.statusCode === 206 &&
contentRange?.start === resumeOffset &&
(parsedLength <= 0 || parsedLength === contentRange.end - contentRange.start + 1)
if (resumeOffset > 0 && response.statusCode === 206 && !resumed) {
// Why: appending an unverified range can silently corrupt the archive;
// discard it so the canonical URL is retried from byte zero.
try {
rmSync(dest)
} catch {
// best-effort
}
const activeRequest = request
const rangeError: HttpStatusError = new Error(
`Invalid Content-Range for resume at byte ${resumeOffset}`
)
rangeError.retryable = true
rejectOnce(rangeError)
activeRequest?.abort()
return
}
const contentLength = response.headers['content-length']
const totalSize =
Number.parseInt(
Array.isArray(contentLength) ? contentLength[0] : contentLength || '0',
10
) || expectedSize
if (response.statusCode !== 200 && !resumed) {
if (response.statusCode === 416) {
// Why: the server rejected our resume offset; drop the partial so
// the retry loop can restart this download from scratch.
try {
rmSync(dest)
} catch {
// best-effort
}
}
const activeRequest = request
const statusError: HttpStatusError = new Error(`HTTP ${response.statusCode}`)
statusError.httpStatusCode = response.statusCode
statusError.retryAfterMs = parseRetryAfterMs(response.headers['retry-after'])
rejectOnce(statusError)
// Why: retrying must not leave a prior error body draining without
// cancellation or idle-timeout ownership.
activeRequest?.abort()
return
}
// Why: a 200 despite our Range request means the server restarted the
// transfer from byte zero, so the partial file must be overwritten.
const progressBase = resumed ? resumeOffset : 0
// Why: Content-Length on a 206 describes only this segment; when
// Content-Range uses '*', retain the known complete archive size.
const totalSize = resumed
? (contentRange?.totalBytes ?? totals?.totalBytes ?? expectedSize)
: parsedLength > 0
? parsedLength
: expectedSize
if (totals) {
totals.totalBytes = totalSize
}
let downloaded = 0
const fileStream = createWriteStream(dest)
const fileStream = createWriteStream(dest, { flags: resumed ? 'a' : 'w' })
const cleanupResponseProgressListener = (): void => {
response.off('data', onResponseData)
@@ -460,7 +764,7 @@ export class ModelManager {
return
}
downloaded += chunk.length
const progress = Math.min(0.9, downloaded / totalSize)
const progress = Math.min(0.9, (progressBase + downloaded) / totalSize)
this.updateState(modelId, 'downloading', progress)
}
@@ -481,6 +785,9 @@ export class ModelManager {
}
request = net.request({ method: 'GET', url: parsedUrl.toString() })
if (resumeOffset > 0) {
request.setHeader('Range', `bytes=${resumeOffset}-`)
}
// Why: Electron's net stack honors app proxy settings, unlike Node's
// https client, but it does not expose request.setTimeout().
@@ -181,6 +181,10 @@ import { shouldPollChromiumErrorPage } from './chromium-error-page-polling'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { translate } from '@/i18n/i18n'
import { isBrowserPagePanePaintable } from './browser-page-paintability'
import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode'
import { MarkupOverlay } from './markup/MarkupOverlay'
import { MarkupDrawButton } from './markup/MarkupDrawButton'
import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery'
type BrowserTabPageState = Partial<
Pick<
@@ -2394,6 +2398,30 @@ function RemoteBrowserPagePane({
const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata])
// Why: markup works on remote panes by snapshotting the already-displayed
// screencast <img> — no in-page injection needed, so it is enabled here even
// though element-grab annotations are not.
const markup = useMarkupMode({
getCaptureContext: useCallback((): MarkupCaptureContext | null => {
const element = imageRef.current
const container = remoteViewportRef.current
if (!element || !container) {
return null
}
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return null
}
return {
source: { kind: 'image', element },
cssWidth: rect.width,
cssHeight: rect.height,
outputScale: window.devicePixelRatio || 1
}
}, []),
onDeliver: deliverMarkupToClipboard
})
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col bg-background">
{contextMenu
@@ -2600,12 +2628,27 @@ function RemoteBrowserPagePane({
)}
</TooltipContent>
</Tooltip>
<MarkupDrawButton
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={!frameUrl}
active={markup.isActive}
surfaceActive={isActive}
className="h-7 w-7"
/>
</div>
<div
ref={remoteViewportRef}
tabIndex={-1}
className="relative min-h-0 flex-1 overflow-hidden bg-background"
>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
{frameUrl ? (
<img
ref={imageRef}
@@ -2796,6 +2839,27 @@ function BrowserPagePane({
const contextMenuRef = useRef<HTMLDivElement>(null)
const [findOpen, setFindOpen] = useState(false)
const grab = useGrabMode(browserTab.id)
const markup = useMarkupMode({
getCaptureContext: useCallback((): MarkupCaptureContext | null => {
const webview = webviewRef.current
const container = containerRef.current
if (!webview || !container) {
return null
}
const rect = container.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return null
}
return {
source: { kind: 'webview', webview },
cssWidth: rect.width,
cssHeight: rect.height,
outputScale: window.devicePixelRatio || 1
}
}, []),
onDeliver: deliverMarkupToClipboard
})
const [grabIntent, setGrabIntent] = useState<GrabIntent>('copy')
const grabIntentRef = useRef(grabIntent)
grabIntentRef.current = grabIntent
@@ -4149,14 +4213,19 @@ function BrowserPagePane({
if (isEditableKeyboardTarget(e.target)) {
return
}
if (keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings)) {
// Why: while the markup overlay is open, don't let the grab shortcut start
// the in-guest picker behind it — matching the disabled grab toolbar buttons.
if (
!markup.isActive &&
keybindingMatchesAction('browser.grabElement', e, shortcutPlatform, keybindings)
) {
e.preventDefault()
startGrabIntent('copy')
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isActive, keybindings, startGrabIntent])
}, [isActive, keybindings, markup.isActive, startGrabIntent])
useEffect(() => {
if (!isActive) {
@@ -4989,7 +5058,7 @@ function BrowserPagePane({
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => startGrabIntent('copy')}
disabled={isBlankTab}
disabled={isBlankTab || markup.isActive}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fdfc7fe0ef',
'Grab page element'
@@ -5026,7 +5095,7 @@ function BrowserPagePane({
'bg-foreground/80 text-background hover:bg-foreground/90'
)}
onClick={() => startGrabIntent('annotate')}
disabled={isBlankTab}
disabled={isBlankTab || markup.isActive}
aria-label={translate(
'auto.components.browser.pane.BrowserPane.fc9be38f6f',
'Annotate page element'
@@ -5050,6 +5119,13 @@ function BrowserPagePane({
</TooltipContent>
</Tooltip>
<MarkupDrawButton
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={isBlankTab || grab.state !== 'idle'}
active={markup.isActive}
surfaceActive={isActive}
/>
<Button
size="icon"
variant="ghost"
@@ -5379,6 +5455,14 @@ function BrowserPagePane({
{pageViewport?.container
? createPortal(
<>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
<div
role="status"
aria-live="polite"
@@ -0,0 +1,160 @@
import React, { useEffect } from 'react'
import { PenTool } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useMarkupDrawHint } from './use-markup-draw-hint'
export type MarkupDrawButtonProps = {
onClick: () => void
disabled?: boolean
active?: boolean
/**
* The browser pane's isActive. Required (no default) so every caller must
* decide explicitly — inactive/hidden tabs cannot force-open a portaled
* layer against a zero-size trigger (which lands at the viewport origin).
*/
surfaceActive: boolean
className?: string
}
// Toolbar toggle that enters screenshot-markup mode. Shared by the local and
// remote browser panes so both expose the same affordance.
export function MarkupDrawButton({
onClick,
disabled,
active,
surfaceActive,
className
}: MarkupDrawButtonProps): React.JSX.Element {
const label = translate('auto.components.browser-pane.markup.drawButton', 'Draw on screenshot')
// Why: nudge the button the first time it's usable on a visible surface so
// users discover the tool without pinning a tooltip to a hidden pane.
const { hintOpen, dismissHint } = useMarkupDrawHint(surfaceActive && !disabled && !active)
// Why: close synchronously off the same `surfaceActive` render instead of
// waiting on the hook's effect — BrowserPane sets inert/hidden on the pane
// in the same commit isActive flips, so a one-tick delay would let the
// popover anchor briefly against an already-hidden trigger.
const showHint = hintOpen && surfaceActive
// Why: Electron <webview> clicks never reach the renderer document, so
// Radix outside-click dismiss misses them. Window blur covers focus leaving
// the renderer into the guest (same pattern as BrowserImportHintButton).
useEffect(() => {
if (!showHint) {
return
}
window.addEventListener('blur', dismissHint)
return () => window.removeEventListener('blur', dismissHint)
}, [dismissHint, showHint])
const startMarkup = (): void => {
dismissHint()
onClick()
}
const button = (
<Button
size="icon"
variant={active ? 'default' : 'ghost'}
className={cn(
className ?? 'h-8 w-8',
active && 'bg-foreground/80 text-background hover:bg-foreground/90',
// Why: soft ring while the discovery callout is open — points at the
// control without the harsh solid highlight of a force-open tooltip.
showHint && 'ring-2 ring-ring/45 ring-offset-1 ring-offset-background'
)}
onClick={startMarkup}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
<PenTool className="size-4" />
</Button>
)
// Discovery uses a dismissable popover (outside click / Escape / blur / act)
// instead of a force-open tooltip — tooltips stay stuck when controlled open
// and mis-position against hidden triggers. Styled like BrowserImportHint
// (surface + title + body + actions), not an inverted tooltip chip.
//
// Why: Popover and Tooltip stay mounted as one persistent tree (matching
// WorkspaceStatusAppearancePopover's Tooltip-wrapping-Popover pattern)
// instead of switching root element types on `hintOpen` — swapping roots
// would unmount/remount the trigger button at the exact moments the hint
// is meant to draw the user's attention to it.
return (
<Popover
modal={false}
open={showHint}
onOpenChange={(open) => {
if (!open) {
dismissHint()
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
{/* Why: wrap in a span so the trigger still fires when disabled. */}
<span className="inline-flex">{button}</span>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
<PopoverContent
side="bottom"
align="center"
sideOffset={6}
className="w-72 p-3"
onOpenAutoFocus={(event) => {
// Why: don't steal focus from the address bar / webview on first show.
event.preventDefault()
}}
>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 shrink-0 px-1.5 text-[10px] font-semibold tracking-wider uppercase"
>
{translate('auto.components.browser-pane.markup.drawHintBadge', 'New')}
</Badge>
<div className="text-sm font-medium text-foreground">{label}</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.browser-pane.markup.drawHint',
'Draw on the page, then copy the markup to paste into your agent.'
)}
</p>
</div>
<div className="flex items-center gap-3">
<Button
type="button"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
onClick={startMarkup}
>
<PenTool className="size-3.5" />
{translate('auto.components.browser-pane.markup.drawHintTry', 'Try it')}
</Button>
<button
type="button"
onClick={dismissHint}
className="rounded-sm text-xs text-muted-foreground hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
>
{translate('auto.components.browser-pane.markup.drawHintDismiss', 'Got it')}
</button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,159 @@
import React, { useCallback, useRef, useState } from 'react'
import { Check, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { MarkupToolbar } from './MarkupToolbar'
import type { MarkupBaseImage } from './markup-base-image'
import type { MarkupShape } from './markup-drawing-model'
import { TEXT_FONT_FAMILY } from './markup-shape-render'
import { useMarkupEditor } from './useMarkupEditor'
export type MarkupOverlayProps = {
baseImage: MarkupBaseImage
busy: boolean
onComplete: (input: { imageElement: HTMLImageElement; shapes: MarkupShape[] }) => void
onCancel: () => void
}
export function MarkupOverlay({
baseImage,
busy,
onComplete,
onCancel
}: MarkupOverlayProps): React.JSX.Element {
const baseImgRef = useRef<HTMLImageElement | null>(null)
const [baseLoaded, setBaseLoaded] = useState(false)
const editor = useMarkupEditor(busy, onCancel)
const { pendingText } = editor
const handleDone = useCallback(() => {
const imageElement = baseImgRef.current
if (!imageElement || !baseLoaded) {
return
}
onComplete({ imageElement, shapes: editor.shapes })
}, [baseLoaded, editor.shapes, onComplete])
return (
<div
ref={editor.rootRef}
data-orca-markup-overlay
className="absolute inset-0 z-20 overflow-hidden"
>
<img
ref={baseImgRef}
src={baseImage.dataUrl}
alt=""
draggable={false}
onLoad={() => setBaseLoaded(true)}
onError={() => {
// Why: the backdrop is a self-generated data URL, so a decode failure is
// unexpected — but never trap the user with a permanently-disabled Done.
console.error('markup: base screenshot failed to load')
onCancel()
}}
className="pointer-events-none absolute inset-0 block h-full w-full select-none"
style={{ objectFit: 'fill' }}
/>
<canvas
ref={editor.canvasRef}
className={cn(
'absolute inset-0 h-full w-full touch-none',
busy ? 'cursor-progress' : 'cursor-crosshair'
)}
onPointerDown={editor.onPointerDown}
onPointerMove={editor.onPointerMove}
onPointerUp={editor.onPointerUp}
onPointerCancel={editor.onPointerUp}
/>
{pendingText ? (
<input
ref={editor.textInputRef}
// Why: key by position so each placement re-mounts a fresh input.
key={`${pendingText.x},${pendingText.y}`}
defaultValue={pendingText.initial}
aria-label={translate('auto.components.browser-pane.markup.textInput', 'Annotation text')}
onPointerDown={(event) => event.stopPropagation()}
onBlur={(event) => editor.commitPendingText(event.target.value)}
onKeyDown={(event) => {
// Why: keep keystrokes local — without this the browser pane's global
// key handlers can swallow typing before it reaches the input.
event.stopPropagation()
// Why: during IME composition (e.g. Japanese conversion), Enter
// confirms the candidate — it must NOT also commit the annotation.
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
editor.commitPendingText(event.currentTarget.value)
} else if (event.key === 'Escape') {
event.preventDefault()
editor.cancelPendingText()
}
}}
// Why: the input renders ONLY the editable glyphs (no box of its own) —
// the selection frame is drawn on the canvas so it's identical whether
// selecting or editing. No padding/border + the same font as the canvas
// so the text sits exactly where it commits. field-sizing hugs the text
// so clicks outside it still reach the canvas.
className="absolute z-30 min-w-0 border-0 bg-transparent p-0 leading-none outline-none [field-sizing:content]"
style={{
left: pendingText.x,
top: pendingText.y,
color: editor.color,
fontSize: editor.fontSize,
fontWeight: 600,
fontFamily: TEXT_FONT_FAMILY,
// Why: force line-height to 1 (the UA default ~1.2 pushes the glyph
// down) and zero the height so the text top aligns with the canvas's
// textBaseline:'top' render — otherwise editing text sits slightly low.
lineHeight: 1,
height: '1em',
boxSizing: 'content-box',
padding: 0,
textShadow:
editor.color.toLowerCase() === '#ffffff'
? '0 0 3px rgba(0,0,0,0.7)'
: '0 0 3px rgba(255,255,255,0.9), 0 0 2px rgba(255,255,255,0.9)'
}}
/>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex flex-col items-center gap-2 px-3">
<div className="pointer-events-auto">
<MarkupToolbar
tool={editor.tool}
onToolChange={editor.setTool}
color={editor.color}
onColorChange={editor.setColor}
width={editor.width}
onWidthChange={editor.setWidth}
fontSize={editor.fontSize}
onFontSizeChange={editor.setFontSize}
canUndo={editor.canUndo}
canRedo={editor.canRedo}
onUndo={editor.undo}
onRedo={editor.redo}
onClear={editor.clear}
/>
</div>
<div className="pointer-events-auto flex items-center gap-2 rounded-md border border-border bg-card/95 p-1.5 shadow-md backdrop-blur">
<span className="px-1 text-xs text-muted-foreground">
{translate(
'auto.components.browser-pane.markup.hint',
'Draw on the page, then copy the markup to paste into your agent.'
)}
</span>
<Button type="button" variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
<X className="size-4" />
{translate('auto.components.browser-pane.markup.cancel', 'Cancel')}
</Button>
<Button type="button" size="sm" onClick={handleDone} disabled={busy || !baseLoaded}>
<Check className="size-4" />
{translate('auto.components.browser-pane.markup.copy', 'Copy Markup')}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,293 @@
import React from 'react'
import {
ArrowUpRight,
Circle,
Highlighter,
Pencil,
Redo2,
Square,
Trash2,
Type,
Undo2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
MARKUP_COLORS,
MARKUP_FONT_SIZES,
MARKUP_WIDTHS,
type MarkupTool
} from './markup-drawing-model'
type ToolItem = {
kind: MarkupTool
icon: React.ComponentType<{ className?: string }>
label: string
}
function toolItems(): ToolItem[] {
return [
{
kind: 'pen',
icon: Pencil,
label: translate('auto.components.browser-pane.markup.tool.pen', 'Pen')
},
{
kind: 'highlight',
icon: Highlighter,
label: translate('auto.components.browser-pane.markup.tool.highlight', 'Highlighter')
},
{
kind: 'arrow',
icon: ArrowUpRight,
label: translate('auto.components.browser-pane.markup.tool.arrow', 'Arrow')
},
{
kind: 'rect',
icon: Square,
label: translate('auto.components.browser-pane.markup.tool.rect', 'Rectangle')
},
{
kind: 'ellipse',
icon: Circle,
label: translate('auto.components.browser-pane.markup.tool.ellipse', 'Ellipse')
},
{
kind: 'text',
icon: Type,
label: translate('auto.components.browser-pane.markup.tool.text', 'Text')
}
]
}
export type MarkupToolbarProps = {
tool: MarkupTool
onToolChange: (tool: MarkupTool) => void
color: string
onColorChange: (color: string) => void
width: number
onWidthChange: (width: number) => void
fontSize: number
onFontSizeChange: (fontSize: number) => void
canUndo: boolean
canRedo: boolean
onUndo: () => void
onRedo: () => void
onClear: () => void
}
export const MarkupToolbar = React.memo(function MarkupToolbar({
tool,
onToolChange,
color,
onColorChange,
width,
onWidthChange,
fontSize,
onFontSizeChange,
canUndo,
canRedo,
onUndo,
onRedo,
onClear
}: MarkupToolbarProps) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-1 rounded-md border border-border bg-card/95 px-1.5 py-1 shadow-md backdrop-blur">
{toolItems().map((item) => (
<IconButton
key={item.kind}
label={item.label}
active={tool === item.kind}
onClick={() => onToolChange(item.kind)}
>
<item.icon className="size-4" />
</IconButton>
))}
<Divider />
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={translate(
'auto.components.browser-pane.markup.style',
'Color and thickness'
)}
>
<span
className="size-4 rounded-full border border-border"
style={{ backgroundColor: color }}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.browser-pane.markup.style', 'Color and thickness')}
</TooltipContent>
</Tooltip>
<PopoverContent side="top" align="start" className="w-auto p-2">
<div className="flex flex-wrap gap-1">
{MARKUP_COLORS.map((swatch) => (
<button
key={swatch}
type="button"
aria-label={swatch}
onClick={() => onColorChange(swatch)}
className={cn(
'size-6 rounded-full border',
color === swatch ? 'border-ring ring-1 ring-ring' : 'border-border'
)}
style={{ backgroundColor: swatch }}
/>
))}
</div>
<div className="mt-2 flex items-center gap-1">
{MARKUP_WIDTHS.map((option) => (
<button
key={option}
type="button"
aria-label={translate(
'auto.components.browser-pane.markup.widthOption',
'{{value0}} px',
{ value0: option }
)}
onClick={() => onWidthChange(option)}
className={cn(
'flex h-6 flex-1 items-center justify-center rounded-sm border',
width === option ? 'border-ring bg-accent' : 'border-border hover:bg-accent/50'
)}
>
<span
className="rounded-full bg-foreground"
style={{ width: option + 2, height: option + 2 }}
/>
</button>
))}
</div>
</PopoverContent>
</Popover>
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2"
aria-label={translate(
'auto.components.browser-pane.markup.fontSize',
'Font size'
)}
>
<Type className="size-3.5" />
<span className="text-[11px] tabular-nums">{fontSize}</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.browser-pane.markup.fontSize', 'Font size')}
</TooltipContent>
</Tooltip>
<PopoverContent side="top" align="start" className="w-auto p-2">
<div className="flex items-center gap-1">
{MARKUP_FONT_SIZES.map((size) => (
<button
key={size}
type="button"
aria-label={translate(
'auto.components.browser-pane.markup.widthOption',
'{{value0}} px',
{ value0: size }
)}
onClick={() => onFontSizeChange(size)}
className={cn(
'flex h-6 min-w-7 items-center justify-center rounded-sm border px-1 text-[11px] tabular-nums',
fontSize === size ? 'border-ring bg-accent' : 'border-border hover:bg-accent/50'
)}
>
{size}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Divider />
<IconButton
label={translate('auto.components.browser-pane.markup.undo', 'Undo')}
disabled={!canUndo}
onClick={onUndo}
>
<Undo2 className="size-4" />
</IconButton>
<IconButton
label={translate('auto.components.browser-pane.markup.redo', 'Redo')}
disabled={!canRedo}
onClick={onRedo}
>
<Redo2 className="size-4" />
</IconButton>
<IconButton
label={translate('auto.components.browser-pane.markup.clear', 'Clear all')}
disabled={!canUndo && !canRedo}
onClick={onClear}
>
<Trash2 className="size-4" />
</IconButton>
</div>
</TooltipProvider>
)
})
function Divider(): React.JSX.Element {
return <span className="mx-0.5 h-5 w-px bg-border" aria-hidden />
}
type IconButtonProps = {
label: string
active?: boolean
disabled?: boolean
onClick: () => void
children: React.ReactNode
}
function IconButton({
label,
active,
disabled,
onClick,
children
}: IconButtonProps): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={active ? 'default' : 'ghost'}
size="icon-sm"
disabled={disabled}
aria-label={label}
aria-pressed={active}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,78 @@
// Captures the frozen base screenshot the user draws on. Renderer-only and
// environment-aware: local Electron webviews expose capturePage(); remote panes
// already display the streamed frame as an <img> we can snapshot. Keeping this
// out of the main process means the markup feature works identically for local
// and remote/SSH browser panes.
// A frozen snapshot as a data URL plus its intrinsic pixel size. The overlay
// shows it as an <img> backdrop and reuses that same loaded element as the
// compositor's base layer.
export type MarkupBaseImage = {
dataUrl: string
width: number
height: number
}
export type MarkupCaptureSource =
| { kind: 'webview'; webview: Electron.WebviewTag }
| { kind: 'image'; element: HTMLImageElement }
export async function captureMarkupBaseImage(
source: MarkupCaptureSource
): Promise<MarkupBaseImage> {
if (source.kind === 'webview') {
return captureFromWebview(source.webview)
}
return captureFromImage(source.element)
}
async function captureFromWebview(webview: Electron.WebviewTag): Promise<MarkupBaseImage> {
const native = await webview.capturePage()
if (native.isEmpty()) {
throw new Error('markup: webview capturePage returned an empty image')
}
const size = native.getSize()
// Why: capturePage() can include an alpha channel where the page has no opaque
// background, which would let the live webview ghost through the frozen
// backdrop. Flatten onto white (the browser's default canvas color) so the
// base image is fully opaque.
const image = await loadImage(native.toDataURL())
return { dataUrl: flattenToOpaquePng(image, size.width, size.height), ...size }
}
function captureFromImage(element: HTMLImageElement): MarkupBaseImage {
const width = element.naturalWidth || element.width
const height = element.naturalHeight || element.height
// Why: drawImage() silently no-ops on an undecoded/detached source, yielding a
// blank PNG. Fail fast instead so the caller surfaces an error.
if (!element.complete || !element.isConnected || width <= 0 || height <= 0) {
throw new Error('markup: remote frame image is not ready')
}
// Why: snapshot the live frame into a detached canvas (opaque white backdrop)
// so later screencast frames can't change the base image and transparent
// regions don't ghost the live stream through.
return { dataUrl: flattenToOpaquePng(element, width, height), width, height }
}
function flattenToOpaquePng(image: CanvasImageSource, width: number, height: number): string {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('markup: 2d context unavailable for base image')
}
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
return canvas.toDataURL('image/png')
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('markup: failed to decode captured screenshot'))
image.src = src
})
}
@@ -0,0 +1,88 @@
// Renders the live markup scene into the overlay canvas at device resolution.
// Committed shapes are rasterized once into an offscreen layer; the per-frame
// paint blits that layer and draws only the in-progress shape on top, so a fast
// pointermove stream never re-strokes the whole scene. Kept separate from the
// editor hook so the draw composition stays focused.
//
// Why the caller passes dpr: the committed layer and the visible canvas are sized
// in two separate paints. Reading window.devicePixelRatio independently in each
// could size them differently if the ratio changed between them (monitor move),
// misaligning the 1:1 blit. Taking one measured dpr keeps them in lockstep.
import { clampMarkupScale } from './markup-screenshot-compose'
import { drawShapes } from './markup-shape-render'
import type { MarkupShape } from './markup-drawing-model'
function sceneDevicePixels(
cssWidth: number,
cssHeight: number,
dpr: number
): { scale: number; width: number; height: number } {
// Why: clamp identically to the compositor so the live preview and the exported
// PNG render shapes at the same scale.
const scale = clampMarkupScale(dpr)
return {
scale,
width: Math.max(1, Math.round(cssWidth * scale)),
height: Math.max(1, Math.round(cssHeight * scale))
}
}
// Rasterizes the committed shapes into an offscreen layer. Called only when the
// committed shape list, the size, or the dpr changes — never on the pointermove
// path.
export function renderCommittedLayer(
layer: HTMLCanvasElement,
shapes: readonly MarkupShape[],
cssWidth: number,
cssHeight: number,
dpr: number
): void {
const { scale, width, height } = sceneDevicePixels(cssWidth, cssHeight, dpr)
if (layer.width !== width || layer.height !== height) {
layer.width = width
layer.height = height
}
const ctx = layer.getContext('2d')
if (!ctx) {
return
}
ctx.setTransform(scale, 0, 0, scale, 0, 0)
ctx.clearRect(0, 0, cssWidth, cssHeight)
drawShapes(ctx, shapes)
}
// Blits the cached committed layer plus the in-progress shape onto the visible
// canvas. Cheap enough for every frame: one image blit + at most one shape.
export function blitMarkupScene(
canvas: HTMLCanvasElement,
committedLayer: HTMLCanvasElement,
inProgress: MarkupShape | null,
cssWidth: number,
cssHeight: number,
dpr: number
): void {
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
const { scale, width, height } = sceneDevicePixels(cssWidth, cssHeight, dpr)
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
}
// Why: blit the committed layer under the identity transform, stretched to the
// canvas's device box. It's 1:1 when the layer matches (the common case, since
// both are sized from the same dpr) and degrades gracefully — never clips or
// offsets — if a layer render briefly lags a dpr change.
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, width, height)
if (committedLayer.width > 0 && committedLayer.height > 0) {
ctx.drawImage(committedLayer, 0, 0, width, height)
}
// Then switch to the dpr transform to draw the in-progress shape in CSS coords.
ctx.setTransform(scale, 0, 0, scale, 0, 0)
if (inProgress) {
drawShapes(ctx, [inProgress])
}
}
@@ -0,0 +1,21 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { MarkupComposeResult } from './markup-screenshot-compose'
// Delivery for v1: copy the composited markup PNG to the clipboard, mirroring the
// browser grab "copy" flow. The user pastes it into their agent terminal, where
// Orca's existing clipboard-screenshot paste writes the image to a temp file (on
// the correct host for local or remote/SSH agents) and hands the path to the TUI.
// This reuses proven, environment-agnostic machinery instead of re-plumbing a
// direct send.
export async function deliverMarkupToClipboard(result: MarkupComposeResult): Promise<void> {
await window.api.ui.writeClipboardImage(result.dataUrl)
const isMac = navigator.userAgent.includes('Mac')
toast.success(
translate(
'auto.components.browser-pane.markup.copiedToast',
'Markup copied — paste it into your agent ({{value0}})',
{ value0: isMac ? '⌘V' : 'Ctrl+V' }
)
)
}
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import {
arrowHeadGeometry,
canRedo,
canUndo,
clearShapes,
commitShape,
createMarkupDocument,
isEmptyDocument,
normalizeRect,
redoShape,
scaleShape,
setShapes,
undoShape,
type ArrowShape,
type MarkupShape,
type PenShape,
type TextShape
} from './markup-drawing-model'
function pen(id: string): PenShape {
return { id, kind: 'pen', color: '#ef4444', width: 4, points: [{ x: 0, y: 0 }] }
}
describe('markup document undo/redo', () => {
it('starts empty', () => {
const doc = createMarkupDocument()
expect(doc.shapes).toHaveLength(0)
expect(isEmptyDocument(doc)).toBe(true)
expect(canUndo(doc)).toBe(false)
expect(canRedo(doc)).toBe(false)
})
it('commits shapes and clears the redo stack', () => {
let doc = createMarkupDocument()
doc = commitShape(doc, pen('a'))
doc = commitShape(doc, pen('b'))
expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b'])
expect(canUndo(doc)).toBe(true)
expect(canRedo(doc)).toBe(false)
})
it('undoes and redoes in order', () => {
let doc = createMarkupDocument()
doc = commitShape(doc, pen('a'))
doc = commitShape(doc, pen('b'))
doc = undoShape(doc)
expect(doc.shapes.map((s) => s.id)).toEqual(['a'])
expect(canRedo(doc)).toBe(true)
doc = redoShape(doc)
expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'b'])
expect(canRedo(doc)).toBe(false)
})
it('forks history: committing after undo discards the redo stack', () => {
let doc = createMarkupDocument()
doc = commitShape(doc, pen('a'))
doc = commitShape(doc, pen('b'))
doc = undoShape(doc)
doc = commitShape(doc, pen('c'))
expect(doc.shapes.map((s) => s.id)).toEqual(['a', 'c'])
expect(canRedo(doc)).toBe(false)
})
it('undo/redo on empty document are no-ops returning the same reference', () => {
const doc = createMarkupDocument()
expect(undoShape(doc)).toBe(doc)
expect(redoShape(doc)).toBe(doc)
})
it('clears shapes, and is a no-op (same reference) when already empty', () => {
const empty = createMarkupDocument()
expect(clearShapes(empty)).toBe(empty)
let doc = commitShape(empty, pen('a'))
doc = clearShapes(doc)
expect(doc.shapes).toHaveLength(0)
expect(canRedo(doc)).toBe(false)
})
it('undoes an edit (not just an add) via whole-list snapshots', () => {
let doc = commitShape(createMarkupDocument(), pen('a'))
doc = setShapes(doc, []) // delete everything
expect(doc.shapes).toHaveLength(0)
doc = undoShape(doc)
expect(doc.shapes.map((s) => s.id)).toEqual(['a'])
})
})
describe('markup geometry', () => {
it('normalizes rectangles from any drag direction', () => {
expect(normalizeRect({ x: 10, y: 20 }, { x: 4, y: 50 })).toEqual({
x: 4,
y: 20,
width: 6,
height: 30
})
})
it('scales pen points and width', () => {
const shape: PenShape = {
id: 'a',
kind: 'pen',
color: '#fff',
width: 4,
points: [
{ x: 2, y: 3 },
{ x: 4, y: 5 }
]
}
const scaled = scaleShape(shape, 2) as PenShape
expect(scaled.width).toBe(8)
expect(scaled.points).toEqual([
{ x: 4, y: 6 },
{ x: 8, y: 10 }
])
})
it('scales rect/arrow endpoints and text position + font size', () => {
const arrow: ArrowShape = {
id: 'a',
kind: 'arrow',
color: '#fff',
width: 3,
from: { x: 1, y: 1 },
to: { x: 3, y: 5 }
}
const scaledArrow = scaleShape(arrow, 3) as ArrowShape
expect(scaledArrow.from).toEqual({ x: 3, y: 3 })
expect(scaledArrow.to).toEqual({ x: 9, y: 15 })
expect(scaledArrow.width).toBe(9)
const text: TextShape = {
id: 't',
kind: 'text',
color: '#fff',
at: { x: 10, y: 20 },
text: 'fix',
fontSize: 18
}
const scaledText = scaleShape(text, 2) as TextShape
expect(scaledText.at).toEqual({ x: 20, y: 40 })
expect(scaledText.fontSize).toBe(36)
})
it('returns null arrowhead for a zero-length segment', () => {
expect(arrowHeadGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 4)).toBeNull()
})
it('produces a symmetric arrowhead pointing at the tip', () => {
const head = arrowHeadGeometry({ x: 0, y: 0 }, { x: 10, y: 0 }, 4)
expect(head).not.toBeNull()
if (!head) {
return
}
expect(head.tip).toEqual({ x: 10, y: 0 })
// Wings sit behind the tip (smaller x) and mirror across the horizontal axis.
expect(head.left.x).toBeLessThan(10)
expect(head.right.x).toBeLessThan(10)
expect(head.left.y).toBeCloseTo(-head.right.y, 6)
})
})
// Why: exhaustiveness guard — if a new MarkupShape kind is added without a
// scaleShape branch, this fails to type-check at the cast site below.
const _exhaustive: MarkupShape['kind'][] = ['pen', 'highlight', 'arrow', 'rect', 'ellipse', 'text']
void _exhaustive
@@ -0,0 +1,213 @@
// Pure drawing model for browser screenshot markup. No DOM access — shapes are
// vector objects in CSS-viewport coordinates so the same data drives both the
// live canvas overlay and the final composited PNG. Keeping this canvas-free
// makes the undo/redo and geometry logic unit-testable.
export type MarkupToolKind = 'pen' | 'highlight' | 'arrow' | 'rect' | 'ellipse' | 'text'
// Toolbar selection. Draw-only: markup is a throwaway scribble the user copies
// once, so there is no select/move/restyle cursor.
export type MarkupTool = MarkupToolKind
export type MarkupPoint = { x: number; y: number }
type MarkupShapeBase = { id: string; color: string }
export type PenShape = MarkupShapeBase & { kind: 'pen'; points: MarkupPoint[]; width: number }
export type HighlightShape = MarkupShapeBase & {
kind: 'highlight'
points: MarkupPoint[]
width: number
}
export type ArrowShape = MarkupShapeBase & {
kind: 'arrow'
from: MarkupPoint
to: MarkupPoint
width: number
}
export type RectShape = MarkupShapeBase & {
kind: 'rect'
from: MarkupPoint
to: MarkupPoint
width: number
}
export type EllipseShape = MarkupShapeBase & {
kind: 'ellipse'
from: MarkupPoint
to: MarkupPoint
width: number
}
export type TextShape = MarkupShapeBase & {
kind: 'text'
at: MarkupPoint
text: string
fontSize: number
}
export type MarkupShape =
| PenShape
| HighlightShape
| ArrowShape
| RectShape
| EllipseShape
| TextShape
// Concrete ink colors (baked into the exported PNG, so these are content colors,
// not UI-chrome tokens). Ordered for a compact swatch row.
export const MARKUP_COLORS = [
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#3b82f6',
'#111827',
'#ffffff'
] as const
export const DEFAULT_MARKUP_COLOR: string = MARKUP_COLORS[0]
export const MARKUP_WIDTHS = [2, 4, 8] as const
export const DEFAULT_MARKUP_WIDTH = 4
export const MARKUP_FONT_SIZES = [14, 18, 24, 32, 48] as const
export const DEFAULT_MARKUP_FONT_SIZE = 18
// Highlight strokes are intentionally fat and translucent.
export const HIGHLIGHT_WIDTH_MULTIPLIER = 4
export const HIGHLIGHT_ALPHA = 0.35
// ─── Document with undo/redo ────────────────────────────────────────────────
export type MarkupDocument = {
shapes: MarkupShape[]
/** Prior whole-list states (oldest first) for undo. */
past: MarkupShape[][]
/** Undone whole-list states (newest first) for redo. */
future: MarkupShape[][]
}
export function createMarkupDocument(): MarkupDocument {
return { shapes: [], past: [], future: [] }
}
// Replace the whole shape list as one undoable step. Snapshot-based so every
// edit — add, move, restyle, delete — is reversible, not just adding a shape.
export function setShapes(doc: MarkupDocument, shapes: MarkupShape[]): MarkupDocument {
return { shapes, past: [...doc.past, doc.shapes], future: [] }
}
export function commitShape(doc: MarkupDocument, shape: MarkupShape): MarkupDocument {
return setShapes(doc, [...doc.shapes, shape])
}
export function undoShape(doc: MarkupDocument): MarkupDocument {
const prev = doc.past.at(-1)
if (!prev) {
return doc
}
return { shapes: prev, past: doc.past.slice(0, -1), future: [doc.shapes, ...doc.future] }
}
export function redoShape(doc: MarkupDocument): MarkupDocument {
const next = doc.future.at(0)
if (!next) {
return doc
}
return { shapes: next, past: [...doc.past, doc.shapes], future: doc.future.slice(1) }
}
export function clearShapes(doc: MarkupDocument): MarkupDocument {
return doc.shapes.length === 0 ? doc : setShapes(doc, [])
}
export function canUndo(doc: MarkupDocument): boolean {
return doc.past.length > 0
}
export function canRedo(doc: MarkupDocument): boolean {
return doc.future.length > 0
}
export function isEmptyDocument(doc: MarkupDocument): boolean {
return doc.shapes.length === 0
}
// ─── Geometry helpers (shared by live overlay + compositor) ─────────────────
export type NormalizedRect = { x: number; y: number; width: number; height: number }
export function normalizeRect(from: MarkupPoint, to: MarkupPoint): NormalizedRect {
return {
x: Math.min(from.x, to.x),
y: Math.min(from.y, to.y),
width: Math.abs(to.x - from.x),
height: Math.abs(to.y - from.y)
}
}
export function scalePoint(point: MarkupPoint, scale: number): MarkupPoint {
return { x: point.x * scale, y: point.y * scale }
}
// Why: the compositor draws into a canvas sized to the base-image's physical
// pixels, while shapes are authored in CSS-viewport pixels. Scaling every shape
// by (imagePx / cssPx) keeps strokes aligned with the underlying screenshot.
export function scaleShape(shape: MarkupShape, scale: number): MarkupShape {
switch (shape.kind) {
case 'pen':
case 'highlight':
return {
...shape,
width: shape.width * scale,
points: shape.points.map((point) => scalePoint(point, scale))
}
case 'arrow':
case 'rect':
case 'ellipse':
return {
...shape,
width: shape.width * scale,
from: scalePoint(shape.from, scale),
to: scalePoint(shape.to, scale)
}
case 'text':
return {
...shape,
fontSize: shape.fontSize * scale,
at: scalePoint(shape.at, scale)
}
}
}
export type ArrowHeadGeometry = { tip: MarkupPoint; left: MarkupPoint; right: MarkupPoint }
const ARROW_HEAD_ANGLE = 0.45
export function arrowHeadLength(width: number): number {
return Math.max(10, width * 3.5)
}
// Returns the two wing points of an arrowhead at `to`, or null when the segment
// has no direction (from === to) so callers can skip drawing a degenerate head.
export function arrowHeadGeometry(
from: MarkupPoint,
to: MarkupPoint,
width: number
): ArrowHeadGeometry | null {
const dx = to.x - from.x
const dy = to.y - from.y
if (dx === 0 && dy === 0) {
return null
}
const angle = Math.atan2(dy, dx)
const size = arrowHeadLength(width)
const leftAngle = angle + Math.PI - ARROW_HEAD_ANGLE
const rightAngle = angle + Math.PI + ARROW_HEAD_ANGLE
return {
tip: to,
left: { x: to.x + size * Math.cos(leftAngle), y: to.y + size * Math.sin(leftAngle) },
right: { x: to.x + size * Math.cos(rightAngle), y: to.y + size * Math.sin(rightAngle) }
}
}
export function highlightWidth(width: number): number {
return width * HIGHLIGHT_WIDTH_MULTIPLIER
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
clampMarkupScale,
dataUrlByteLength,
effectiveMarkupScale,
markupCanvasSize,
MARKUP_DOWNSCALE_STEPS
} from './markup-screenshot-compose'
describe('clampMarkupScale', () => {
it('keeps sane scales and clamps to [1, 4]', () => {
expect(clampMarkupScale(2)).toBe(2)
expect(clampMarkupScale(0.5)).toBe(1)
expect(clampMarkupScale(10)).toBe(4)
})
it('guards against non-finite / non-positive values', () => {
expect(clampMarkupScale(0)).toBe(1)
expect(clampMarkupScale(-3)).toBe(1)
expect(clampMarkupScale(Number.NaN)).toBe(1)
})
})
describe('markupCanvasSize', () => {
it('sizes the output to the content box times the (clamped) scale', () => {
expect(markupCanvasSize(800, 600, 2)).toEqual({ width: 1600, height: 1200 })
expect(markupCanvasSize(800, 600, 0.5)).toEqual({ width: 800, height: 600 })
})
it('keeps the composite within the pixel budget (never over)', () => {
// A large HiDPI pane: 4000×3000 CSS × dpr 2 = 48M px > the ~33.55M (32×1024×1024) ceiling.
const maxPixels = 32 * 1024 * 1024
const size = markupCanvasSize(4000, 3000, 2, maxPixels)
expect(size.width * size.height).toBeLessThanOrEqual(maxPixels)
// Aspect ratio is preserved (one uniform scale).
expect(size.width / size.height).toBeCloseTo(4000 / 3000, 3)
})
})
describe('effectiveMarkupScale', () => {
it('returns the clamped scale when the composite fits the pixel budget', () => {
expect(effectiveMarkupScale(800, 600, 2, 32 * 1024 * 1024)).toBe(2)
})
it('reduces the scale so area stays within the pixel budget', () => {
const scale = effectiveMarkupScale(4000, 3000, 2, 32 * 1024 * 1024)
expect(scale).toBeLessThan(2)
expect(4000 * scale * (3000 * scale)).toBeLessThanOrEqual(32 * 1024 * 1024 + 1)
})
it('defaults to the clamped scale with no pixel budget', () => {
expect(effectiveMarkupScale(3840, 2160, 2)).toBe(2)
})
})
describe('dataUrlByteLength', () => {
it('decodes the base64 payload size', () => {
// "hi" -> base64 "aGk=" (1 pad char) -> 2 bytes
expect(dataUrlByteLength('data:image/png;base64,aGk=')).toBe(2)
// "man" -> "bWFu" (no padding) -> 3 bytes
expect(dataUrlByteLength('data:image/png;base64,bWFu')).toBe(3)
})
it('returns 0 for a malformed data url', () => {
expect(dataUrlByteLength('not-a-data-url')).toBe(0)
})
})
describe('compose budget plans', () => {
it('tries full size first, then shrinks (PNG only, descending)', () => {
expect(MARKUP_DOWNSCALE_STEPS[0]).toBe(1)
expect([...MARKUP_DOWNSCALE_STEPS]).toEqual([...MARKUP_DOWNSCALE_STEPS].sort((a, b) => b - a))
expect(MARKUP_DOWNSCALE_STEPS.every((s) => s > 0 && s <= 1)).toBe(true)
})
})
@@ -0,0 +1,201 @@
// Composites the frozen base screenshot + markup shapes into a single PNG under
// the delivery byte and pixel budgets (PNG only — see MarkupComposeResult).
//
// WYSIWYG: the output canvas matches the on-screen content box (its CSS width ×
// height) times an output scale for crispness. The base image is stretched to
// fill that box exactly as the user saw it (so a distorted objectFit:fill remote
// frame composites correctly), and shapes — authored in CSS coordinates — are
// scaled by the same single factor. One uniform scale keeps strokes aligned.
import { scaleShape, type MarkupShape } from './markup-drawing-model'
import { drawShapes } from './markup-shape-render'
export type MarkupComposeResult = {
dataUrl: string
// Why: PNG only — the clipboard:writeImage handler accepts a PNG data URL and
// silently drops anything else, so a JPEG fallback would "succeed" with an
// empty clipboard.
mimeType: 'image/png'
width: number
height: number
byteLength: number
}
export type MarkupComposeInput = {
image: CanvasImageSource
/** On-screen size of the content box the canvas overlaid (CSS pixels). */
displayCssWidth: number
displayCssHeight: number
/** Output resolution multiplier (typically devicePixelRatio). */
outputScale: number
shapes: readonly MarkupShape[]
/** Hard byte ceiling for the encoded data URL payload. */
maxBytes: number
/** Hard pixel (width×height) ceiling — the clipboard handler silently drops
* images over its own dimension limit, so the composite must fit it too. */
maxPixels: number
}
// ─── Pure helpers (unit-tested) ─────────────────────────────────────────────
// Keep the output scale sane so a huge DPR or bogus value can't allocate an
// absurd canvas.
export function clampMarkupScale(scale: number): number {
if (!Number.isFinite(scale) || scale <= 0) {
return 1
}
return Math.min(Math.max(scale, 1), 4)
}
// The single uniform scale actually applied to the base image and every shape:
// the clamped output scale, reduced further if needed so the composite's pixel
// area stays within `maxPixels`. Reducing one uniform factor keeps strokes aligned.
export function effectiveMarkupScale(
displayCssWidth: number,
displayCssHeight: number,
outputScale: number,
maxPixels: number = Number.POSITIVE_INFINITY
): number {
const scale = clampMarkupScale(outputScale)
const area = displayCssWidth * displayCssHeight
if (area <= 0 || !Number.isFinite(maxPixels) || maxPixels <= 0) {
return scale
}
return Math.min(scale, Math.sqrt(maxPixels / area))
}
export function markupCanvasSize(
displayCssWidth: number,
displayCssHeight: number,
outputScale: number,
maxPixels: number = Number.POSITIVE_INFINITY
): { width: number; height: number } {
const scale = effectiveMarkupScale(displayCssWidth, displayCssHeight, outputScale, maxPixels)
// Why: floor (not round) so a rounded-up dimension can't nudge the area back
// over maxPixels at the budget boundary.
return {
width: Math.max(1, Math.floor(displayCssWidth * scale)),
height: Math.max(1, Math.floor(displayCssHeight * scale))
}
}
// Bytes represented by a `data:...;base64,<payload>` URL's payload.
export function dataUrlByteLength(dataUrl: string): number {
const commaIndex = dataUrl.indexOf(',')
if (commaIndex === -1) {
return 0
}
const payload = dataUrl.slice(commaIndex + 1)
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding)
}
// Progressive raster sizes (full, then shrink) tried to fit the byte budget.
// PNG at every step — never JPEG (see MarkupComposeResult).
export const MARKUP_DOWNSCALE_STEPS = [1, 0.85, 0.7, 0.55, 0.4, 0.3] as const
// ─── Canvas raster (thin) ───────────────────────────────────────────────────
function createCanvas(width: number, height: number): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(width))
canvas.height = Math.max(1, Math.round(height))
return canvas
}
function renderComposite(input: MarkupComposeInput): HTMLCanvasElement {
const scale = effectiveMarkupScale(
input.displayCssWidth,
input.displayCssHeight,
input.outputScale,
input.maxPixels
)
const size = markupCanvasSize(
input.displayCssWidth,
input.displayCssHeight,
input.outputScale,
input.maxPixels
)
const canvas = createCanvas(size.width, size.height)
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('markup compose: 2d context unavailable')
}
ctx.drawImage(input.image, 0, 0, size.width, size.height)
for (const shape of input.shapes) {
drawShapes(ctx, [scaleShape(shape, scale)])
}
return canvas
}
function downscaleCanvas(source: HTMLCanvasElement, factor: number): HTMLCanvasElement {
if (factor >= 1) {
return source
}
const next = createCanvas(source.width * factor, source.height * factor)
const ctx = next.getContext('2d')
if (!ctx) {
return source
}
ctx.drawImage(source, 0, 0, next.width, next.height)
return next
}
// Encodes a canvas to a PNG data URL. Prefers toBlob because Chromium encodes it
// off the main thread — a multi-megapixel toData('image/png') is synchronous and
// freezes the renderer. Falls back to the sync encode if toBlob yields no blob.
function canvasToPngDataUrl(canvas: HTMLCanvasElement): Promise<string> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
try {
resolve(canvas.toDataURL('image/png'))
} catch (error) {
reject(error instanceof Error ? error : new Error('markup compose: toDataURL failed'))
}
return
}
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('markup compose: blob read failed'))
reader.readAsDataURL(blob)
}, 'image/png')
})
}
// Encodes a PNG within budget: full size, then progressively smaller. Always
// returns a PNG (best effort at the smallest step) so the clipboard handler
// accepts it and delivery never silently drops. Async so the per-step PNG encode
// runs off the main thread instead of blocking the renderer.
export async function composeMarkupDataUrl(
input: MarkupComposeInput
): Promise<MarkupComposeResult> {
const composite = renderComposite(input)
let smallest: MarkupComposeResult | null = null
for (const step of MARKUP_DOWNSCALE_STEPS) {
const canvas = downscaleCanvas(composite, step)
const dataUrl = await canvasToPngDataUrl(canvas)
const byteLength = dataUrlByteLength(dataUrl)
const result: MarkupComposeResult = {
dataUrl,
mimeType: 'image/png',
width: canvas.width,
height: canvas.height,
byteLength
}
if (byteLength <= input.maxBytes) {
return result
}
smallest = result
}
// Why: still over the byte budget at the smallest step — return it anyway. It's
// a PNG within the pixel budget (every step is), so the clipboard handler
// accepts it; a slightly large attachment beats dropping the user's markup.
if (!smallest) {
// Unreachable: MARKUP_DOWNSCALE_STEPS is non-empty, so the loop always sets it.
throw new Error('markup compose: no downscale step produced output')
}
return smallest
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { drawShape } from './markup-shape-render'
import { HIGHLIGHT_ALPHA, type MarkupShape } from './markup-drawing-model'
// Minimal recording stand-in for CanvasRenderingContext2D — drawShape only uses
// this subset, so we can assert the dispatch without a real canvas.
function makeRecordingCtx() {
const calls: { method: string; args: unknown[] }[] = []
const record =
(method: string) =>
(...args: unknown[]) => {
calls.push({ method, args })
}
const ctx = {
save: record('save'),
restore: record('restore'),
beginPath: record('beginPath'),
moveTo: record('moveTo'),
lineTo: record('lineTo'),
arc: record('arc'),
ellipse: record('ellipse'),
stroke: record('stroke'),
fill: record('fill'),
strokeRect: record('strokeRect'),
fillText: record('fillText'),
strokeText: record('strokeText'),
lineCap: '',
lineJoin: '',
strokeStyle: '',
fillStyle: '',
lineWidth: 0,
globalAlpha: 1,
font: '',
textBaseline: ''
}
const methods = (name: string) => calls.filter((c) => c.method === name)
return { ctx: ctx as unknown as CanvasRenderingContext2D, calls, methods }
}
describe('drawShape dispatch', () => {
it('strokes a multi-point pen as a polyline', () => {
const { ctx, methods } = makeRecordingCtx()
const shape: MarkupShape = {
id: 'p',
kind: 'pen',
color: '#ef4444',
width: 4,
points: [
{ x: 0, y: 0 },
{ x: 5, y: 5 },
{ x: 9, y: 1 }
]
}
drawShape(ctx, shape)
expect(methods('moveTo')).toHaveLength(1)
expect(methods('lineTo')).toHaveLength(2)
expect(methods('stroke')).toHaveLength(1)
})
it('draws a dot for a single-point pen', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, { id: 'p', kind: 'pen', color: '#fff', width: 4, points: [{ x: 3, y: 3 }] })
expect(methods('arc')).toHaveLength(1)
expect(methods('fill')).toHaveLength(1)
})
it('applies translucent alpha for highlights', () => {
const { ctx } = makeRecordingCtx()
drawShape(ctx, {
id: 'h',
kind: 'highlight',
color: '#eab308',
width: 4,
points: [
{ x: 0, y: 0 },
{ x: 8, y: 0 }
]
})
expect(ctx.globalAlpha).toBe(HIGHLIGHT_ALPHA)
})
it('draws an arrow as a shaft plus a head', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'a',
kind: 'arrow',
color: '#fff',
width: 4,
from: { x: 0, y: 0 },
to: { x: 20, y: 0 }
})
// shaft (moveTo+lineTo) + head (moveTo+lineTo+lineTo)
expect(methods('moveTo').length).toBeGreaterThanOrEqual(2)
expect(methods('stroke').length).toBeGreaterThanOrEqual(2)
})
it('uses strokeRect for rectangles', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'r',
kind: 'rect',
color: '#fff',
width: 2,
from: { x: 10, y: 10 },
to: { x: 4, y: 30 }
})
expect(methods('strokeRect')).toHaveLength(1)
expect(methods('strokeRect')[0].args).toEqual([4, 10, 6, 20])
})
it('uses ellipse for ovals', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'e',
kind: 'ellipse',
color: '#fff',
width: 2,
from: { x: 0, y: 0 },
to: { x: 20, y: 10 }
})
expect(methods('ellipse')).toHaveLength(1)
expect(methods('ellipse')[0].args.slice(0, 4)).toEqual([10, 5, 10, 5])
})
it('renders single-line text with a halo (stroke behind fill)', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 't',
kind: 'text',
color: '#fff',
at: { x: 5, y: 5 },
text: 'fix this',
fontSize: 18
})
expect(methods('strokeText')).toHaveLength(1)
expect(methods('fillText')).toHaveLength(1)
expect(methods('fillText')[0].args[0]).toBe('fix this')
})
it('save/restore brackets every shape so state never leaks', () => {
const { ctx, methods } = makeRecordingCtx()
drawShape(ctx, {
id: 'r',
kind: 'rect',
color: '#fff',
width: 2,
from: { x: 0, y: 0 },
to: { x: 1, y: 1 }
})
expect(methods('save')).toHaveLength(1)
expect(methods('restore')).toHaveLength(1)
})
})
@@ -0,0 +1,144 @@
// Canvas rendering for markup shapes. Shared by the live overlay (draws into the
// visible canvas) and the compositor (draws into the offscreen export canvas), so
// on-screen preview and the delivered PNG are pixel-identical.
import {
arrowHeadGeometry,
highlightWidth,
normalizeRect,
HIGHLIGHT_ALPHA,
type ArrowShape,
type EllipseShape,
type MarkupShape,
type PenShape,
type HighlightShape,
type RectShape,
type TextShape
} from './markup-drawing-model'
export const TEXT_FONT_FAMILY =
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
export function drawShape(ctx: CanvasRenderingContext2D, shape: MarkupShape): void {
ctx.save()
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = shape.color
ctx.fillStyle = shape.color
switch (shape.kind) {
case 'pen':
drawStroke(ctx, shape)
break
case 'highlight':
drawHighlight(ctx, shape)
break
case 'arrow':
drawArrow(ctx, shape)
break
case 'rect':
drawRect(ctx, shape)
break
case 'ellipse':
drawEllipse(ctx, shape)
break
case 'text':
drawText(ctx, shape)
break
}
ctx.restore()
}
export function drawShapes(ctx: CanvasRenderingContext2D, shapes: readonly MarkupShape[]): void {
for (const shape of shapes) {
drawShape(ctx, shape)
}
}
function drawStroke(ctx: CanvasRenderingContext2D, shape: PenShape): void {
ctx.lineWidth = shape.width
strokePolyline(ctx, shape.points, shape.width)
}
function drawHighlight(ctx: CanvasRenderingContext2D, shape: HighlightShape): void {
// Why: translucent + fat so it reads as a marker pass over the screenshot
// without hiding the pixels beneath.
ctx.globalAlpha = HIGHLIGHT_ALPHA
const width = highlightWidth(shape.width)
ctx.lineWidth = width
strokePolyline(ctx, shape.points, width)
}
function strokePolyline(
ctx: CanvasRenderingContext2D,
points: readonly { x: number; y: number }[],
width: number
): void {
if (points.length === 0) {
return
}
if (points.length === 1) {
// Why: a tap (single point) still leaves a visible dot.
const point = points[0]
ctx.beginPath()
ctx.arc(point.x, point.y, Math.max(width / 2, 1), 0, Math.PI * 2)
ctx.fill()
return
}
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length; i += 1) {
ctx.lineTo(points[i].x, points[i].y)
}
ctx.stroke()
}
function drawArrow(ctx: CanvasRenderingContext2D, shape: ArrowShape): void {
ctx.lineWidth = shape.width
ctx.beginPath()
ctx.moveTo(shape.from.x, shape.from.y)
ctx.lineTo(shape.to.x, shape.to.y)
ctx.stroke()
const head = arrowHeadGeometry(shape.from, shape.to, shape.width)
if (!head) {
return
}
ctx.beginPath()
ctx.moveTo(head.left.x, head.left.y)
ctx.lineTo(head.tip.x, head.tip.y)
ctx.lineTo(head.right.x, head.right.y)
ctx.stroke()
}
function drawRect(ctx: CanvasRenderingContext2D, shape: RectShape): void {
ctx.lineWidth = shape.width
const rect = normalizeRect(shape.from, shape.to)
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height)
}
function drawEllipse(ctx: CanvasRenderingContext2D, shape: EllipseShape): void {
ctx.lineWidth = shape.width
const rect = normalizeRect(shape.from, shape.to)
const cx = rect.x + rect.width / 2
const cy = rect.y + rect.height / 2
ctx.beginPath()
ctx.ellipse(cx, cy, rect.width / 2, rect.height / 2, 0, 0, Math.PI * 2)
ctx.stroke()
}
function drawText(ctx: CanvasRenderingContext2D, shape: TextShape): void {
ctx.font = `600 ${shape.fontSize}px ${TEXT_FONT_FAMILY}`
ctx.textBaseline = 'top'
// Why: a thin contrasting halo keeps text legible over busy screenshots
// regardless of the underlying pixels.
ctx.lineWidth = Math.max(shape.fontSize / 6, 2)
ctx.strokeStyle = haloColor(shape.color)
ctx.lineJoin = 'round'
// Text comes from a single-line input, so there are no newlines to lay out.
ctx.strokeText(shape.text, shape.at.x, shape.at.y)
ctx.fillText(shape.text, shape.at.x, shape.at.y)
}
// White text gets a dark halo, everything else a light halo.
function haloColor(color: string): string {
return color.toLowerCase() === '#ffffff' ? 'rgba(0,0,0,0.65)' : 'rgba(255,255,255,0.85)'
}
@@ -0,0 +1,61 @@
/** @vitest-environment happy-dom */
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const HINT_KEY = 'orca.browser.markup-draw-hint-seen'
vi.mock('@/store', () => ({
useAppStore: (selector: (state: { persistedUIReady: boolean }) => unknown) =>
selector({ persistedUIReady: true })
}))
import { useMarkupDrawHint } from './use-markup-draw-hint'
describe('useMarkupDrawHint', () => {
beforeEach(() => {
window.localStorage.removeItem(HINT_KEY)
})
afterEach(() => {
window.localStorage.removeItem(HINT_KEY)
})
it('opens once when eligible and records the seen flag', () => {
const { result } = renderHook(() => useMarkupDrawHint(true))
expect(result.current.hintOpen).toBe(true)
expect(window.localStorage.getItem(HINT_KEY)).toBe('true')
})
it('does not open when the surface is not eligible', () => {
const { result } = renderHook(() => useMarkupDrawHint(false))
expect(result.current.hintOpen).toBe(false)
expect(window.localStorage.getItem(HINT_KEY)).toBeNull()
})
it('closes when eligibility drops so a hidden pane cannot keep it open', () => {
const { result, rerender } = renderHook(({ eligible }) => useMarkupDrawHint(eligible), {
initialProps: { eligible: true }
})
expect(result.current.hintOpen).toBe(true)
rerender({ eligible: false })
expect(result.current.hintOpen).toBe(false)
})
it('dismisses without reopening after the first view', () => {
const first = renderHook(() => useMarkupDrawHint(true))
expect(first.result.current.hintOpen).toBe(true)
act(() => {
first.result.current.dismissHint()
})
expect(first.result.current.hintOpen).toBe(false)
first.unmount()
const second = renderHook(() => useMarkupDrawHint(true))
expect(second.result.current.hintOpen).toBe(false)
})
})
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from 'react'
import { useAppStore } from '@/store'
// One-time discovery highlight for the screenshot-markup Draw button. Shows once
// per install — the first time the button is usable and its surface is active —
// so users notice the new tool. Gated on its own localStorage flag (not a
// contextual tour), so it fires for everyone, including users who already
// finished the capped browser tour.
//
// Stays open until the user dismisses it (outside click / Escape / blur), clicks
// Draw, or the button stops being eligible. No auto-timeout.
const MARKUP_DRAW_HINT_SEEN_KEY = 'orca.browser.markup-draw-hint-seen'
// Records the first-ever view and returns whether this call is that first view.
// Returns false when storage is unavailable so a private-mode session never
// risks nagging on every open.
function claimFirstView(): boolean {
try {
if (window.localStorage.getItem(MARKUP_DRAW_HINT_SEEN_KEY) === 'true') {
return false
}
window.localStorage.setItem(MARKUP_DRAW_HINT_SEEN_KEY, 'true')
return true
} catch {
return false
}
}
export type MarkupDrawHint = { hintOpen: boolean; dismissHint: () => void }
export function useMarkupDrawHint(eligible: boolean): MarkupDrawHint {
const persistedUIReady = useAppStore((state) => state.persistedUIReady)
const [hintOpen, setHintOpen] = useState(false)
useEffect(() => {
// Why: only nudge once the app is ready and the button is usable on a
// visible surface. If eligibility drops mid-hint (tab switch, grab
// started, markup open, blank tab), close it so a forced-open floating
// layer can't stick over a hidden or disabled control at (0,0).
if (!persistedUIReady || !eligible) {
setHintOpen(false)
return
}
if (claimFirstView()) {
setHintOpen(true)
}
}, [eligible, persistedUIReady])
const dismissHint = useCallback(() => setHintOpen(false), [])
return { hintOpen, dismissHint }
}
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { blitMarkupScene, renderCommittedLayer } from './markup-canvas-render'
import { useMarkupKeyboardShortcuts, type PendingText } from './useMarkupKeyboardShortcuts'
import { useMarkupPointerHandlers } from './useMarkupPointerHandlers'
import {
canRedo,
canUndo,
clearShapes,
commitShape,
createMarkupDocument,
DEFAULT_MARKUP_COLOR,
DEFAULT_MARKUP_FONT_SIZE,
DEFAULT_MARKUP_WIDTH,
redoShape,
undoShape,
type MarkupDocument,
type MarkupShape,
type MarkupTool
} from './markup-drawing-model'
type Size = { width: number; height: number; dpr: number }
// Owns the markup surface: document, active tool/style, the pending text box, and
// the canvas paint effect. Draw-only — committed shapes are not re-editable.
export function useMarkupEditor(busy: boolean, onCancel: () => void) {
const rootRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const textInputRef = useRef<HTMLInputElement | null>(null)
// Why: committed shapes are rasterized once into this offscreen layer; the live
// paint blits it instead of re-stroking every committed shape each pointermove.
const committedLayerRef = useRef<HTMLCanvasElement | null>(null)
if (committedLayerRef.current === null) {
committedLayerRef.current = document.createElement('canvas')
}
const [size, setSize] = useState<Size>({ width: 0, height: 0, dpr: 1 })
const [doc, setDoc] = useState<MarkupDocument>(() => createMarkupDocument())
const [inProgress, setInProgress] = useState<MarkupShape | null>(null)
const [tool, setTool] = useState<MarkupTool>('pen')
const [color, setColor] = useState<string>(DEFAULT_MARKUP_COLOR)
const [width, setWidth] = useState<number>(DEFAULT_MARKUP_WIDTH)
const [fontSize, setFontSize] = useState<number>(DEFAULT_MARKUP_FONT_SIZE)
const [pendingText, setPendingText] = useState<PendingText | null>(null)
// Track the content-box size so the canvas matches the frozen backdrop exactly.
useEffect(() => {
const root = rootRef.current
if (!root) {
return undefined
}
const measure = () => {
const rect = root.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
// Why: bail on an unchanged measurement so an identical ResizeObserver/resize
// tick can't schedule a redundant repaint.
setSize((prev) =>
prev.width === rect.width && prev.height === rect.height && prev.dpr === dpr
? prev
: { width: rect.width, height: rect.height, dpr }
)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(root)
// Why: a monitor move changes devicePixelRatio without changing the element's
// CSS box, so ResizeObserver won't fire — window resize (which Chromium emits
// on dpr changes) re-measures the dpr so the canvas repaints at the new scale.
window.addEventListener('resize', measure)
return () => {
observer.disconnect()
window.removeEventListener('resize', measure)
}
}, [])
// Rasterize committed shapes into the offscreen layer only when they (or the
// size) change — not on every in-progress pointermove.
useEffect(() => {
const layer = committedLayerRef.current
if (!layer) {
return
}
renderCommittedLayer(layer, doc.shapes, size.width, size.height, size.dpr)
}, [doc.shapes, size])
// Blit the cached layer + the in-progress shape, coalesced to one paint per
// frame so a burst of pointermove events can't queue redundant full repaints.
useEffect(() => {
const canvas = canvasRef.current
const layer = committedLayerRef.current
if (!canvas || !layer) {
return undefined
}
const handle = requestAnimationFrame(() => {
blitMarkupScene(canvas, layer, inProgress, size.width, size.height, size.dpr)
})
return () => cancelAnimationFrame(handle)
}, [doc.shapes, inProgress, size])
// Why: focus the text input on mount — a placement click can beat autoFocus.
useEffect(() => {
if (!pendingText) {
return undefined
}
const handle = requestAnimationFrame(() => textInputRef.current?.focus())
return () => cancelAnimationFrame(handle)
}, [pendingText])
const undo = useCallback(() => setDoc((current) => undoShape(current)), [])
const redo = useCallback(() => setDoc((current) => redoShape(current)), [])
const clear = useCallback(() => {
// Why: also drop any open text input / in-progress stroke so a clear leaves a
// truly clean slate — otherwise a pending input blur can re-add text.
setPendingText(null)
setInProgress(null)
setDoc((current) => clearShapes(current))
}, [])
useMarkupKeyboardShortcuts({ pendingText, setPendingText, undo, redo, onCancel })
const pointerHandlers = useMarkupPointerHandlers({
busy,
tool,
color,
width,
pendingText,
canvasRef,
setInProgress,
setPendingText,
setDoc
})
const commitPendingText = useCallback(
(text: string) => {
const at = pendingText
setPendingText(null)
const trimmed = text.trim()
if (!at || trimmed.length === 0) {
return
}
setDoc((document) =>
commitShape(document, {
id: crypto.randomUUID(),
kind: 'text',
color,
at,
text: trimmed,
fontSize
})
)
},
[color, fontSize, pendingText]
)
const cancelPendingText = useCallback(() => setPendingText(null), [])
return {
rootRef,
canvasRef,
textInputRef,
tool,
color,
width,
fontSize,
pendingText,
shapes: doc.shapes,
canUndo: canUndo(doc),
canRedo: canRedo(doc),
setTool,
setColor,
setWidth,
setFontSize,
undo,
redo,
clear,
...pointerHandlers,
commitPendingText,
cancelPendingText
}
}
@@ -0,0 +1,52 @@
import { useEffect } from 'react'
export type PendingText = { x: number; y: number; initial: string }
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
}
const tag = target.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable
}
export type MarkupKeyboardParams = {
pendingText: PendingText | null
setPendingText: (value: PendingText | null) => void
undo: () => void
redo: () => void
onCancel: () => void
}
// Window-level shortcuts for the markup overlay: Escape (close the open text box,
// else exit markup) and platform-correct undo/redo.
export function useMarkupKeyboardShortcuts(params: MarkupKeyboardParams): void {
const { pendingText, setPendingText, undo, redo, onCancel } = params
useEffect(() => {
const isMac = navigator.userAgent.includes('Mac')
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (pendingText) {
setPendingText(null)
} else {
onCancel()
}
return
}
if (isTypingTarget(event.target)) {
return
}
const mod = isMac ? event.metaKey : event.ctrlKey
if (mod && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) {
redo()
} else {
undo()
}
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [pendingText, setPendingText, undo, redo, onCancel])
}
@@ -0,0 +1,172 @@
import { useCallback, useRef, useState } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import {
CLIPBOARD_IMAGE_MAX_PIXELS,
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES
} from '../../../../../shared/clipboard-image'
import {
captureMarkupBaseImage,
type MarkupBaseImage,
type MarkupCaptureSource
} from './markup-base-image'
import { composeMarkupDataUrl, type MarkupComposeResult } from './markup-screenshot-compose'
import type { MarkupShape } from './markup-drawing-model'
export type MarkupModeState = 'idle' | 'capturing' | 'drawing' | 'composing'
// Where to capture the base image from, plus the on-screen geometry used to
// size and scale the composite. Resolved lazily at start() time by the owner
// (BrowserPane) since it depends on which environment the pane is showing.
export type MarkupCaptureContext = {
source: MarkupCaptureSource
cssWidth: number
cssHeight: number
outputScale: number
}
export type MarkupCompleteInput = {
imageElement: CanvasImageSource
shapes: readonly MarkupShape[]
}
type UseMarkupModeParams = {
getCaptureContext: () => MarkupCaptureContext | null
onDeliver: (result: MarkupComposeResult) => Promise<void> | void
}
export type MarkupModeController = {
state: MarkupModeState
isActive: boolean
baseImage: MarkupBaseImage | null
start: () => Promise<void>
cancel: () => void
complete: (input: MarkupCompleteInput) => Promise<void>
}
export function useMarkupMode({
getCaptureContext,
onDeliver
}: UseMarkupModeParams): MarkupModeController {
const [state, setState] = useState<MarkupModeState>('idle')
const [baseImage, setBaseImage] = useState<MarkupBaseImage | null>(null)
const contextRef = useRef<MarkupCaptureContext | null>(null)
// Why: a token invalidates an in-flight capture if the user cancels before it
// resolves, so the stale promise can't flip state back to 'drawing'.
const captureTokenRef = useRef(0)
const reset = useCallback(() => {
captureTokenRef.current += 1
contextRef.current = null
setBaseImage(null)
setState('idle')
}, [])
const reportError = useCallback((key: string, fallback: string) => {
toast.error(translate(key, fallback))
}, [])
const start = useCallback(async () => {
const context = getCaptureContext()
if (!context) {
reportError(
'auto.components.browser-pane.markup.errorUnavailable',
'Screenshot markup is not available on this page.'
)
return
}
const token = (captureTokenRef.current += 1)
contextRef.current = context
setState('capturing')
try {
const image = await captureMarkupBaseImage(context.source)
if (captureTokenRef.current !== token) {
return
}
setBaseImage(image)
setState('drawing')
} catch {
if (captureTokenRef.current !== token) {
return
}
// Why: a capture failure has no overlay to fall back to, so return to idle
// (not a stuck 'active' state with no surface and an inert Escape).
reportError(
'auto.components.browser-pane.markup.errorCapture',
'Could not capture the page to draw on.'
)
reset()
}
}, [getCaptureContext, reportError, reset])
const cancel = useCallback(() => {
reset()
}, [reset])
const complete = useCallback(
async ({ imageElement, shapes }: MarkupCompleteInput) => {
const context = contextRef.current
if (!context) {
reset()
return
}
// Why: invalidate this completion if the user cancels or restarts while
// onDeliver is pending, so a stale callback can't reset/error the new session.
const token = captureTokenRef.current
setState('composing')
// Why: yield a frame so the busy 'composing' UI (disabled buttons, progress
// cursor) paints before the synchronous composite raster runs — otherwise
// Copy freezes with no visible feedback. Skip the work if cancelled meanwhile.
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
if (captureTokenRef.current !== token) {
return
}
try {
const result = await composeMarkupDataUrl({
image: imageElement,
displayCssWidth: context.cssWidth,
displayCssHeight: context.cssHeight,
outputScale: context.outputScale,
shapes,
// Why: target the clipboard handler's own ceilings — the byte limit
// (≈18 MB, well above a normal viewport PNG) and the pixel limit, which
// the handler otherwise enforces by silently dropping oversize images.
maxBytes: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
maxPixels: CLIPBOARD_IMAGE_MAX_PIXELS
})
// Why: re-check before delivering — the async compose is a wide window in
// which the user can Escape/cancel, and onDeliver writes the clipboard
// irreversibly. Without this, a cancelled session still overwrites it.
if (captureTokenRef.current !== token) {
return
}
await onDeliver(result)
if (captureTokenRef.current !== token) {
return
}
reset()
} catch {
if (captureTokenRef.current !== token) {
return
}
// Why: the frozen backdrop is still valid, so return to drawing (not a
// dead-end error state) — the user can retry Copy or Cancel out.
reportError(
'auto.components.browser-pane.markup.errorAttach',
'Could not attach the markup screenshot.'
)
setState('drawing')
}
},
[onDeliver, reset, reportError]
)
return {
state,
isActive: state !== 'idle',
baseImage,
start,
cancel,
complete
}
}
@@ -0,0 +1,99 @@
import { useCallback } from 'react'
import type React from 'react'
import type { PendingText } from './useMarkupKeyboardShortcuts'
import {
commitShape,
type MarkupDocument,
type MarkupPoint,
type MarkupShape,
type MarkupTool
} from './markup-drawing-model'
export type MarkupPointerParams = {
busy: boolean
tool: MarkupTool
color: string
width: number
pendingText: PendingText | null
canvasRef: React.RefObject<HTMLCanvasElement | null>
setInProgress: React.Dispatch<React.SetStateAction<MarkupShape | null>>
setPendingText: (value: PendingText | null) => void
setDoc: React.Dispatch<React.SetStateAction<MarkupDocument>>
}
// Canvas pointer interactions: draw a new shape, or place text. Split out of
// useMarkupEditor to keep that hook focused.
export function useMarkupPointerHandlers(params: MarkupPointerParams) {
const { busy, tool, color, width, pendingText, canvasRef, setInProgress, setPendingText, setDoc } =
params
const pointFromEvent = useCallback(
(event: { clientX: number; clientY: number }): MarkupPoint => {
const canvas = canvasRef.current
if (!canvas) {
return { x: 0, y: 0 }
}
const rect = canvas.getBoundingClientRect()
return { x: event.clientX - rect.left, y: event.clientY - rect.top }
},
[canvasRef]
)
const onPointerDown = useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
if (busy || event.button !== 0) {
return
}
const point = pointFromEvent(event)
if (tool === 'text') {
// Why: a box is already open — this click's job is only to commit it (the
// input's blur fires), not to open a second box at the click point.
if (pendingText) {
return
}
// Why: keep focus off the canvas so the mounting text input keeps it.
event.preventDefault()
setPendingText({ x: point.x, y: point.y, initial: '' })
return
}
event.currentTarget.setPointerCapture(event.pointerId)
const id = crypto.randomUUID()
if (tool === 'pen' || tool === 'highlight') {
setInProgress({ id, kind: tool, color, width, points: [point] })
} else {
setInProgress({ id, kind: tool, color, width, from: point, to: point })
}
},
[busy, color, pendingText, pointFromEvent, setInProgress, setPendingText, tool, width]
)
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
setInProgress((current) => {
if (!current) {
return current
}
const point = pointFromEvent(event)
if (current.kind === 'pen' || current.kind === 'highlight') {
return { ...current, points: [...current.points, point] }
}
if (current.kind === 'text') {
return current
}
return { ...current, to: point }
})
},
[pointFromEvent, setInProgress]
)
const onPointerUp = useCallback(() => {
setInProgress((current) => {
if (current) {
setDoc((document) => commitShape(document, current))
}
return null
})
}, [setDoc, setInProgress])
return { onPointerDown, onPointerMove, onPointerUp }
}
@@ -52,6 +52,10 @@ export function GrokAccountsSection(): React.JSX.Element {
const signedIn = status?.signedIn === true
const tokenFresh = status?.tokenFresh === true
// Why: unified-billing accounts have no weekly credits; surface their
// monthly included usage instead of hiding the usage row entirely.
const usageIsWeekly = Boolean(grokUsage?.weekly)
const usageWindow = grokUsage?.weekly ?? grokUsage?.monthly ?? null
return (
<section id="accounts-grok" className="space-y-4 scroll-mt-6">
@@ -148,32 +152,46 @@ export function GrokAccountsSection(): React.JSX.Element {
</Button>
</div>
{grokUsage?.weekly ? (
{usageWindow ? (
<SearchableSetting
title={translate(
'auto.components.settings.GrokAccountsSection.a8f3e2c1b4',
'Weekly credits'
)}
description={translate(
'auto.components.settings.GrokAccountsSection.b7e2d9f0a3',
'Same weekly credit % as the grok /usage screen in the terminal.'
)}
title={
usageIsWeekly
? translate(
'auto.components.settings.GrokAccountsSection.a8f3e2c1b4',
'Weekly credits'
)
: translate(
'auto.components.settings.GrokAccountsSection.e6dadc1e2b',
'Monthly usage'
)
}
description={
usageIsWeekly
? translate(
'auto.components.settings.GrokAccountsSection.b7e2d9f0a3',
'Same weekly credit % as the grok /usage screen in the terminal.'
)
: translate(
'auto.components.settings.GrokAccountsSection.75e396bf42',
'Included monthly usage for Grok unified-billing accounts.'
)
}
keywords={['grok', 'xai', 'usage', 'credits', 'oauth']}
>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary" className="tabular-nums">
{Math.round(grokUsage.weekly.usedPercent)}%
{Math.round(usageWindow.usedPercent)}%
</Badge>
{grokUsage.weekly.resetDescription ? (
{usageWindow.resetDescription ? (
<span className="text-muted-foreground">
{translate(
'auto.components.settings.GrokAccountsSection.c6d1a8f4e2',
'Resets {{when}}',
{ when: grokUsage.weekly.resetDescription }
{ when: usageWindow.resetDescription }
)}
</span>
) : null}
{grokUsage.usageMetadata?.authProvenance ? (
{grokUsage?.usageMetadata?.authProvenance ? (
<span className="truncate text-muted-foreground">
{grokUsage.usageMetadata.authProvenance}
</span>
@@ -13,6 +13,13 @@ import {
import { Cloud, Download, Trash2, Loader2, ChevronDown, Check } from 'lucide-react'
import { translate } from '@/i18n/i18n'
function describeSpeechModelDownloadError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
// Why: ipcRenderer.invoke wraps main-process rejections; strip the transport
// prefix so the toast shows only the underlying download failure.
return message.replace(/^Error invoking remote method '[^']+': (?:Error: )?/, '')
}
type VoiceSpeechModelSectionProps = {
voiceSettings: VoiceSettings
catalog: SpeechModelManifest[]
@@ -90,16 +97,17 @@ export function VoiceSpeechModelSection({
} else if (!isDownloading) {
// Why: download progress appears in this menu, so starting one should not dismiss it.
event.preventDefault()
void window.api.speech
.downloadModel(manifest.id)
.catch(() =>
toast.error(
translate(
'auto.components.settings.VoicePane.cfde55c7b0',
'Failed to download model.'
)
)
void window.api.speech.downloadModel(manifest.id).catch((error: unknown) =>
toast.error(
translate(
'auto.components.settings.VoicePane.cfde55c7b0',
'Failed to download model.'
),
// Why: the raw cause (e.g. net::ERR_CONTENT_LENGTH_MISMATCH)
// is the only diagnosable signal users can report back.
{ description: describeSpeechModelDownloadError(error) }
)
)
}
}}
className={`group flex items-center gap-2.5 py-2.5 ${
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { CalendarClock, ExternalLink, RefreshCw, Sparkles } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../../store'
@@ -13,6 +14,18 @@ export function GrokUsagePane(): React.JSX.Element {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
// Why: settled snapshots keep their status during refetches (no 'fetching'
// repaint), so manual-refresh feedback must be renderer-local, matching the
// StatusBar refresh button.
const [isRefreshing, setIsRefreshing] = useState(false)
const handleRefresh = (): void => {
if (isRefreshing) {
return
}
setIsRefreshing(true)
void refreshGrokRateLimits().finally(() => setIsRefreshing(false))
}
const openGrokAccounts = (): void => {
openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'accounts-grok' })
@@ -57,7 +70,7 @@ export function GrokUsagePane(): React.JSX.Element {
grok?.weekly && typeof grok.weekly.usedPercent === 'number'
? Math.round(grok.weekly.usedPercent)
: null
const isFetching = grok?.status === 'fetching'
const isFetching = isRefreshing || grok?.status === 'fetching'
return (
<div
@@ -83,7 +96,7 @@ export function GrokUsagePane(): React.JSX.Element {
<Button
variant="ghost"
size="icon-xs"
onClick={() => void refreshGrokRateLimits()}
onClick={handleRefresh}
disabled={isFetching}
aria-label={translate(
'auto.components.stats.GrokUsagePane.i0j1k2l3m4',
@@ -1114,7 +1114,7 @@ function WindowLabel({
// the rest (Flash Lite, experimental) are secondary and would clutter the bar.
const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro'])
function ProviderSegment({
export function ProviderSegment({
p,
compact,
display
@@ -1137,7 +1137,7 @@ function ProviderSegment({
}
// Fetching with no prior data
if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly) {
if (p.status === 'fetching' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) {
return (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ProviderIcon provider={provider} />
@@ -1156,7 +1156,7 @@ function ProviderSegment({
}
// Error with no data
if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly) {
if (p.status === 'error' && !p.session && !p.weekly && !p.fableWeekly && !p.monthly) {
return (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ProviderIcon provider={provider} />
@@ -1215,6 +1215,16 @@ function ProviderSegment({
window: p.fableWeekly,
label: translate('auto.components.status.bar.StatusBar.a79c64f87e', 'Fable')
}
: null,
// Why: monthly is chip-visible only when it's the sole window (Grok
// unified billing); providers with session/weekly data (OpenCode Go)
// keep monthly tooltip-only so the chip stays uncluttered.
p.monthly && !p.session && !p.weekly
? {
key: 'monthly',
window: p.monthly,
label: formatWindowLabel(p.monthly.windowMinutes)
}
: null
].filter((w): w is { key: string; window: RateLimitWindow; label: string } => w !== null)
@@ -1783,7 +1793,7 @@ export function ProviderDetailsMenu({
{iconOnly ? (
<span className="inline-flex items-center gap-1">
<span
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly || provider.fableWeekly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
className={`inline-block h-2 w-2 rounded-full ${provider.session || provider.weekly || provider.fableWeekly || provider.monthly ? 'bg-muted-foreground/60' : 'bg-muted-foreground/30'}`}
/>
<span className="text-muted-foreground">
{provider.provider === 'claude'
@@ -0,0 +1,86 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types'
vi.mock('@/i18n/i18n', () => ({
i18n: { language: 'en' },
translate: (_key: string, fallback: string, values?: Record<string, string>) => {
let result = fallback
for (const [key, value] of Object.entries(values ?? {})) {
result = result.replace(`{{${key}}}`, value)
}
return result
}
}))
vi.mock('@/lib/agent-catalog', () => ({
AgentIcon: () => null
}))
vi.mock('../../store', () => ({
useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) =>
selector({ usagePercentageDisplay: 'used' })
}))
function windowOf(usedPercent: number, windowMinutes: number): RateLimitWindow {
return { usedPercent, windowMinutes, resetsAt: null, resetDescription: null }
}
// Grok unified-billing accounts surface a monthly window and nothing else.
function grokMonthlyLimits(status: ProviderRateLimits['status']): ProviderRateLimits {
return {
provider: 'grok',
session: null,
weekly: null,
monthly: windowOf(25, 43200),
updatedAt: Date.now(),
error: null,
status
}
}
describe('ProviderSegment monthly window', () => {
it('renders a monthly-only snapshot in the chip instead of a bare icon', async () => {
const { ProviderSegment } = await import('./StatusBar')
const markup = renderToStaticMarkup(
<ProviderSegment p={grokMonthlyLimits('ok')} compact={false} display="used" />
)
expect(markup).toContain('25% used 30d')
})
it('shows monthly data while fetching instead of the loading placeholder', async () => {
const { ProviderSegment } = await import('./StatusBar')
const markup = renderToStaticMarkup(
<ProviderSegment p={grokMonthlyLimits('fetching')} compact={false} display="used" />
)
expect(markup).toContain('25% used 30d')
expect(markup).not.toContain('···')
})
// Why: providers with session/weekly windows (OpenCode Go) keep monthly
// tooltip-only so the chip stays uncluttered.
it('keeps monthly out of the chip when session and weekly windows exist', async () => {
const { ProviderSegment } = await import('./StatusBar')
const limits: ProviderRateLimits = {
provider: 'opencode-go',
session: windowOf(10, 300),
weekly: windowOf(20, 10080),
monthly: windowOf(30, 43200),
updatedAt: Date.now(),
error: null,
status: 'ok'
}
const markup = renderToStaticMarkup(
<ProviderSegment p={limits} compact={false} display="used" />
)
expect(markup).toContain('10% used 5h')
expect(markup).toContain('20% used wk')
expect(markup).not.toContain('30d')
})
})
@@ -1217,6 +1217,21 @@ describe('createFilePathLinkProvider range bounds', () => {
)
})
it('recovers with no links when a path-existence probe rejects (SSH teardown)', async () => {
// Regression: a rejected probe used to escape the void Promise.all as an
// unhandled rejection the crash-breadcrumb buffer retained, leaking heap (#8260).
const shellPathExists = vi.mocked(window.api.shell.pathExists)
shellPathExists.mockRejectedValueOnce(new Error('Remote connection dropped/reconnecting'))
const { provider } = createProviderSetup([makeBufferLine('CLAUDE.md')], new Map())
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links).toEqual([])
expect(shellPathExists).toHaveBeenCalled()
})
it('shows switch and external-open hint for known worktree root hover', async () => {
setPlatform('Macintosh')
storeState.worktreesByRepo = {
@@ -214,23 +214,31 @@ export function createFilePathLinkProvider(
}
)
)
).then((resolvedLinks) => {
const latestFingerprints = new Set(
buildCandidateLogicalLinesForBufferPosition(buffer, bufferLineNumber).map(
(logicalLine) => logicalLine.fingerprint
)
.then((resolvedLinks) => {
const latestFingerprints = new Set(
buildCandidateLogicalLinesForBufferPosition(buffer, bufferLineNumber).map(
(logicalLine) => logicalLine.fingerprint
)
)
)
const providedLinks = resolvedLinks.filter(
(link): link is ProvidedFileLink => link !== null
)
const links = preferLongestNonOverlappingLinks(providedLinks)
.filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint))
.map(({ link }) => link)
if (providedLinks.length > 0 && links.length === 0) {
return
}
callback(links.length > 0 ? links : undefined)
})
const providedLinks = resolvedLinks.filter(
(link): link is ProvidedFileLink => link !== null
)
const links = preferLongestNonOverlappingLinks(providedLinks)
.filter(({ logicalLine }) => latestFingerprints.has(logicalLine.fingerprint))
.map(({ link }) => link)
if (providedLinks.length > 0 && links.length === 0) {
return
}
callback(links.length > 0 ? links : undefined)
})
.catch(() => {
// Why: remote path-existence probes reject with "Remote connection
// dropped/reconnecting" during SSH teardown. Without a catch the
// rejected Promise.all is unhandled and the crash-breadcrumb buffer
// retains it, growing the renderer heap until it crashes (#8260).
callback(undefined)
})
}
}
}
+34 -1
View File
@@ -9028,7 +9028,9 @@
"3325d996cb": "Refresh usage",
"a8f3e2c1b4": "Weekly credits",
"b7e2d9f0a3": "Same weekly credit % as the grok /usage screen in the terminal.",
"c6d1a8f4e2": "Resets {{when}}"
"c6d1a8f4e2": "Resets {{when}}",
"e6dadc1e2b": "Monthly usage",
"75e396bf42": "Included monthly usage for Grok unified-billing accounts."
},
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "Used",
@@ -13018,6 +13020,37 @@
"searchLabels": "Filter labels…",
"searchAssignee": "Filter assignee…",
"back": "Back"
},
"browser-pane": {
"markup": {
"cancel": "Cancel",
"clear": "Clear all",
"copiedToast": "Markup copied — paste it into your agent ({{value0}})",
"copy": "Copy Markup",
"drawButton": "Draw on screenshot",
"drawHint": "Draw on the page, then copy the markup to paste into your agent.",
"drawHintBadge": "New",
"drawHintDismiss": "Got it",
"drawHintTry": "Try it",
"errorAttach": "Could not attach the markup screenshot.",
"errorCapture": "Could not capture the page to draw on.",
"errorUnavailable": "Screenshot markup is not available on this page.",
"fontSize": "Font size",
"hint": "Draw on the page, then copy the markup to paste into your agent.",
"redo": "Redo",
"style": "Color and thickness",
"textInput": "Annotation text",
"tool": {
"arrow": "Arrow",
"ellipse": "Ellipse",
"highlight": "Highlighter",
"pen": "Pen",
"rect": "Rectangle",
"text": "Text"
},
"undo": "Undo",
"widthOption": "{{value0}} px"
}
}
},
"i18n": {
+34 -1
View File
@@ -9028,7 +9028,9 @@
"3325d996cb": "Actualizar uso",
"a8f3e2c1b4": "Créditos semanales",
"b7e2d9f0a3": "El mismo porcentaje de créditos semanales que la pantalla grok /usage en la terminal.",
"c6d1a8f4e2": "Se restablece {{when}}"
"c6d1a8f4e2": "Se restablece {{when}}",
"e6dadc1e2b": "Monthly usage",
"75e396bf42": "Included monthly usage for Grok unified-billing accounts."
},
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "Usado",
@@ -13018,6 +13020,37 @@
"searchLabels": "Filtrar etiquetas…",
"searchAssignee": "Filtrar asignatario…",
"back": "Atrás"
},
"browser-pane": {
"markup": {
"cancel": "Cancelar",
"clear": "Borrar todo",
"copiedToast": "Marcado copiado: pégalo en tu agente ({{value0}})",
"copy": "Copiar marcado",
"drawButton": "Dibujar en la captura",
"drawHint": "Dibuja en la página y copia el markup para pegarlo en tu agente.",
"drawHintBadge": "Nuevo",
"drawHintDismiss": "Entendido",
"drawHintTry": "Probar",
"errorAttach": "No se pudo adjuntar la captura de pantalla con marcado.",
"errorCapture": "No se pudo capturar la página para dibujar.",
"errorUnavailable": "El marcado de captura de pantalla no está disponible en esta página.",
"fontSize": "Tamaño de letra",
"hint": "Dibuja en la página y luego copia el marcado para pegarlo en tu agente.",
"redo": "Rehacer",
"style": "Color y grosor",
"textInput": "Texto de anotación",
"tool": {
"arrow": "Flecha",
"ellipse": "Elipse",
"highlight": "Resaltador",
"pen": "Lápiz",
"rect": "Rectángulo",
"text": "Texto"
},
"undo": "Deshacer",
"widthOption": "{{value0}} px"
}
}
},
"i18n": {
+34 -1
View File
@@ -9028,7 +9028,9 @@
"3325d996cb": "使用状況を更新",
"a8f3e2c1b4": "週次クレジット",
"b7e2d9f0a3": "ターミナルの grok /usage 画面と同じ週次クレジット率です。",
"c6d1a8f4e2": "{{when}} にリセット"
"c6d1a8f4e2": "{{when}} にリセット",
"e6dadc1e2b": "Monthly usage",
"75e396bf42": "Included monthly usage for Grok unified-billing accounts."
},
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "使用済み",
@@ -13018,6 +13020,37 @@
"searchLabels": "ラベルをフィルター…",
"searchAssignee": "担当者をフィルター…",
"back": "戻る"
},
"browser-pane": {
"markup": {
"cancel": "キャンセル",
"clear": "すべて消去",
"copiedToast": "注釈をコピーしました — エージェントに貼り付けてください({{value0}})",
"copy": "注釈をコピー",
"drawButton": "スクリーンショットに描き込み",
"drawHint": "ページに描き込み、マークアップをコピーしてエージェントに貼り付けられます。",
"drawHintBadge": "新機能",
"drawHintDismiss": "了解",
"drawHintTry": "試してみる",
"errorAttach": "注釈付きスクリーンショットを添付できませんでした。",
"errorCapture": "描画用にページを取得できませんでした。",
"errorUnavailable": "このページではスクリーンショット注釈を利用できません。",
"fontSize": "文字サイズ",
"hint": "ページに描き込み、注釈をコピーしてエージェントに貼り付けてください。",
"redo": "やり直し",
"style": "色と太さ",
"textInput": "注釈テキスト",
"tool": {
"arrow": "矢印",
"ellipse": "楕円",
"highlight": "ハイライト",
"pen": "ペン",
"rect": "四角形",
"text": "テキスト"
},
"undo": "元に戻す",
"widthOption": "{{value0}} px"
}
}
},
"i18n": {
+34 -1
View File
@@ -9028,7 +9028,9 @@
"3325d996cb": "사용량 새로 고침",
"a8f3e2c1b4": "주간 크레딧",
"b7e2d9f0a3": "터미널의 grok /usage 화면과 같은 주간 크레딧 비율입니다.",
"c6d1a8f4e2": "{{when}}에 재설정"
"c6d1a8f4e2": "{{when}}에 재설정",
"e6dadc1e2b": "Monthly usage",
"75e396bf42": "Included monthly usage for Grok unified-billing accounts."
},
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "사용",
@@ -13018,6 +13020,37 @@
"searchLabels": "레이블 필터…",
"searchAssignee": "담당자 필터…",
"back": "뒤로"
},
"browser-pane": {
"markup": {
"cancel": "취소",
"clear": "모두 지우기",
"copiedToast": "마크업을 복사했습니다 — 에이전트에 붙여넣으세요 ({{value0}})",
"copy": "마크업 복사",
"drawButton": "스크린샷에 그리기",
"drawHint": "페이지에 그린 다음 마크업을 복사해 에이전트에 붙여넣으세요.",
"drawHintBadge": "새 기능",
"drawHintDismiss": "확인",
"drawHintTry": "사용해 보기",
"errorAttach": "마크업 스크린샷을 첨부하지 못했습니다.",
"errorCapture": "그릴 페이지를 캡처하지 못했습니다.",
"errorUnavailable": "이 페이지에서는 스크린샷 마크업을 사용할 수 없습니다.",
"fontSize": "글자 크기",
"hint": "페이지에 그린 다음 마크업을 복사해 에이전트에 붙여넣으세요.",
"redo": "다시 실행",
"style": "색상 및 두께",
"textInput": "주석 텍스트",
"tool": {
"arrow": "화살표",
"ellipse": "타원",
"highlight": "형광펜",
"pen": "펜",
"rect": "사각형",
"text": "텍스트"
},
"undo": "실행 취소",
"widthOption": "{{value0}} px"
}
}
},
"i18n": {
+34 -1
View File
@@ -9028,7 +9028,9 @@
"3325d996cb": "刷新使用量",
"a8f3e2c1b4": "每周额度",
"b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。",
"c6d1a8f4e2": "{{when}} 重置"
"c6d1a8f4e2": "{{when}} 重置",
"e6dadc1e2b": "Monthly usage",
"75e396bf42": "Included monthly usage for Grok unified-billing accounts."
},
"AppearanceWindowSidebarSection": {
"usagePercentageDisplayUsed": "已用",
@@ -13018,6 +13020,37 @@
"searchLabels": "筛选标签…",
"searchAssignee": "筛选负责人…",
"back": "返回"
},
"browser-pane": {
"markup": {
"cancel": "取消",
"clear": "全部清除",
"copiedToast": "已复制标注 — 粘贴到你的智能体({{value0}})",
"copy": "复制标注",
"drawButton": "在截图上绘制",
"drawHint": "在页面上绘制,然后复制标记并粘贴到你的智能体。",
"drawHintBadge": "新功能",
"drawHintDismiss": "知道了",
"drawHintTry": "试一试",
"errorAttach": "无法附加标注后的截图。",
"errorCapture": "无法捕获页面进行绘制。",
"errorUnavailable": "此页面无法使用截图标注。",
"fontSize": "字号",
"hint": "在页面上绘制,然后复制标注以粘贴到你的智能体。",
"redo": "重做",
"style": "颜色和粗细",
"textInput": "标注文字",
"tool": {
"arrow": "箭头",
"ellipse": "椭圆",
"highlight": "高亮",
"pen": "画笔",
"rect": "矩形",
"text": "文字"
},
"undo": "撤销",
"widthOption": "{{value0}} px"
}
}
},
"i18n": {
@@ -116,6 +116,20 @@ describe('renderer crash diagnostics', () => {
})
})
it('suppresses benign ResizeObserver loop errors instead of recording them', () => {
diagnostics.installRendererCrashDiagnostics()
recordBreadcrumbMock.mockClear()
const preventDefault = vi.fn()
listeners.get('error')?.[0]?.({
message: 'ResizeObserver loop completed with undelivered notifications.',
preventDefault
})
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(recordBreadcrumbMock).not.toHaveBeenCalled()
})
it('disposes global listeners and the memory interval', () => {
diagnostics.installRendererCrashDiagnostics()
@@ -64,6 +64,13 @@ if (typeof import.meta !== 'undefined' && import.meta.hot) {
}
function recordRendererError(event: ErrorEvent): void {
// Why: "ResizeObserver loop completed" is a benign, self-resolving Chromium
// quirk. Recording it fills the breadcrumb buffer and inflates the error
// count without diagnostic value, contributing to renderer heap growth (#8260).
if (/ResizeObserver loop/i.test(event.message)) {
event.preventDefault()
return
}
recordRendererCrashBreadcrumb(
'renderer_error',
compactBreadcrumbData({
+1 -1
View File
@@ -59,7 +59,7 @@ export type ProviderRateLimits = {
weekly: RateLimitWindow | null
/** Claude Fable 7-day weekly window, null if not available. */
fableWeekly?: RateLimitWindow | null
/** 30-day monthly window (OpenCode Go only), null if not available. */
/** 30-day monthly window (OpenCode Go, Grok unified billing), null if not available. */
monthly?: RateLimitWindow | null
/** Named per-model buckets (Gemini only). */
buckets?: RateLimitBucket[]
@@ -60,10 +60,13 @@ describe('TerminalKittyKeyboardModeTracker', () => {
tracker.scan('\x1b[>1u')
expect(tracker.flags).toBe(1)
tracker.scan('\x1b[?1049h')
expect(tracker.hasObservedAlternateScreenSwitch).toBe(true)
expect(tracker.isAlternateScreen).toBe(true)
expect(tracker.flags).toBe(0)
tracker.scan('\x1b[>2u')
expect(tracker.flags).toBe(2)
tracker.scan('\x1b[?1049l')
expect(tracker.isAlternateScreen).toBe(false)
expect(tracker.flags).toBe(1)
})
@@ -30,12 +30,21 @@ export class TerminalKittyKeyboardModeTracker {
private mainStack: number[] = []
private altStack: number[] = []
private alternateScreenActive = false
private alternateScreenSwitchObserved = false
/** Current effective kitty keyboard flags (0 = protocol inactive). */
get flags(): number {
return this.currentFlags
}
get isAlternateScreen(): boolean {
return this.alternateScreenActive
}
get hasObservedAlternateScreenSwitch(): boolean {
return this.alternateScreenSwitchObserved
}
reset(): void {
this.scanTail = ''
this.currentFlags = 0
@@ -44,6 +53,7 @@ export class TerminalKittyKeyboardModeTracker {
this.mainStack = []
this.altStack = []
this.alternateScreenActive = false
this.alternateScreenSwitchObserved = false
}
scan(data: string): void {
@@ -77,6 +87,7 @@ export class TerminalKittyKeyboardModeTracker {
const tail = this.scanTail
this.reset()
this.scanTail = tail
this.alternateScreenSwitchObserved = true
continue
}
if (match[0].endsWith('!p')) {
@@ -108,6 +119,7 @@ export class TerminalKittyKeyboardModeTracker {
if (param !== 47 && param !== 1047 && param !== 1049) {
continue
}
this.alternateScreenSwitchObserved = true
// Why: xterm swaps the current flags with the inactive screen's slot on
// every 47/1047/1049 transition, without an already-active guard —
// mirror it exactly so this state matches what the renderer encodes.