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} /> + ) + + // 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. */} - - - - - - {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.' + )} +

+
+
+ + +
+
+
+
) } 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