Files
orca/mobile/app/h/_layout.tsx
T
8f6e44ed53 Show agent session history on mobile (#6786)
* Show agent session history on mobile

Bring the desktop "Agent Session History" panel to Orca Mobile as a
per-worktree screen: browse past agent transcript sessions across the
host with scope tabs (Workspace/Project/All), search, grouping, session
cards, and tap-to-read message previews.

The transcript scan previously ran only over Electron IPC, so mobile
could not reach it. Expose it over the runtime RPC protocol mobile
already speaks (aiVault.listSessions) so the scan runs on whichever host
owns the transcripts — correct for local and SSH/remote hosts. Both the
desktop IPC handler and the new RPC method share one cache, so opening
the desktop panel and the mobile screen never double-scan.

The pure filter/group/display logic is lifted into /shared (the renderer
re-exports it) so the standalone mobile package can reuse it. Mobile
narrows scoped tabs client-side by cwd path-prefix because the host scan
treats scope paths as a widening union.

Resume-from-mobile is intentionally a follow-up.

* Fix mobile agent history list rendering and RPC authorization

- Authorize aiVault.listSessions in the mobile RPC allowlist so the
  mobile client's call is not rejected before dispatch (without this the
  screen could never load sessions at runtime).
- Name each SectionList section's rows `data` (the field React Native
  reads) instead of `cards`, fixing a type error and silent empty-section
  rendering.

* Address review feedback on agent session history

- Match quoted repo:/path: search operator values so labels and paths
  with spaces match (e.g. path:"/Users/ada/My Project").
- Hold a scoped tab in loading until the worktree list resolves instead
  of firing an unscoped fetch that briefly shows unrelated host history;
  proceed once loaded even if the worktree is absent (no stuck spinner).
- Clear cached host capabilities on disconnect/host-switch and failed
  status.get so a capability-gated action can't linger for a host that
  doesn't support it.
- Cover the real OrcaRuntimeService codex-home forwarding path and the
  quoted-operator parser with tests.

* Hide redundant mobile current worktree badges

Co-authored-by: Orca <help@stably.ai>

* Resume agent sessions from mobile history (#6969)

Co-authored-by: Orca <help@stably.ai>

* Adapt merged seams to main's lint and reply-sender hardening

Co-authored-by: Orca <help@stably.ai>

* Cap mobile project-scope paths to the aiVault RPC bound

Co-authored-by: Orca <help@stably.ai>

* Share the aiVault scopePaths bound between the RPC schema and mobile

Co-authored-by: Orca <help@stably.ai>

* Guard shared AI Vault inflight cleanup against concurrent key replacement

The extracted cache module's .finally() cleared inflight tracking
unconditionally, dropping the if (inflightKey === key) guard its sibling
outer cache kept: an older scan resolving after a different-key scan
replaced the tracking would null the newer scan's dedup slot, so a
re-request started a duplicate transcript rescan. Mirrors the sibling
guard; the regression test flushes a macrotask so a reverted guard fails
fast on the call count instead of hanging.

Co-authored-by: Orca <help@stably.ai>

* Harden aiVault.listSessions contract and gate mobile header entry on capability

- Clamp scopePaths (64) instead of rejecting, cap limit at 2000, and make
  executionHostId optional so mobile can omit it; restamp per caller.
- Retain successful mobile terminal-create mutation ids for 60s so resume
  retries dedupe after transient socket drops.
- Gate the session-header Agent History action on the aiVault.v1 capability
  (mirrors the host-list action) so old hosts never show a dead-end entry.
- Fix stale contract comments (scopePaths clamp semantics; filters move
  includes quoted repo:/path: operator parsing).

* Add subagent field to session test fixtures after #7423 merge

AiVaultSession.subagent became required on main; the five fixtures added on
this branch predate it. Top-level scanned sessions carry null.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
2026-07-10 13:48:10 -07:00

186 lines
6.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { View, StyleSheet, PanResponder } from 'react-native'
import { Stack, useGlobalSearchParams, usePathname } from 'expo-router'
import { colors } from '../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../src/layout/responsive-layout'
import {
HOST_SIDEBAR_DEFAULT_WIDTH,
HOST_SIDEBAR_MAX_WIDTH,
HOST_SIDEBAR_MIN_WIDTH,
loadHostSidebarWidth,
saveHostSidebarWidth
} from '../../src/storage/preferences'
import { HostScreen } from './[hostId]/index'
// Keep at least this much room for the detail pane when resizing the sidebar.
const MIN_DETAIL_WIDTH = 320
const RESIZE_EDGE_WIDTH = 24
// Clamp a sidebar width to the bounds and to the current window, so a width
// saved on a larger device can't starve the detail pane on a narrower one.
function clampSidebarToWindow(width: number, windowWidth: number): number {
const hardMax = Math.max(
HOST_SIDEBAR_MIN_WIDTH,
Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - MIN_DETAIL_WIDTH)
)
return Math.min(hardMax, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width)))
}
function HostStack({ animation }: { animation: 'none' | 'default' }) {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bgBase },
// In the tablet split view the detail pane should swap instantly like
// a desktop master-detail; the default slide animates the outgoing
// screen and briefly reveals the one beneath it. Phones keep the slide.
animation
}}
>
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/tasks" options={{ title: 'Tasks' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
<Stack.Screen
name="[hostId]/source-control/[worktreeId]"
options={{ title: 'Source Control' }}
/>
<Stack.Screen
name="[hostId]/agent-history/[worktreeId]"
options={{ title: 'Agent Session History' }}
/>
<Stack.Screen name="[hostId]/review/[worktreeId]" options={{ title: 'Changes' }} />
<Stack.Screen name="[hostId]/pr/[worktreeId]" options={{ title: 'Pull Request' }} />
</Stack>
)
}
export default function HostGroupLayout() {
// Wide layout = tablet/foldable canvas (see responsive-layout-metrics).
const { isWideLayout, width: windowWidth } = useResponsiveLayout()
const { hostId, action } = useGlobalSearchParams<{ hostId?: string; action?: string }>()
const pathname = usePathname()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [sidebarWidth, setSidebarWidth] = useState(HOST_SIDEBAR_DEFAULT_WIDTH)
// Refs keep the once-created PanResponder reading live values without
// re-creating its handlers on every width/window change.
const widthRef = useRef(sidebarWidth)
widthRef.current = sidebarWidth
const windowWidthRef = useRef(windowWidth)
windowWidthRef.current = windowWidth
const dragStartRef = useRef(sidebarWidth)
// Restore the user's last sidebar width, clamped to the current window.
useEffect(() => {
let stale = false
void loadHostSidebarWidth().then((saved) => {
if (!stale) {
setSidebarWidth(clampSidebarToWindow(saved, windowWidthRef.current))
}
})
return () => {
stale = true
}
}, [])
// Re-clamp when the window shrinks (fold, rotation, split-screen) so the
// detail pane keeps at least MIN_DETAIL_WIDTH.
useEffect(() => {
setSidebarWidth((current) => clampSidebarToWindow(current, windowWidth))
}, [windowWidth])
const hideSidebar = useCallback(() => setSidebarOpen(false), [])
const showSidebar = isWideLayout && !!hostId
const detailHasContent = !!hostId && pathname !== `/h/${hostId}`
const canCollapseSidebar = showSidebar && detailHasContent
// Why: there is no reveal button — navigating Back to the base host route brings
// the sidebar back (and that route's detail pane is only a placeholder, so a
// hidden sidebar would leave nothing useful).
useEffect(() => {
if (showSidebar && !detailHasContent) {
setSidebarOpen(true)
}
}, [detailHasContent, showSidebar])
// Why: the resizer lives on a dedicated edge handle (a leaf overlay at the
// sidebar's right border), NOT on the sidebar container. On Android a child
// ScrollView/FlatList claims the native touch responder, so a parent-View
// PanResponder never sees the move events and the drag silently no-ops; a
// dedicated handle on top of the content captures the gesture on both
// platforms. It claims on start (capture too) since nothing sits under it.
const resizer = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: () => {
dragStartRef.current = widthRef.current
},
onPanResponderMove: (_evt, g) => {
setSidebarWidth(clampSidebarToWindow(dragStartRef.current + g.dx, windowWidthRef.current))
},
onPanResponderRelease: () => {
void saveHostSidebarWidth(widthRef.current)
},
onPanResponderTerminate: () => {
void saveHostSidebarWidth(widthRef.current)
}
})
).current
// The detail Stack stays at a stable position in the tree across width
// changes so a fold/rotation doesn't remount the navigator and reset the
// navigation stack — only the sidebar pane toggles in and out.
return (
<View style={styles.row}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<HostScreen
embedded
hostId={hostId}
action={action}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
/>
{/* Dedicated drag handle straddling the right border — see resizer note. */}
<View style={styles.resizeHandle} {...resizer.panHandlers} />
</View>
) : null}
<View style={styles.detail}>
<HostStack animation={showSidebar ? 'none' : 'default'} />
</View>
</View>
)
}
const styles = StyleSheet.create({
row: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.bgBase
},
sidebar: {
borderRightWidth: 1,
borderRightColor: colors.borderSubtle
},
// Invisible grab strip over the sidebar's right edge. Absolute + elevated so it
// sits above the worktree list and reliably owns the drag on Android.
resizeHandle: {
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
width: RESIZE_EDGE_WIDTH,
zIndex: 20,
elevation: 20
},
detail: {
flex: 1,
minWidth: 0
}
})