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/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index bc65bc2e6eb..5790eb456aa 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -130,7 +130,7 @@ describe('mobile relay pairing journal store', () => { expect(metadataRaw).toBeNull() }) - it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => { + it('cleans mismatched secret records', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, hostId: 'host-1', @@ -142,6 +142,137 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() expect(metadataRaw).toBeNull() expect(secretRaw).toBeNull() + }) + + it('replaces a journal that never selected an authorization path', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + await saveMobileRelayPairingJournal(journal) + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + + const replacementSave = saveMobileRelayPairingJournal(replacement) + const staleUpdate = updateMobileRelayPairingJournal( + journal.metadata.journalId, + (metadata) => metadata + ) + + await expect(replacementSave).resolves.toBeUndefined() + await expect(staleUpdate).rejects.toThrow(/stale/) + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement) + }) + + it('serializes a replacement behind an in-flight journal snapshot', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + await saveMobileRelayPairingJournal(journal) + let releaseSecretRead!: () => void + const secretReadGate = new Promise((resolve) => { + releaseSecretRead = resolve + }) + secureStore.getItemAsync.mockImplementationOnce(async () => { + await secretReadGate + return secretRaw + }) + const loading = loadMobileRelayPairingJournal() + await vi.waitFor(() => expect(secureStore.getItemAsync).toHaveBeenCalled()) + + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + let replacementSaved = false + const saving = saveMobileRelayPairingJournal(replacement).then(() => { + replacementSaved = true + }) + await Promise.resolve() + expect(replacementSaved).toBe(false) + + releaseSecretRead() + await expect(loading).resolves.toEqual(journal) + await expect(saving).resolves.toBeUndefined() + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement) + expect(asyncStorage.removeItem).not.toHaveBeenCalled() + }) + + it('cleans a replacement whose secret write fails', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + await saveMobileRelayPairingJournal(journal) + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')) + + await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/) + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(metadataRaw).toBeNull() + expect(secretRaw).toBeNull() + }) + + it('blocks replacement when the old authorization update wins the mutation race', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + await saveMobileRelayPairingJournal(journal) + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + const authorizationUpdate = updateMobileRelayPairingJournal( + journal.metadata.journalId, + (metadata) => ({ + ...metadata, + winner: 'direct', + authorizationMode: 'authenticated-direct' + }) + ) + const replacementSave = saveMobileRelayPairingJournal(replacement) + + await expect(authorizationUpdate).resolves.toBeUndefined() + await expect(replacementSave).rejects.toThrow(/recovery pending/) + }) + + it.each([ + ['winner', { winner: 'direct' as const }], + ['authorization mode', { authorizationMode: 'authenticated-direct' as const }] + ])('refuses replacement once the durable %s exists', async (_name, authorization) => { + const created = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + const journal = { + ...created, + metadata: { ...created.metadata, ...authorization } + } await saveMobileRelayPairingJournal(journal) const replacement = createMobileRelayPairingJournal({ diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index 6e82bf38b9f..b7044830da5 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -27,9 +27,15 @@ export async function saveMobileRelayPairingJournal( const mutation = journalMutation.then(async () => { const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) const existing = existingRaw ? parseMetadata(existingRaw) : null - if (existing && existing.journalId !== metadata.journalId) { + if ( + existing && + existing.journalId !== metadata.journalId && + (existing.winner !== undefined || existing.authorizationMode !== undefined) + ) { throw new Error('mobile relay pairing recovery pending') } + // Why: no install RPC can run before winner+authorization are durable, so + // a new user-initiated scan may safely supersede a pre-authorization attempt. // Why: metadata-first makes a crash before the keychain write recover as // an incomplete journal, never as an untracked bearer secret. await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) @@ -41,28 +47,36 @@ export async function saveMobileRelayPairingJournal( export async function loadMobileRelayPairingJournal(): Promise { requireNativeSecretStore() - await journalMutation - const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) - if (rawMetadata === null) { - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) - return null - } - const metadata = parseMetadata(rawMetadata) - if (!metadata) { - await removeIncompleteJournal() - return null - } - const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) - if (rawSecrets === null) { - await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - return null - } - const secrets = parseSecrets(rawSecrets) - if (!secrets || secrets.journalId !== metadata.journalId) { - await removeIncompleteJournal() - return null - } - return { metadata, secrets } + const load = journalMutation.then(async () => { + const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + if (rawMetadata === null) { + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + return null + } + const metadata = parseMetadata(rawMetadata) + if (!metadata) { + await removeIncompleteJournal() + return null + } + const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + if (rawSecrets === null) { + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + return null + } + const secrets = parseSecrets(rawSecrets) + if (!secrets || secrets.journalId !== metadata.journalId) { + await removeIncompleteJournal() + return null + } + return { metadata, secrets } + }) + // Why: recovery may load while a new scan saves; serialize the complete + // metadata/secret snapshot and any cleanup so it cannot delete the new journal. + journalMutation = load.then( + () => undefined, + () => undefined + ) + return load } async function removeIncompleteJournal(): Promise { diff --git a/package.json b/package.json index e00e1119e71..18f7e0cbac3 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 336a417fe1d..158a946eb88 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1324,6 +1324,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 75e93651a73..52c8aaf5804 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -974,6 +974,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/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index 0b153d77cb1..9388731bb9c 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -556,6 +556,22 @@ line-height: 1.3; } +/* Heading toggle sizes mirror the plain h2–h4 scales (1.4 / 1.15 / 1em). */ +.markdown-body details.orca-details[data-orca-toggle='heading-2'] > summary { + font-size: 1.4em; + line-height: 1.3; +} + +.markdown-body details.orca-details[data-orca-toggle='heading-3'] > summary { + font-size: 1.15em; + line-height: 1.3; +} + +.markdown-body details.orca-details[data-orca-toggle='heading-4'] > summary { + font-size: 1em; + line-height: 1.3; +} + .markdown-body details.orca-details > :not(summary) { margin-left: 0.15em; } diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 38c625f0d37..48c13e0057d 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -764,6 +764,29 @@ height: 2.405em; } +/* Heading toggle sizes mirror the plain h2–h4 scales (1.4 / 1.15 / 1em). */ +.rich-markdown-editor details.orca-details[data-orca-toggle='heading-2'] > summary, +.rich-markdown-editor .orca-details[data-type='details'][data-orca-toggle='heading-2'] summary { + font-size: 1.4em; + line-height: 1.3; +} + +.rich-markdown-editor .orca-details[data-type='details'][data-orca-toggle='heading-2'] > button { + height: 1.82em; +} + +.rich-markdown-editor details.orca-details[data-orca-toggle='heading-3'] > summary, +.rich-markdown-editor .orca-details[data-type='details'][data-orca-toggle='heading-3'] summary { + font-size: 1.15em; + line-height: 1.3; +} + +.rich-markdown-editor details.orca-details[data-orca-toggle='heading-4'] > summary, +.rich-markdown-editor .orca-details[data-type='details'][data-orca-toggle='heading-4'] summary { + font-size: 1em; + line-height: 1.3; +} + .rich-markdown-editor blockquote { padding: 0.5em 1em; border-left: 3px solid var(--border); 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/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 331d683e715..3b4c53c3fbc 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -310,7 +310,7 @@ const markdownPreviewSanitizeSchema = { ...(defaultSchema.attributes?.details ?? []), 'open', ['className', 'orca-details'], - ['dataOrcaToggle', 'heading-1'] + ['dataOrcaToggle', 'heading-1', 'heading-2', 'heading-3', 'heading-4'] ], h1: [...(defaultSchema.attributes?.h1 ?? []), 'id'], h2: [...(defaultSchema.attributes?.h2 ?? []), 'id'], diff --git a/src/renderer/src/components/editor/details-markdown-html.ts b/src/renderer/src/components/editor/details-markdown-html.ts index 50acfb7b51a..6534793ce63 100644 --- a/src/renderer/src/components/editor/details-markdown-html.ts +++ b/src/renderer/src/components/editor/details-markdown-html.ts @@ -1,5 +1,23 @@ import type { MarkdownToken } from '@tiptap/core' +// Toggle summaries can render at heading scales 1–4, mirroring the plain +// heading levels the slash menu / toolbar dropdown offer (h1–h4). +export type ToggleHeadingVariant = 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4' + +export const TOGGLE_HEADING_VARIANTS: readonly ToggleHeadingVariant[] = [ + 'heading-1', + 'heading-2', + 'heading-3', + 'heading-4' +] + +export function parseToggleHeadingVariant(value: unknown): ToggleHeadingVariant | null { + return typeof value === 'string' && + TOGGLE_HEADING_VARIANTS.includes(value as ToggleHeadingVariant) + ? (value as ToggleHeadingVariant) + : null +} + export type DetailsHtmlToken = MarkdownToken & { attributes?: Record bodyTokens?: MarkdownToken[] @@ -28,11 +46,16 @@ export function escapeDetailsHtml(value: string): string { } export function parseDetailsAttributes(rawAttributes: string): Record { + // Why: validation accepts normal HTML whitespace around `=`, so parsing + // must accept it too or an editable toggle loses its heading variant. + const variantMatch = rawAttributes.match( + /\sdata-orca-toggle\s*=\s*(?:"(heading-[1-4])"|'(heading-[1-4])'|(heading-[1-4]))(?:\s|$)/i + ) return { open: /\sopen(?:\s|=|$)/i.test(rawAttributes), - variant: /\sdata-orca-toggle=(?:"heading-1"|'heading-1'|heading-1)(?:\s|$)/i.test(rawAttributes) - ? 'heading-1' - : null + variant: parseToggleHeadingVariant( + (variantMatch?.[1] ?? variantMatch?.[2] ?? variantMatch?.[3])?.toLowerCase() + ) } } @@ -47,8 +70,9 @@ export function detailsBodyHtmlToMarkdown(body: string): string { export function renderDetailsAttributes(attrs: Record | undefined): string { const attributes = ['class="orca-details"'] - if (attrs?.variant === 'heading-1') { - attributes.push('data-orca-toggle="heading-1"') + const variant = parseToggleHeadingVariant(attrs?.variant) + if (variant) { + attributes.push(`data-orca-toggle="${variant}"`) } if (attrs?.open === true) { @@ -155,7 +179,10 @@ function hasOnlySupportedDetailsAttributes(rawAttributes: string): boolean { rawAttributes .replace(/\s+open(?:\s*=\s*(?:""|"open"|''|'open'|open))?(?=\s|$)/giu, '') .replace(/\s+class\s*=\s*(?:"orca-details"|'orca-details'|orca-details)(?=\s|$)/giu, '') - .replace(/\s+data-orca-toggle\s*=\s*(?:"heading-1"|'heading-1'|heading-1)(?=\s|$)/giu, '') + .replace( + /\s+data-orca-toggle\s*=\s*(?:"heading-[1-4]"|'heading-[1-4]'|heading-[1-4])(?=\s|$)/giu, + '' + ) .trim() === '' ) } diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index ef4d24d0847..6cc40c4fb89 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -132,6 +132,29 @@ describe('rich markdown round trip', () => { ) }) + it.each(['heading-2', 'heading-3', 'heading-4'])( + 'preserves %s-styled details blocks', + (variant) => { + expect( + roundTripMarkdown( + `
Toggle

Body

\n` + ) + ).toBe( + `
\nToggle\n\nBody\n\n
` + ) + } + ) + + it('preserves a heading toggle when its attribute uses HTML whitespace around equals', () => { + expect( + roundTripMarkdown( + '
Toggle

Body

\n' + ) + ).toBe( + '
\nToggle\n\nBody\n\n
' + ) + }) + it('preserves details blocks with raw html as passthrough html', () => { const input = '
Toggle

Body

\n' expect(roundTripMarkdown(input)).toBe(input.trimEnd()) @@ -182,6 +205,17 @@ describe('rich markdown round trip', () => { expect(slashCommandSelectionParent('toggle-h1')).toBe('detailsSummary') }) + it.each([ + ['toggle-h2', 'heading-2'], + ['toggle-h3', 'heading-3'], + ['toggle-h4', 'heading-4'] + ] as const)('inserts editable %s toggles from slash commands', (commandId, variant) => { + expect(slashCommandMarkdown(commandId)).toBe( + `
\n\n\n\n\n
` + ) + expect(slashCommandSelectionParent(commandId)).toBe('detailsSummary') + }) + it('preserves markdown tables', () => { expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a') }) diff --git a/src/renderer/src/components/editor/rich-markdown-commands.test.ts b/src/renderer/src/components/editor/rich-markdown-commands.test.ts index a97d65caefd..d04b1de08f6 100644 --- a/src/renderer/src/components/editor/rich-markdown-commands.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-commands.test.ts @@ -42,8 +42,11 @@ describe('rich markdown slash commands', () => { 'Headings:heading-1', 'Headings:toggle-h1', 'Headings:heading-2', + 'Headings:toggle-h2', 'Headings:heading-3', + 'Headings:toggle-h3', 'Headings:heading-4', + 'Headings:toggle-h4', 'Headings:heading-5', 'Basic blocks:blockquote', 'Basic blocks:ordered-list', diff --git a/src/renderer/src/components/editor/rich-markdown-details-extension.ts b/src/renderer/src/components/editor/rich-markdown-details-extension.ts index 5312b0d491a..c36fa5753e1 100644 --- a/src/renderer/src/components/editor/rich-markdown-details-extension.ts +++ b/src/renderer/src/components/editor/rich-markdown-details-extension.ts @@ -2,6 +2,7 @@ import { decodeHtmlEntities, type AnyExtension, type Editor } from '@tiptap/core import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details' import type { PlaceholderOptions } from '@tiptap/extension-placeholder' import { TextSelection } from '@tiptap/pm/state' +import { translate } from '@/i18n/i18n' import { detailsBodyHtmlToMarkdown, escapeDetailsHtml, @@ -9,13 +10,25 @@ import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock, parseDetailsAttributes, + parseToggleHeadingVariant, renderDetailsAttributes, - type DetailsHtmlToken + type DetailsHtmlToken, + type ToggleHeadingVariant } from './details-markdown-html' const RICH_MARKDOWN_PLACEHOLDER = 'Write markdown… Type / for blocks.' const TOGGLE_TEXT_PLACEHOLDER = 'text' -const TOGGLE_HEADING_PLACEHOLDER = 'Heading 1' +const TOGGLE_HEADING_PLACEHOLDERS: Record = { + 'heading-1': ['auto.components.editor.rich.markdown.slash.commands.e66e7f04c6', 'Heading 1'], + 'heading-2': ['auto.components.editor.rich.markdown.slash.commands.c209a116b7', 'Heading 2'], + 'heading-3': ['auto.components.editor.rich.markdown.slash.commands.30566ee962', 'Heading 3'], + 'heading-4': ['auto.components.editor.rich.markdown.slash.commands.5f9a0ed7c4', 'Heading 4'] +} + +function toggleHeadingPlaceholder(variant: ToggleHeadingVariant): string { + const [key, fallback] = TOGGLE_HEADING_PLACEHOLDERS[variant] + return translate(key, fallback) +} export function getRichMarkdownPlaceholder({ editor, @@ -29,9 +42,12 @@ export function getRichMarkdownPlaceholder({ } const parent = editor.state.doc.resolve(pos).parent - return parent.type.name === 'details' && parent.attrs.variant === 'heading-1' - ? TOGGLE_HEADING_PLACEHOLDER - : TOGGLE_TEXT_PLACEHOLDER + if (parent.type.name !== 'details') { + return TOGGLE_TEXT_PLACEHOLDER + } + + const variant = parseToggleHeadingVariant(parent.attrs.variant) + return variant ? toggleHeadingPlaceholder(variant) : TOGGLE_TEXT_PLACEHOLDER } export function moveDetailsSummarySelectionToContent(editor: Editor): boolean { @@ -191,10 +207,11 @@ const OrcaDetails = Details.extend({ ...this.parent?.(), variant: { default: null, - parseHTML: (element) => - element.getAttribute('data-orca-toggle') === 'heading-1' ? 'heading-1' : null, - renderHTML: ({ variant }) => - variant === 'heading-1' ? { 'data-orca-toggle': 'heading-1' } : {} + parseHTML: (element) => parseToggleHeadingVariant(element.getAttribute('data-orca-toggle')), + renderHTML: ({ variant }) => { + const parsed = parseToggleHeadingVariant(variant) + return parsed ? { 'data-orca-toggle': parsed } : {} + } } } }, diff --git a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx index 2dc4635dc75..b71b79f1e25 100644 --- a/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-heading-slash-commands.tsx @@ -68,6 +68,27 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 2 }).run() } }, + { + id: 'toggle-h2', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.7a2c1f9b04', + 'Toggle Heading 2' + ) + }, + aliases: ['toggle-h2'], + icon: icon(ChevronRight), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.b3e5d8a1c6', + 'Create a collapsible section with a medium heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-2') + } + }, { id: 'heading-3', get label() { @@ -90,6 +111,27 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 3 }).run() } }, + { + id: 'toggle-h3', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.2f9d6b4e10', + 'Toggle Heading 3' + ) + }, + aliases: ['toggle-h3'], + icon: icon(ChevronRight), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.8c1a3e7d52', + 'Create a collapsible section with a small heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-3') + } + }, { id: 'heading-4', get label() { @@ -112,6 +154,27 @@ export const headingSlashCommands: SlashCommand[] = [ editor.chain().focus().setHeading({ level: 4 }).run() } }, + { + id: 'toggle-h4', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.5e0b9c2a71', + 'Toggle Heading 4' + ) + }, + aliases: ['toggle-h4'], + icon: icon(ChevronRight), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d4f16a8b39', + 'Create a collapsible section with a nested heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-4') + } + }, { id: 'heading-5', get label() { diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts index 4b8829ba173..447a7c08a53 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts @@ -1,6 +1,7 @@ import type React from 'react' import type { Editor } from '@tiptap/react' import { TextSelection } from '@tiptap/pm/state' +import type { ToggleHeadingVariant } from './details-markdown-html' export type SlashMenuState = { query: string @@ -16,8 +17,11 @@ export type SlashCommandId = | 'heading-1' | 'toggle-h1' | 'heading-2' + | 'toggle-h2' | 'heading-3' + | 'toggle-h3' | 'heading-4' + | 'toggle-h4' | 'heading-5' | 'task-list' | 'bullet-list' @@ -92,7 +96,7 @@ export function insertCodeBlock(editor: Editor, language: string, text: string): }) } -export function insertToggle(editor: Editor, variant?: 'heading-1'): void { +export function insertToggle(editor: Editor, variant?: ToggleHeadingVariant): void { const insertAt = editor.state.selection.from editor 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 d43b1bee72d..6573bf49c15 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11897,7 +11897,13 @@ "5f9a0ed7c4": "Heading 4", "01a71dbbdd": "Nested section heading.", "8440fa4acf": "Heading 5", - "b287b93c66": "Deep section heading." + "b287b93c66": "Deep section heading.", + "7a2c1f9b04": "Toggle Heading 2", + "b3e5d8a1c6": "Create a collapsible section with a medium heading summary.", + "2f9d6b4e10": "Toggle Heading 3", + "8c1a3e7d52": "Create a collapsible section with a small heading summary.", + "5e0b9c2a71": "Toggle Heading 4", + "d4f16a8b39": "Create a collapsible section with a nested heading summary." } } } @@ -13078,7 +13084,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 d80c7945e39..93571c7781b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11897,7 +11897,13 @@ "5f9a0ed7c4": "Título 4", "01a71dbbdd": "Encabezado de sección anidada.", "8440fa4acf": "Título 5", - "b287b93c66": "Encabezado de sección profunda." + "b287b93c66": "Encabezado de sección profunda.", + "7a2c1f9b04": "Alternar título 2", + "b3e5d8a1c6": "Cree una sección plegable con un resumen de encabezado mediano.", + "2f9d6b4e10": "Alternar título 3", + "8c1a3e7d52": "Cree una sección plegable con un resumen de encabezado pequeño.", + "5e0b9c2a71": "Alternar título 4", + "d4f16a8b39": "Cree una sección plegable con un resumen de encabezado anidado." } } } @@ -13078,7 +13084,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 0d0a8d8a45d..4760388a25c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11897,7 +11897,13 @@ "5f9a0ed7c4": "見出し 4", "01a71dbbdd": "ネストされたセクション見出し。", "8440fa4acf": "見出し 5", - "b287b93c66": "深いセクションの見出し。" + "b287b93c66": "深いセクションの見出し。", + "7a2c1f9b04": "見出し 2 を切り替えます", + "b3e5d8a1c6": "中程度の見出しの概要を含む折りたたみ可能なセクションを作成します。", + "2f9d6b4e10": "見出し 3 を切り替えます", + "8c1a3e7d52": "小さな見出しの概要を含む折りたたみ可能なセクションを作成します。", + "5e0b9c2a71": "見出し 4 を切り替えます", + "d4f16a8b39": "入れ子の見出しの概要を含む折りたたみ可能なセクションを作成します。" } } } @@ -13078,7 +13084,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 ba797e0079b..b8b86646290 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11897,7 +11897,13 @@ "5f9a0ed7c4": "제목 4", "01a71dbbdd": "중첩된 섹션 제목.", "8440fa4acf": "제목 5", - "b287b93c66": "깊은 섹션 제목." + "b287b93c66": "깊은 섹션 제목.", + "7a2c1f9b04": "토글 제목 2", + "b3e5d8a1c6": "중간 제목 요약이 포함된 접을 수 있는 섹션을 만듭니다.", + "2f9d6b4e10": "토글 제목 3", + "8c1a3e7d52": "작은 제목 요약이 포함된 접을 수 있는 섹션을 만듭니다.", + "5e0b9c2a71": "토글 제목 4", + "d4f16a8b39": "중첩된 제목 요약이 포함된 접을 수 있는 섹션을 만듭니다." } } } @@ -13078,7 +13084,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 4670b266209..3126c521923 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11897,7 +11897,13 @@ "5f9a0ed7c4": "标题 4", "01a71dbbdd": "嵌套小节标题。", "8440fa4acf": "标题 5", - "b287b93c66": "深层小节标题。" + "b287b93c66": "深层小节标题。", + "7a2c1f9b04": "切换标题 2", + "b3e5d8a1c6": "创建一个带有中等标题摘要的可折叠部分。", + "2f9d6b4e10": "切换标题 3", + "8c1a3e7d52": "创建一个带有小标题摘要的可折叠部分。", + "5e0b9c2a71": "切换标题 4", + "d4f16a8b39": "创建一个带有嵌套标题摘要的可折叠部分。" } } } @@ -13078,7 +13084,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 8bdf0e88463..26e92c9baa3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2772,6 +2772,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