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