Merge remote-tracking branch 'origin/main' into brennanb2025/fix-mobile-terminal-blank

This commit is contained in:
Brennan Benson
2026-07-14 22:55:02 -07:00
40 changed files with 745 additions and 79 deletions
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [Baixar APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.30",
"version": "0.0.31",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 7
"versionCode": 8
},
"plugins": [
"expo-router",
+17 -6
View File
@@ -19,6 +19,9 @@ export function MobileHostCard(props: {
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const worktreeSummary = props.worktreeCounts
? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: null
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
@@ -38,14 +41,16 @@ export function MobileHostCard(props: {
</Text>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]}>
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
{verdictDisplayLabel(props.verdict)}
{connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
{connected && props.worktreeCounts
? ` · ${props.worktreeCounts.total} worktree${props.worktreeCounts.total !== 1 ? 's' : ''}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: ''}
</Text>
</View>
{connected && worktreeSummary ? (
<Text style={styles.worktreeMetaText} numberOfLines={1}>
{worktreeSummary}
</Text>
) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
@@ -80,8 +85,14 @@ const styles = StyleSheet.create({
},
main: { flex: 1, minWidth: 0, marginRight: spacing.sm },
name: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 },
meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
metaText: { fontSize: 12, color: colors.textSecondary },
meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3, minWidth: 0 },
metaText: { flex: 1, fontSize: 12, color: colors.textSecondary },
worktreeMetaText: {
marginTop: 2,
marginLeft: spacing.xl,
fontSize: 12,
color: colors.textMuted
},
discoveryHint: {
marginTop: spacing.xs,
fontSize: 11,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.142-rc.3",
"version": "1.4.142-rc.5",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -262,6 +262,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
describe('background stream thinning compatibility', () => {
it('reports authoritative snapshot support only for protocol v20 and newer', () => {
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 })
try {
expect(legacy.canProvideAuthoritativeBufferSnapshot('legacy-session')).toBe(false)
expect(adapter.canProvideAuthoritativeBufferSnapshot('current-session')).toBe(true)
} finally {
legacy.dispose()
}
})
it('reports background state on the authoritative-snapshot protocol', () => {
const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify')
try {
@@ -319,6 +329,42 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
ensureConnectedSpy.mockRestore()
}
})
it('still returns the v19 attach snapshot for a desktop renderer replay', async () => {
const ensureConnectedSpy = vi
.spyOn(DaemonClient.prototype, 'ensureConnected')
.mockResolvedValue()
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({
isNew: false,
pid: 123,
shellState: 'unsupported',
snapshot: {
scrollbackAnsi: 'legacy history\r\n',
rehydrateSequences: '\x1b[?2004h',
snapshotAnsi: 'legacy prompt',
modes: { alternateScreen: false },
cols: 80,
rows: 24
}
} as never)
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 })
try {
const result = await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 })
expect(result).toMatchObject({
id: 'legacy-session',
isReattach: true,
snapshot: expect.stringContaining('legacy history')
})
expect(result.snapshot).toContain('legacy prompt')
expect(result.providerSequence).toBeUndefined()
await expect(legacy.getBufferSnapshot('legacy-session')).resolves.toBeNull()
} finally {
legacy.dispose()
requestSpy.mockRestore()
ensureConnectedSpy.mockRestore()
}
})
})
describe('getAppliedSize', () => {
+4
View File
@@ -156,6 +156,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
}
canProvideAuthoritativeBufferSnapshot(_id: string): boolean {
return this.supportsAuthoritativeBufferSnapshots
}
constructor(opts: DaemonPtyAdapterOptions) {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.socketPath = opts.socketPath
+11
View File
@@ -35,6 +35,7 @@ function createAdapter(
protocolVersion,
supportsGitCredentialGuardHost: () =>
protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
canProvideAuthoritativeBufferSnapshot: () => protocolVersion >= 20,
spawn: vi.fn(async (opts: PtySpawnOptions): Promise<PtySpawnResult> => {
const id = opts.sessionId ?? `${label}-new`
sessions.push(id)
@@ -127,6 +128,16 @@ function createAdapter(
}
describe('DaemonPtyRouter', () => {
it('reports snapshot capability for the adapter that owns each session', async () => {
const current = createAdapter('current', ['current-session'], undefined, 22)
const legacy = createAdapter('legacy', ['legacy-session'], undefined, 19)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
expect(router.canProvideAuthoritativeBufferSnapshot('current-session')).toBe(true)
expect(router.canProvideAuthoritativeBufferSnapshot('legacy-session')).toBe(false)
})
it('reports guard-host support for the adapter that owns the session', async () => {
const current = createAdapter('current', [], undefined, 22)
const legacy = createAdapter('legacy', ['legacy-session'], undefined, 21)
+4
View File
@@ -135,6 +135,10 @@ export class DaemonPtyRouter implements IPtyProvider {
return await this.adapterFor(id).getBufferSnapshot(id, opts)
}
canProvideAuthoritativeBufferSnapshot(id: string): boolean {
return this.adapterFor(id).canProvideAuthoritativeBufferSnapshot(id)
}
async clearBuffer(id: string): Promise<void> {
await this.adapterFor(id).clearBuffer(id)
}
+22
View File
@@ -3830,6 +3830,28 @@ describe('registerPtyHandlers', () => {
})
})
it('reports authoritative snapshot capability with the owning provider context', () => {
const capabilityProvider = {
authoritativeIds: new Set(['current-pty']),
canProvideAuthoritativeBufferSnapshot(id: string) {
return this.authoritativeIds.has(id)
}
}
registerPtyHandlers(mainWindow as never)
setLocalPtyProvider(capabilityProvider as never)
const listener = onMock.mock.calls.find(
([channel]) => channel === 'pty:getAuthoritativeBufferSnapshotCapabilitiesSync'
)?.[1] as ((event: { returnValue?: unknown }, args: { ids: unknown[] }) => void) | undefined
const event: { returnValue?: unknown } = {}
listener?.(event, { ids: ['current-pty', 'legacy-pty', 'current-pty', 42] })
expect(event.returnValue).toEqual([
{ id: 'current-pty', authoritative: true },
{ id: 'legacy-pty', authoritative: false }
])
})
it('checks single-PTY liveness without listing every session', async () => {
const hasPty = vi.fn((id: string) => id === 'live-pty')
const listProcesses = vi.fn(async () => {
+35
View File
@@ -1518,6 +1518,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:confirmForegroundProcess')
ipcMain.removeHandler('pty:getCwd')
ipcMain.removeHandler('pty:getSize')
ipcMain.removeAllListeners('pty:getAuthoritativeBufferSnapshotCapabilitiesSync')
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
@@ -5081,6 +5082,40 @@ export function registerPtyHandlers(
}
)
ipcMain.on(
'pty:getAuthoritativeBufferSnapshotCapabilitiesSync',
(event, args: { ids?: unknown }) => {
const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : []
const capabilities: { id: string; authoritative: boolean | null }[] = []
const seen = new Set<string>()
for (const value of ids) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 512 ||
seen.has(value)
) {
continue
}
seen.add(value)
const provider = tryGetProviderForPty(value)
// Why: degraded routing mixes preserved daemons with an in-process
// fallback. Keep all of its panes mounted rather than guess ownership.
capabilities.push({
id: value,
authoritative: provider?.canProvideAuthoritativeBufferSnapshot
? provider.canProvideAuthoritativeBufferSnapshot(value)
: provider && routesFreshSpawnsToLocalProvider(provider)
? false
: null
})
}
// Why: cold deferral runs during render, before hidden panes mount. This
// batch is an in-memory route lookup so legacy PTYs can mount in that pass.
event.returnValue = capabilities
}
)
ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise<boolean | null> => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
+2
View File
@@ -196,6 +196,8 @@ export type IPtyProvider = {
id: string,
opts?: { scrollbackRows?: number }
) => Promise<PtyProviderBufferSnapshot | null>
/** Whether this exact PTY can return a sequence-safe provider snapshot. */
canProvideAuthoritativeBufferSnapshot?: (id: string) => boolean
/**
* The size the PTY has ACTUALLY applied, not the last size requested.
* resize() is fire-and-forget for remote providers (daemon/SSH `notify`),
+3
View File
@@ -1323,6 +1323,9 @@ export type PreloadApi = {
getCwd: (id: string) => Promise<string>
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
getAuthoritativeBufferSnapshotCapabilities?: (
ids: string[]
) => { id: string; authoritative: boolean | null }[]
hasPty: (id: string) => Promise<boolean | null>
getMainBufferSnapshot: (
id: string,
+4
View File
@@ -973,6 +973,10 @@ const api = {
listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> =>
ipcRenderer.invoke('pty:listSessions'),
getAuthoritativeBufferSnapshotCapabilities: (
ids: string[]
): { id: string; authoritative: boolean | null }[] =>
ipcRenderer.sendSync('pty:getAuthoritativeBufferSnapshotCapabilitiesSync', { ids }),
hasPty: (id: string): Promise<boolean | null> => ipcRenderer.invoke('pty:hasPty', { id }),
getMainBufferSnapshot: (
+15 -2
View File
@@ -111,6 +111,8 @@ import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { listBoundAgentTabActions, resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts'
import { terminalProviderHasAuthoritativeSnapshot } from './terminal/terminal-provider-snapshot-capability'
import { useTerminalProviderSnapshotCapability } from './terminal/use-terminal-provider-snapshot-capability'
import {
createFloatingWorkspaceBrowserTab,
createFloatingWorkspaceMarkdownTab,
@@ -354,6 +356,7 @@ function Terminal(): React.JSX.Element | null {
() => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []),
[renderedActiveWorktreeId, tabsByWorktree]
)
useTerminalProviderSnapshotCapability(workspaceSessionReady && hydrationSucceeded)
// Why: the TabBar is rendered into the titlebar via a portal so tabs share
// the same row as the "Orca" title. The target element is created by App.tsx.
@@ -1064,7 +1067,11 @@ function Terminal(): React.JSX.Element | null {
coldActivationDeferralEnabled &&
activationHostSupportsDeferral &&
tab !== undefined &&
canWatcherCoverParkedTerminalTab(renderedActiveWorktreeId, tab)
canWatcherCoverParkedTerminalTab(
renderedActiveWorktreeId,
tab,
terminalProviderHasAuthoritativeSnapshot
)
)
},
immediateTabIds
@@ -1079,7 +1086,13 @@ function Terminal(): React.JSX.Element | null {
// gate. Uncoverable/no-PTY tabs must mount now so they can spawn or keep
// their non-snapshot-backed live transport.
for (const tab of worktreeTabs) {
if (!canWatcherCoverParkedTerminalTab(renderedActiveWorktreeId, tab)) {
if (
!canWatcherCoverParkedTerminalTab(
renderedActiveWorktreeId,
tab,
terminalProviderHasAuthoritativeSnapshot
)
) {
immediateTabIds.add(tab.id)
}
}
@@ -2632,6 +2632,7 @@ function RemoteBrowserPagePane({
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={!frameUrl}
active={markup.isActive}
surfaceActive={isActive}
className="h-7 w-7"
/>
</div>
@@ -5122,6 +5123,7 @@ function BrowserPagePane({
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={isBlankTab || grab.state !== 'idle'}
active={markup.isActive}
surfaceActive={isActive}
/>
<Button
@@ -1,6 +1,8 @@
import React from 'react'
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'
@@ -10,6 +12,12 @@ 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
}
@@ -19,45 +27,134 @@ 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 so users discover the tool.
const { hintOpen, dismissHint } = useMarkupDrawHint(!disabled && !active)
// 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 (
<Tooltip open={hintOpen ? true : undefined}>
<TooltipTrigger asChild>
{/* Why: wrap in a span so tooltip hover still fires when disabled. */}
<span className="inline-flex">
<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: ring the button while the one-time discovery hint is open.
hintOpen && 'ring-2 ring-ring ring-offset-1 ring-offset-background'
)}
onClick={() => {
dismissHint()
onClick()
}}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
<PenTool className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{hintOpen
? translate(
'auto.components.browser-pane.markup.drawHint',
'New — draw on the page, then copy it to your agent'
)
: label}
</TooltipContent>
</Tooltip>
<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,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)
})
})
@@ -2,11 +2,13 @@ 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 — 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.
// It stays open until the user acts (clicks Draw) or the button stops being
// usable; there is no auto-timeout.
// 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.
@@ -31,9 +33,10 @@ export function useMarkupDrawHint(eligible: boolean): MarkupDrawHint {
const [hintOpen, setHintOpen] = useState(false)
useEffect(() => {
// Why: only nudge once the app is ready and the button is usable. If it stops
// being usable while showing (grab started, markup open, blank tab), close it
// so the forced-open tooltip can't get stuck over a now-disabled button.
// 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
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
const ANDROID_COPY: InstallCopy = {
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk'
}
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
@@ -108,7 +108,7 @@ describe('MobilePairingConnectionOptions', () => {
await user.click(screen.getByRole('button', { name: 'Android APK' }))
expect(window.api.shell.openUrl).toHaveBeenCalledWith(
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk'
)
})
@@ -2,7 +2,7 @@ import { translate } from '@/i18n/i18n'
const TESTFLIGHT_URL = 'https://testflight.apple.com/join/YjeGMQBA'
const ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk'
export function MobileRelayBetaAvailability(): React.JSX.Element {
return (
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
@@ -1,4 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
clearTerminalProviderSnapshotCapabilities,
synchronizeTerminalProviderSnapshotCapabilities
} from '../terminal/terminal-provider-snapshot-capability'
import {
TERMINAL_TAB_HOT_RETAIN_MS,
TERMINAL_WORKTREE_HOT_RETAIN_MS,
@@ -61,6 +65,16 @@ describe('canParkTerminalWorktreeRenderers', () => {
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
})
it('keeps a previously mounted v19 terminal eligible for ordinary parking', () => {
const legacyPtyId = 'repo::/worktree@@session-1'
clearTerminalProviderSnapshotCapabilities()
synchronizeTerminalProviderSnapshotCapabilities([legacyPtyId], (ids) =>
ids.map((id) => ({ id, authoritative: false }))
)
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
})
it('never parks when the settings kill switch disables parking', () => {
expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
expect(
@@ -627,6 +627,23 @@ describe('terminal-parked-tab-watchers', () => {
)
})
it('lets cold activation add stricter eligibility without changing ordinary parking', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
const providerCanSnapshotWithoutRenderer = vi.fn(() => false)
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
true
)
expect(
canWatcherCoverParkedTerminalTab(
WORKTREE_ID,
{ id: TAB_ID, ptyId: PTY_ID },
providerCanSnapshotWithoutRenderer
)
).toBe(false)
expect(providerCanSnapshotWithoutRenderer).toHaveBeenCalledWith(PTY_ID)
})
it('rejects a capture containing a legacy non-UUID leaf id', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
@@ -45,6 +45,9 @@ export {
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean
const allowSnapshotBackedPty = (): boolean => true
type ParkedPaneFallbackState = {
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
@@ -103,7 +106,10 @@ function resolveParkedTerminalPaneCandidates(
*/
export function canWatcherCoverParkedTerminalTab(
worktreeId: string,
tab: ParkableTerminalTabModel
tab: ParkableTerminalTabModel,
// Why: cold activation needs stronger provider snapshot support because the
// view has never mounted; ordinary parking can reattach a previously mounted v19 view.
isPtyEligible: ParkedTerminalPtyEligibility = allowSnapshotBackedPty
): boolean {
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
return (
@@ -112,7 +118,8 @@ export function canWatcherCoverParkedTerminalTab(
(pane) =>
pane.ptyId !== null &&
isTerminalLeafId(pane.leafId) &&
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId)
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) &&
isPtyEligible(pane.ptyId)
)
)
}
@@ -1,4 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { isSnapshotBackedTerminalPty } from '../terminal-pane/terminal-hidden-view-parking'
import {
clearTerminalProviderSnapshotCapabilities,
synchronizeTerminalProviderSnapshotCapabilities,
terminalProviderHasAuthoritativeSnapshot
} from './terminal-provider-snapshot-capability'
import {
COLD_ACTIVATION_TAB_DEFER_THRESHOLD,
@@ -232,6 +238,83 @@ describe('cold activation tab deferral', () => {
expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2', 'tab-5', 'tab-9']))
})
it('mounts legacy PTYs eagerly while deferring snapshot-capable siblings', () => {
const worktreeId = 'wt-1'
const allTabIds = tabIds(7)
const ptyIdByTabId = new Map(
allTabIds.map((tabId) => [tabId, `${worktreeId}@@${tabId}-session`])
)
const legacyPtyId = ptyIdByTabId.get('tab-2')!
clearTerminalProviderSnapshotCapabilities()
synchronizeTerminalProviderSnapshotCapabilities([...ptyIdByTabId.values()], (ids) =>
ids.map((id) => ({ id, authoritative: id !== legacyPtyId }))
)
const restrictions = new Map<string, ReadonlySet<string>>()
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
const deferring = planColdActivationTabDeferral({
restrictions,
deferredMountTabIdsByWorktree,
worktreeId,
allTabIds,
isTabLive: () => false,
isTabDeferrable: (tabId) => {
const ptyId = ptyIdByTabId.get(tabId) ?? null
return (
isSnapshotBackedTerminalPty(ptyId, worktreeId) &&
ptyId !== null &&
terminalProviderHasAuthoritativeSnapshot(ptyId)
)
},
immediateTabIds: new Set(['tab-1'])
})
expect(deferring).toBe(true)
expect(restrictions.get(worktreeId)).toEqual(new Set(['tab-1', 'tab-2']))
expect(deferredMountTabIdsByWorktree.get(worktreeId)).toEqual(
new Set(['tab-3', 'tab-4', 'tab-5', 'tab-6', 'tab-7'])
)
})
it('preserves cold-activation deferral for an all-current daemon worktree', () => {
const worktreeId = 'wt-current'
const allTabIds = tabIds(7)
const ptyIdByTabId = new Map(
allTabIds.map((tabId) => [tabId, `${worktreeId}@@${tabId}-session`])
)
const resolve = vi.fn((ids: string[]) =>
ids.map((id) => ({ id, authoritative: true as boolean | null }))
)
clearTerminalProviderSnapshotCapabilities()
synchronizeTerminalProviderSnapshotCapabilities([...ptyIdByTabId.values()], resolve)
const restrictions = new Map<string, ReadonlySet<string>>()
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
const deferring = planColdActivationTabDeferral({
restrictions,
deferredMountTabIdsByWorktree,
worktreeId,
allTabIds,
isTabLive: () => false,
isTabDeferrable: (tabId) => {
const ptyId = ptyIdByTabId.get(tabId) ?? null
return (
isSnapshotBackedTerminalPty(ptyId, worktreeId) &&
ptyId !== null &&
terminalProviderHasAuthoritativeSnapshot(ptyId)
)
},
immediateTabIds: new Set(['tab-1'])
})
expect(deferring).toBe(true)
expect(restrictions.get(worktreeId)).toEqual(new Set(['tab-1']))
expect(deferredMountTabIdsByWorktree.get(worktreeId)).toEqual(
new Set(['tab-2', 'tab-3', 'tab-4', 'tab-5', 'tab-6', 'tab-7'])
)
expect(resolve).toHaveBeenCalledOnce()
})
it('does not defer when most tabs are already live', () => {
const restrictions = new Map<string, ReadonlySet<string>>()
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearTerminalProviderSnapshotCapabilities,
synchronizeTerminalProviderSnapshotCapabilities,
terminalProviderHasAuthoritativeSnapshot
} from './terminal-provider-snapshot-capability'
describe('terminal provider snapshot capabilities', () => {
beforeEach(() => clearTerminalProviderSnapshotCapabilities())
it('records current and legacy daemon capabilities from one batch', () => {
const resolve = vi.fn(() => [
{ id: 'current', authoritative: true },
{ id: 'legacy', authoritative: false }
])
synchronizeTerminalProviderSnapshotCapabilities(['current', 'legacy'], resolve)
expect(resolve).toHaveBeenCalledWith(['current', 'legacy'])
expect(terminalProviderHasAuthoritativeSnapshot('current')).toBe(true)
expect(terminalProviderHasAuthoritativeSnapshot('legacy')).toBe(false)
})
it('caches resolved PTYs and prunes closed ones', () => {
const resolve = vi.fn((ids: string[]) =>
ids.map((id) => ({ id, authoritative: true as boolean | null }))
)
synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], resolve)
synchronizeTerminalProviderSnapshotCapabilities(['pty-2', 'pty-3'], resolve)
expect(resolve).toHaveBeenNthCalledWith(1, ['pty-1', 'pty-2'])
expect(resolve).toHaveBeenNthCalledWith(2, ['pty-3'])
expect(terminalProviderHasAuthoritativeSnapshot('pty-1')).toBe(false)
expect(terminalProviderHasAuthoritativeSnapshot('pty-2')).toBe(true)
expect(terminalProviderHasAuthoritativeSnapshot('pty-3')).toBe(true)
})
it('does not rescan an unchanged fully resolved PTY collection on later renders', () => {
let indexedReads = 0
const ids = new Proxy(['pty-1', 'pty-2'], {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) {
indexedReads += 1
}
return Reflect.get(target, property, receiver)
}
})
const resolve = vi.fn((batch: string[]) =>
batch.map((id) => ({ id, authoritative: true as boolean | null }))
)
synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
indexedReads = 0
synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
expect(indexedReads).toBe(0)
expect(resolve).toHaveBeenCalledOnce()
})
it('bounds initial capability IPC to batches of 512 PTYs', () => {
const ids = Array.from({ length: 1_025 }, (_, index) => `pty-${index}`)
const resolve = vi.fn((batch: string[]) =>
batch.map((id) => ({ id, authoritative: true as boolean | null }))
)
synchronizeTerminalProviderSnapshotCapabilities(ids, resolve)
expect(resolve.mock.calls.map(([batch]) => batch.length)).toEqual([512, 512, 1])
})
it('retries capabilities that are still unknown during daemon startup', () => {
const resolve = vi
.fn()
.mockReturnValueOnce([{ id: 'pty-1', authoritative: null }])
.mockReturnValueOnce([{ id: 'pty-1', authoritative: true }])
synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 1_000)
expect(terminalProviderHasAuthoritativeSnapshot('pty-1')).toBe(false)
synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 1_999)
expect(resolve).toHaveBeenCalledOnce()
synchronizeTerminalProviderSnapshotCapabilities(['pty-1'], resolve, 2_000)
expect(resolve).toHaveBeenCalledTimes(2)
expect(terminalProviderHasAuthoritativeSnapshot('pty-1')).toBe(true)
})
})
@@ -0,0 +1,95 @@
type SnapshotCapability = { id: string; authoritative: boolean | null }
const authoritativeSnapshotByPtyId = new Map<string, boolean>()
const unknownCapabilityRetryAtByPtyId = new Map<string, number>()
const UNKNOWN_CAPABILITY_RETRY_MS = 1_000
let lastSynchronizedLivePtyIds: readonly string[] | null = null
let earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
function refreshEarliestUnknownCapabilityRetry(): void {
earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
for (const retryAtMs of unknownCapabilityRetryAtByPtyId.values()) {
earliestUnknownCapabilityRetryAtMs = Math.min(earliestUnknownCapabilityRetryAtMs, retryAtMs)
}
}
export function synchronizeTerminalProviderSnapshotCapabilities(
livePtyIds: readonly string[],
resolveCapabilities?: (ids: string[]) => SnapshotCapability[],
observedAtMs?: number
): void {
// Why: Terminal can re-render for unrelated UI state. A stable binding list
// must add no repeated all-PTY scan or IPC work to that render path.
if (
livePtyIds === lastSynchronizedLivePtyIds &&
earliestUnknownCapabilityRetryAtMs === Number.POSITIVE_INFINITY
) {
return
}
const nowMs = observedAtMs ?? Date.now()
if (livePtyIds === lastSynchronizedLivePtyIds && nowMs < earliestUnknownCapabilityRetryAtMs) {
return
}
lastSynchronizedLivePtyIds = livePtyIds
const live = new Set(livePtyIds.filter((id) => id.length > 0))
for (const cachedId of authoritativeSnapshotByPtyId.keys()) {
if (!live.has(cachedId)) {
authoritativeSnapshotByPtyId.delete(cachedId)
}
}
for (const pendingId of unknownCapabilityRetryAtByPtyId.keys()) {
if (!live.has(pendingId)) {
unknownCapabilityRetryAtByPtyId.delete(pendingId)
}
}
const missing = [...live].filter(
(id) =>
!authoritativeSnapshotByPtyId.has(id) &&
(unknownCapabilityRetryAtByPtyId.get(id) ?? 0) <= nowMs
)
const resolve = resolveCapabilities ?? window.api.pty.getAuthoritativeBufferSnapshotCapabilities
if (!resolve) {
for (const id of missing) {
unknownCapabilityRetryAtByPtyId.set(id, nowMs + UNKNOWN_CAPABILITY_RETRY_MS)
}
refreshEarliestUnknownCapabilityRetry()
return
}
for (let offset = 0; offset < missing.length; offset += 512) {
const batch = missing.slice(offset, offset + 512)
let resolved: SnapshotCapability[]
try {
resolved = resolve(batch)
} catch {
// Why: unknown capability must keep the pane mounted. Do not cache the
// failure as supported; back off before retrying daemon startup.
for (const id of batch) {
unknownCapabilityRetryAtByPtyId.set(id, nowMs + UNKNOWN_CAPABILITY_RETRY_MS)
}
continue
}
const resolvedById = new Map(resolved.map((entry) => [entry.id, entry.authoritative]))
for (const id of batch) {
const authoritative = resolvedById.get(id)
if (typeof authoritative === 'boolean') {
authoritativeSnapshotByPtyId.set(id, authoritative)
unknownCapabilityRetryAtByPtyId.delete(id)
} else {
unknownCapabilityRetryAtByPtyId.set(id, nowMs + UNKNOWN_CAPABILITY_RETRY_MS)
}
}
}
refreshEarliestUnknownCapabilityRetry()
}
export function terminalProviderHasAuthoritativeSnapshot(ptyId: string): boolean {
return authoritativeSnapshotByPtyId.get(ptyId) === true
}
export function clearTerminalProviderSnapshotCapabilities(): void {
authoritativeSnapshotByPtyId.clear()
unknownCapabilityRetryAtByPtyId.clear()
lastSynchronizedLivePtyIds = null
earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY
}
@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { synchronizeTerminalProviderSnapshotCapabilities } from './terminal-provider-snapshot-capability'
export function useTerminalProviderSnapshotCapability(enabled: boolean): void {
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId)
const boundPtyIds = useMemo(() => {
const ids = new Set<string>()
for (const worktreeTabs of Object.values(tabsByWorktree)) {
for (const tab of worktreeTabs) {
if (tab.ptyId) {
ids.add(tab.ptyId)
}
for (const ptyId of ptyIdsByTabId[tab.id] ?? []) {
ids.add(ptyId)
}
}
}
return [...ids]
}, [ptyIdsByTabId, tabsByWorktree])
if (enabled) {
// Why: deferral is decided in this render. A cached in-memory IPC batch
// prevents legacy panes from unmounting before an async effect runs.
synchronizeTerminalProviderSnapshotCapabilities(boundPtyIds)
}
}
+4 -1
View File
@@ -13028,7 +13028,10 @@
"copiedToast": "Markup copied — paste it into your agent ({{value0}})",
"copy": "Copy Markup",
"drawButton": "Draw on screenshot",
"drawHint": "New — draw on the page, then copy it to your agent",
"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.",
+4 -1
View File
@@ -13028,7 +13028,10 @@
"copiedToast": "Marcado copiado: pégalo en tu agente ({{value0}})",
"copy": "Copiar marcado",
"drawButton": "Dibujar en la captura",
"drawHint": "Nuevo: dibuja en la página y cópialo en tu agente",
"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.",
+4 -1
View File
@@ -13028,7 +13028,10 @@
"copiedToast": "注釈をコピーしました — エージェントに貼り付けてください({{value0}})",
"copy": "注釈をコピー",
"drawButton": "スクリーンショットに描き込み",
"drawHint": "新機能 — ページに描き込み、エージェントに貼り付けられます",
"drawHint": "ページに描き込み、マークアップをコピーしてエージェントに貼り付けられます。",
"drawHintBadge": "新機能",
"drawHintDismiss": "了解",
"drawHintTry": "試してみる",
"errorAttach": "注釈付きスクリーンショットを添付できませんでした。",
"errorCapture": "描画用にページを取得できませんでした。",
"errorUnavailable": "このページではスクリーンショット注釈を利用できません。",
+4 -1
View File
@@ -13028,7 +13028,10 @@
"copiedToast": "마크업을 복사했습니다 — 에이전트에 붙여넣으세요 ({{value0}})",
"copy": "마크업 복사",
"drawButton": "스크린샷에 그리기",
"drawHint": "새 기능 — 페이지에 그린 다음 에이전트에 붙여넣으세요",
"drawHint": "페이지에 그린 다음 마크업을 복사해 에이전트에 붙여넣으세요.",
"drawHintBadge": "새 기능",
"drawHintDismiss": "확인",
"drawHintTry": "사용해 보기",
"errorAttach": "마크업 스크린샷을 첨부하지 못했습니다.",
"errorCapture": "그릴 페이지를 캡처하지 못했습니다.",
"errorUnavailable": "이 페이지에서는 스크린샷 마크업을 사용할 수 없습니다.",
+4 -1
View File
@@ -13028,7 +13028,10 @@
"copiedToast": "已复制标注 — 粘贴到你的智能体({{value0}})",
"copy": "复制标注",
"drawButton": "在截图上绘制",
"drawHint": "新功能 — 在页面上绘制,然后复制到你的智能体",
"drawHint": "在页面上绘制,然后复制标记并粘贴到你的智能体。",
"drawHintBadge": "新功能",
"drawHintDismiss": "知道了",
"drawHintTry": "试一试",
"errorAttach": "无法附加标注后的截图。",
"errorCapture": "无法捕获页面进行绘制。",
"errorUnavailable": "此页面无法使用截图标注。",
+2
View File
@@ -2763,6 +2763,8 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
getCwd: () => Promise.resolve('~'),
getSize: () => Promise.resolve(null),
listSessions: () => Promise.resolve([]),
getAuthoritativeBufferSnapshotCapabilities: (ids) =>
ids.map((id) => ({ id, authoritative: false })),
hasPty: () => Promise.resolve(null),
getMainBufferSnapshot: () => Promise.resolve(null),
// Why: remote-runtime PTYs never transit local main, so the web client has