diff --git a/README.md b/README.md
index 095b0f486e9..949937935aa 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -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)
---
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index f8d168e5f62..6e2161840d5 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -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)
@@ -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)
---
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 6c5e1bbfff5..07f5054d4cd 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -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)
@@ -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)
---
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index ce9a13cc30b..070b0acda1d 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -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)
@@ -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)
---
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index d81c6077879..3f61245ff8c 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -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)
@@ -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)
---
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index c771506ed7f..c0351670195 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -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)
@@ -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)
---
diff --git a/mobile/app.json b/mobile/app.json
index cb740d1f545..0208b82f4a7 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -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",
diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx
index 1787b958d7c..b79d0c01f5c 100644
--- a/mobile/src/components/MobileHostCard.tsx
+++ b/mobile/src/components/MobileHostCard.tsx
@@ -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 (
[styles.card, pressed && styles.cardPressed]}
@@ -38,14 +41,16 @@ export function MobileHostCard(props: {
-
+
{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` : ''}`
- : ''}
+ {connected && worktreeSummary ? (
+
+ {worktreeSummary}
+
+ ) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
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,
diff --git a/package.json b/package.json
index 0ebf17fde15..70b6dddfdf0 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts
index 1b3b5fb0b7e..ce66fdfe5d0 100644
--- a/src/main/daemon/daemon-pty-adapter.test.ts
+++ b/src/main/daemon/daemon-pty-adapter.test.ts
@@ -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', () => {
diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts
index 151969928be..ce60f6f4a17 100644
--- a/src/main/daemon/daemon-pty-adapter.ts
+++ b/src/main/daemon/daemon-pty-adapter.ts
@@ -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
diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts
index 4a2cb52c9d6..bf2d6ff8944 100644
--- a/src/main/daemon/daemon-pty-router.test.ts
+++ b/src/main/daemon/daemon-pty-router.test.ts
@@ -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 => {
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)
diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts
index 6db11c42efb..f8cad04b43b 100644
--- a/src/main/daemon/daemon-pty-router.ts
+++ b/src/main/daemon/daemon-pty-router.ts
@@ -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 {
await this.adapterFor(id).clearBuffer(id)
}
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index 6adb7e5cf69..4acea214fb8 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -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 () => {
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index ffaa3a3990d..045a52009a8 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -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()
+ 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 => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts
index ab6012cb7b9..6395174dc1b 100644
--- a/src/main/providers/types.ts
+++ b/src/main/providers/types.ts
@@ -196,6 +196,8 @@ export type IPtyProvider = {
id: string,
opts?: { scrollbackRows?: number }
) => Promise
+ /** 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`),
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 890b50d6197..6315416e41a 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1323,6 +1323,9 @@ export type PreloadApi = {
getCwd: (id: string) => Promise
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
getMainBufferSnapshot: (
id: string,
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 327f84a2c98..d879c2c65f3 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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 => ipcRenderer.invoke('pty:hasPty', { id }),
getMainBufferSnapshot: (
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index 4a78f0163a6..2890e483435 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -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)
}
}
diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx
index 5a0cb9d0615..0e786d64544 100644
--- a/src/renderer/src/components/browser-pane/BrowserPane.tsx
+++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx
@@ -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"
/>
@@ -5122,6 +5123,7 @@ function BrowserPagePane({
onClick={() => (markup.isActive ? markup.cancel() : void markup.start())}
disabled={isBlankTab || grab.state !== 'idle'}
active={markup.isActive}
+ surfaceActive={isActive}
/>
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 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 = (
+
+
+
+ )
+
+ // 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 (
-
-
- {/* Why: wrap in a span so tooltip hover still fires when disabled. */}
-
- {
- dismissHint()
- onClick()
- }}
- disabled={disabled}
- aria-label={label}
- aria-pressed={active}
- >
-
-
-
-
-
- {hintOpen
- ? translate(
- 'auto.components.browser-pane.markup.drawHint',
- 'New — draw on the page, then copy it to your agent'
- )
- : label}
-
-
+ {
+ if (!open) {
+ dismissHint()
+ }
+ }}
+ >
+
+
+
+ {/* Why: wrap in a span so the trigger still fires when disabled. */}
+ {button}
+
+
+
+ {label}
+
+
+ {
+ // Why: don't steal focus from the address bar / webview on first show.
+ event.preventDefault()
+ }}
+ >
+
+
+
+
+ {translate('auto.components.browser-pane.markup.drawHintBadge', 'New')}
+
+
{label}
+
+
+ {translate(
+ 'auto.components.browser-pane.markup.drawHint',
+ 'Draw on the page, then copy the markup to paste into your agent.'
+ )}
+
+
+
+
+
+ {translate('auto.components.browser-pane.markup.drawHintTry', 'Try it')}
+
+
+ {translate('auto.components.browser-pane.markup.drawHintDismiss', 'Got it')}
+
+
+
+
+
)
}
diff --git a/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.test.ts b/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.test.ts
new file mode 100644
index 00000000000..309a109b03b
--- /dev/null
+++ b/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.test.ts
@@ -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)
+ })
+})
diff --git a/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts b/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts
index f6d1ab2257e..7709c196984 100644
--- a/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts
+++ b/src/renderer/src/components/browser-pane/markup/use-markup-draw-hint.ts
@@ -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
diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts
index 4499e1594cf..49c6510717e 100644
--- a/src/renderer/src/components/mobile/mobile-platform-copy.ts
+++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record = {
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 {
diff --git a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx
index 41983525be1..1fa6d2bbcb1 100644
--- a/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx
+++ b/src/renderer/src/components/settings/MobilePairingConnectionOptions.test.tsx
@@ -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'
)
})
diff --git a/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx b/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx
index b7f4f946f0e..f49a32be99b 100644
--- a/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx
+++ b/src/renderer/src/components/settings/MobileRelayBetaAvailability.tsx
@@ -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 (
diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx
index d077cbea902..a6b72d3a6c0 100644
--- a/src/renderer/src/components/settings/MobileSettingsPane.tsx
+++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx
@@ -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)
diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
index c2a65f5b054..5c8026b74d0 100644
--- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts
@@ -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(
diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
index d791a08e3a7..2377b5e78db 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
@@ -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 },
diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts
index e38d5a2d51c..a797ed06b78 100644
--- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts
@@ -45,6 +45,9 @@ export {
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
export type ParkableTerminalTabModel = Pick
+export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean
+
+const allowSnapshotBackedPty = (): boolean => true
type ParkedPaneFallbackState = {
terminalLayoutsByTabId: ReturnType['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)
)
)
}
diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
index 78987d6437a..85209b8c633 100644
--- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
+++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts
@@ -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>()
+ const deferredMountTabIdsByWorktree = new Map>()
+
+ 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>()
+ const deferredMountTabIdsByWorktree = new Map>()
+
+ 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>()
const deferredMountTabIdsByWorktree = new Map>()
diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts
new file mode 100644
index 00000000000..f908992eb39
--- /dev/null
+++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts
@@ -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)
+ })
+})
diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts
new file mode 100644
index 00000000000..ed6bb2c59ed
--- /dev/null
+++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts
@@ -0,0 +1,95 @@
+type SnapshotCapability = { id: string; authoritative: boolean | null }
+
+const authoritativeSnapshotByPtyId = new Map()
+const unknownCapabilityRetryAtByPtyId = new Map()
+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
+}
diff --git a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts
new file mode 100644
index 00000000000..bf2532c6473
--- /dev/null
+++ b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts
@@ -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()
+ 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)
+ }
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 904551378c9..257200dcc77 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -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.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 4561dd25d61..5c0fc46acc2 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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.",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index f012157d588..75efd9ce994 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13028,7 +13028,10 @@
"copiedToast": "注釈をコピーしました — エージェントに貼り付けてください({{value0}})",
"copy": "注釈をコピー",
"drawButton": "スクリーンショットに描き込み",
- "drawHint": "新機能 — ページに描き込み、エージェントに貼り付けられます",
+ "drawHint": "ページに描き込み、マークアップをコピーしてエージェントに貼り付けられます。",
+ "drawHintBadge": "新機能",
+ "drawHintDismiss": "了解",
+ "drawHintTry": "試してみる",
"errorAttach": "注釈付きスクリーンショットを添付できませんでした。",
"errorCapture": "描画用にページを取得できませんでした。",
"errorUnavailable": "このページではスクリーンショット注釈を利用できません。",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2fae5839230..2ddefe47f79 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13028,7 +13028,10 @@
"copiedToast": "마크업을 복사했습니다 — 에이전트에 붙여넣으세요 ({{value0}})",
"copy": "마크업 복사",
"drawButton": "스크린샷에 그리기",
- "drawHint": "새 기능 — 페이지에 그린 다음 에이전트에 붙여넣으세요",
+ "drawHint": "페이지에 그린 다음 마크업을 복사해 에이전트에 붙여넣으세요.",
+ "drawHintBadge": "새 기능",
+ "drawHintDismiss": "확인",
+ "drawHintTry": "사용해 보기",
"errorAttach": "마크업 스크린샷을 첨부하지 못했습니다.",
"errorCapture": "그릴 페이지를 캡처하지 못했습니다.",
"errorUnavailable": "이 페이지에서는 스크린샷 마크업을 사용할 수 없습니다.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index e23f6853fe3..9e74716b26a 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13028,7 +13028,10 @@
"copiedToast": "已复制标注 — 粘贴到你的智能体({{value0}})",
"copy": "复制标注",
"drawButton": "在截图上绘制",
- "drawHint": "新功能 — 在页面上绘制,然后复制到你的智能体",
+ "drawHint": "在页面上绘制,然后复制标记并粘贴到你的智能体。",
+ "drawHintBadge": "新功能",
+ "drawHintDismiss": "知道了",
+ "drawHintTry": "试一试",
"errorAttach": "无法附加标注后的截图。",
"errorCapture": "无法捕获页面进行绘制。",
"errorUnavailable": "此页面无法使用截图标注。",
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 4abfb47b724..e84eadce112 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -2763,6 +2763,8 @@ function createPtyApi(): NonNullable['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