diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0bfd054f73..5737b46831 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -59,7 +59,7 @@ "expo-image": "55.0.11", "expo-image-picker": "~55.0.21", "expo-insights": "55.0.18", - "expo-linear-gradient": "~55.0.15", + "expo-linear-gradient": "~55.0.16", "expo-linking": "55.0.16", "expo-localization": "~55.0.16", "expo-location": "55.1.11", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index acb510cd09..37c997158a 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -2,6 +2,8 @@ import '../global.css'; import '@/lib/cloud-agent-runtime'; +import { installE2EWebSocketLatency } from '@/lib/e2e-ws-latency'; + import { JetBrainsMono_500Medium, JetBrainsMono_600SemiBold, @@ -66,6 +68,9 @@ const navigationIntegration = Sentry.reactNavigationIntegration({ enableTimeToInitialDisplay: !isRunningInExpoGo(), }); +// No-op unless E2E_LATENCY_WS_MS is set at bundle time (see lib/e2e-ws-latency). +installE2EWebSocketLatency(); + // Session replay, screenshots, and view-hierarchy capture are gated on // stored consent (see src/lib/sentry-consent.ts) — the consent copy only // promises anonymous performance/crash data. The RN SDK reads all of these diff --git a/apps/mobile/src/components/agents/active-now-section.tsx b/apps/mobile/src/components/agents/active-now-section.tsx index c7e6f13201..f949b8b773 100644 --- a/apps/mobile/src/components/agents/active-now-section.tsx +++ b/apps/mobile/src/components/agents/active-now-section.tsx @@ -26,15 +26,18 @@ type ActiveNowSectionProps = { }; /** - * Pinned "Active now" tray for the Agents session list. Renders above the - * history list. The section never scrolls itself — it sits inside the - * screen's non-scrolling vertical layout so it animates in/out with - * Reanimated `FadeIn`/`FadeOut` while the screen's `LinearTransition` - * wrappers absorb the layout change without jumping the history list. + * Pinned "Active now" tray for the Agents session list. Rendered as the + * history `SectionList`'s `ListHeaderComponent`, so it scrolls with the + * session history in one continuous gesture (search/filter chrome stays + * pinned above the list). `ListHeaderComponent` is not a virtualized cell, + * so Reanimated `FadeIn`/`FadeOut`/`LinearTransition` wrappers on the tray + * and its rows keep working. * * The tray caps the visible rows at `ACTIVE_NOW_TRAY_CAP` while collapsed * and exposes a `+N more` expander when more sessions are pinned. Expansion - * is local `useState` (no persistence — resets on unmount). + * is local `useState` (no persistence — resets on unmount). The list keeps + * a single SectionList mount across loading → rows so this state is not + * wiped when skeletons are replaced by real sections. */ export function ActiveNowSection({ pinned, diff --git a/apps/mobile/src/components/agents/mono-scroll-block-model.ts b/apps/mobile/src/components/agents/mono-scroll-block-model.ts new file mode 100644 index 0000000000..f9efc360be --- /dev/null +++ b/apps/mobile/src/components/agents/mono-scroll-block-model.ts @@ -0,0 +1,69 @@ +/** + * Caps long mono payloads for display. Returns a sliced copy and whether the + * cap applied — never mutates the caller's string. + */ +export function prepareMonoScrollContent( + content: string, + maxLength?: number +): { displayText: string; isTruncated: boolean } { + if (maxLength === undefined || content.length <= maxLength) { + return { displayText: content, isTruncated: false }; + } + return { displayText: content.slice(0, maxLength), isTruncated: true }; +} + +/** Measured ScrollView height keyed to the text it was measured for. */ +export type MonoScrollHeightPin = { text: string; height: number }; + +/** + * Apply a pinned height only while it still matches the displayed text. + * When text changes the pin is ignored so the next layout remeasures + * intrinsic height (avoids clipping grown content into a stale pin). + */ +export function resolveMonoScrollPinnedHeight( + pin: MonoScrollHeightPin | undefined, + displayText: string +): number | undefined { + if (pin === undefined || pin.text !== displayText) { + return undefined; + } + return pin.height; +} + +/** Record a fresh measurement, keeping the previous pin when nothing changed. */ +export function nextMonoScrollHeightPin( + prev: MonoScrollHeightPin | undefined, + displayText: string, + measuredHeight: number +): MonoScrollHeightPin { + if (prev?.text === displayText && Math.abs(prev.height - measuredHeight) < 0.5) { + return prev; + } + return { text: displayText, height: measuredHeight }; +} + +/** + * Directional activation for the RNGH ScrollView nested in the session + * FlashList. Horizontal pans beyond this dead-zone activate the block; + * vertical pans beyond it fail the inner gesture so the conversation takes over. + */ +export const MONO_SCROLL_GESTURE_OFFSETS = { + activeOffsetX: [-10, 10] as [number, number], + failOffsetY: [-10, 10] as [number, number], +}; + +/** Props applied to the horizontal ScrollView so tests can assert the contract. */ +export const MONO_SCROLL_VIEW_PROPS = { + horizontal: true as const, + showsHorizontalScrollIndicator: true as const, + // iOS: once the pan direction is chosen, keep it so a horizontal drag does + // not also scroll the conversation FlashList, and a mostly-vertical drag + // stays with the list. + directionalLockEnabled: true as const, + // Android: allow this horizontal scroller inside the vertical list. + nestedScrollEnabled: true as const, + // Do not let the scroller steal the vertical list's bounce/cancel path on + // primarily vertical drags that begin on the block. + canCancelContentTouches: true as const, + ...MONO_SCROLL_GESTURE_OFFSETS, +}; diff --git a/apps/mobile/src/components/agents/mono-scroll-block.test.ts b/apps/mobile/src/components/agents/mono-scroll-block.test.ts new file mode 100644 index 0000000000..9f70133bfa --- /dev/null +++ b/apps/mobile/src/components/agents/mono-scroll-block.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { + MONO_SCROLL_GESTURE_OFFSETS, + MONO_SCROLL_VIEW_PROPS, + nextMonoScrollHeightPin, + prepareMonoScrollContent, + resolveMonoScrollPinnedHeight, +} from './mono-scroll-block-model'; + +describe('prepareMonoScrollContent', () => { + it('returns the full string when under the cap and does not mark truncated', () => { + const content = 'short output'; + const result = prepareMonoScrollContent(content, 2000); + expect(result.displayText).toBe(content); + expect(result.isTruncated).toBe(false); + }); + + it('returns the full string when maxLength is omitted', () => { + const content = 'x'.repeat(5000); + const result = prepareMonoScrollContent(content); + expect(result.displayText).toBe(content); + expect(result.isTruncated).toBe(false); + }); + + it('slices at the cap and marks truncated without mutating the input', () => { + const content = 'abcdefghij'; + const result = prepareMonoScrollContent(content, 4); + expect(result.displayText).toBe('abcd'); + expect(result.isTruncated).toBe(true); + expect(content).toBe('abcdefghij'); + }); + + it('does not truncate when length equals maxLength', () => { + const content = 'abcd'; + const result = prepareMonoScrollContent(content, 4); + expect(result.displayText).toBe(content); + expect(result.isTruncated).toBe(false); + }); + + it('does not append an ellipsis — the Truncated marker is the affordance', () => { + const result = prepareMonoScrollContent('hello world', 5); + expect(result.displayText).toBe('hello'); + expect(result.displayText.endsWith('\u2026')).toBe(false); + }); +}); + +describe('height pin — content growth must remeasure', () => { + it('applies pinned height only while pin text matches display text', () => { + const pin = { text: 'line1', height: 16 }; + expect(resolveMonoScrollPinnedHeight(pin, 'line1')).toBe(16); + expect(resolveMonoScrollPinnedHeight(pin, 'line1\nline2')).toBeUndefined(); + expect(resolveMonoScrollPinnedHeight(undefined, 'line1')).toBeUndefined(); + }); + + it('allows height to grow after display text changes (stale pin ignored)', () => { + const shortPin = nextMonoScrollHeightPin(undefined, 'short', 16); + expect(resolveMonoScrollPinnedHeight(shortPin, 'short')).toBe(16); + + // Text grew (e.g. preparation-group outputTail while expanded) — drop pin. + const grownText = 'short\nsecond line\nthird'; + expect(resolveMonoScrollPinnedHeight(shortPin, grownText)).toBeUndefined(); + + const grownPin = nextMonoScrollHeightPin(shortPin, grownText, 48); + expect(grownPin).toEqual({ text: grownText, height: 48 }); + expect(resolveMonoScrollPinnedHeight(grownPin, grownText)).toBe(48); + }); + + it('keeps the previous pin object when text and height are unchanged', () => { + const pin = nextMonoScrollHeightPin(undefined, 'stable', 20); + const again = nextMonoScrollHeightPin(pin, 'stable', 20.2); + expect(again).toBe(pin); + }); +}); + +describe('MONO_SCROLL_VIEW_PROPS — overflow affordance and nested delivery', () => { + it('shows the horizontal scroll indicator (C-a overflow affordance)', () => { + expect(MONO_SCROLL_VIEW_PROPS.showsHorizontalScrollIndicator).toBe(true); + }); + + it('is a horizontal scroller with nested / directional lock for list coexistence', () => { + expect(MONO_SCROLL_VIEW_PROPS.horizontal).toBe(true); + expect(MONO_SCROLL_VIEW_PROPS.directionalLockEnabled).toBe(true); + expect(MONO_SCROLL_VIEW_PROPS.nestedScrollEnabled).toBe(true); + expect(MONO_SCROLL_VIEW_PROPS.canCancelContentTouches).toBe(true); + }); + + it('uses RNGH directional activation so horizontal pans reach the block', () => { + expect(MONO_SCROLL_GESTURE_OFFSETS.activeOffsetX).toEqual([-10, 10]); + expect(MONO_SCROLL_GESTURE_OFFSETS.failOffsetY).toEqual([-10, 10]); + expect(MONO_SCROLL_VIEW_PROPS.activeOffsetX).toEqual([-10, 10]); + expect(MONO_SCROLL_VIEW_PROPS.failOffsetY).toEqual([-10, 10]); + }); +}); diff --git a/apps/mobile/src/components/agents/mono-scroll-block.tsx b/apps/mobile/src/components/agents/mono-scroll-block.tsx new file mode 100644 index 0000000000..1a6638a0cc --- /dev/null +++ b/apps/mobile/src/components/agents/mono-scroll-block.tsx @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react'; +import { type LayoutChangeEvent, View } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +import { + MONO_SCROLL_VIEW_PROPS, + type MonoScrollHeightPin, + nextMonoScrollHeightPin, + prepareMonoScrollContent, + resolveMonoScrollPinnedHeight, +} from './mono-scroll-block-model'; + +type MonoScrollBlockProps = { + content: string; + /** When set, content longer than this is sliced and a "Truncated" marker is shown. */ + maxLength?: number; + /** Merged onto the mono Text (colors, leading). Base mono sizing is applied. */ + textClassName?: string; + /** Optional chrome around the scroller (e.g. rounded bg on preparation output). */ + containerClassName?: string; +}; + +/** + * Horizontally scrollable monospace block for tool-card / preparation output. + * + * Nested-scroll delivery (device-proven): RN 0.83 Fabric does not hand + * horizontal pans to a stock RN ScrollView nested inside the session FlashList + * — content is wide enough, but the inner scroller never receives the pan. + * This block uses `ScrollView` from `react-native-gesture-handler` with + * `activeOffsetX` / `failOffsetY` so horizontal pans activate the block and + * vertical pans fail over to the conversation list (same hazard family as + * nested scrolls in the markdown renderer). + * + * Intrinsic width: mono Text keeps `shrink-0 self-start` so content lays out + * at its natural width inside the horizontal scroller. + * + * Height pin: pin ScrollView height from the content's onLayout measurement so + * RN 0.83 Fabric cannot inflate a horizontal ScrollView inside a width- + * constrained parent (~10× spurious height). The pin is keyed to displayText + * so a taller payload remeasures instead of clipping into a stale height. + */ +export function MonoScrollBlock({ + content, + maxLength, + textClassName, + containerClassName, +}: Readonly) { + const { displayText, isTruncated } = prepareMonoScrollContent(content, maxLength); + const [heightPin, setHeightPin] = useState(undefined); + const contentHeight = resolveMonoScrollPinnedHeight(heightPin, displayText); + + const handleContentLayout = useCallback( + (event: LayoutChangeEvent) => { + const measured = event.nativeEvent.layout.height; + setHeightPin(prev => nextMonoScrollHeightPin(prev, displayText, measured)); + }, + [displayText] + ); + + return ( + + + + {displayText} + + + {isTruncated ? ( + + Truncated + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/agents/preparation-group.tsx b/apps/mobile/src/components/agents/preparation-group.tsx index ca75f82ff2..967171ead7 100644 --- a/apps/mobile/src/components/agents/preparation-group.tsx +++ b/apps/mobile/src/components/agents/preparation-group.tsx @@ -6,6 +6,8 @@ import { type PreparationAttempt, type PreparationStepSnapshot } from 'cloud-age import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { MonoScrollBlock } from './mono-scroll-block'; + export function PreparationGroup({ attempt }: { attempt: PreparationAttempt }) { const [expanded, setExpanded] = useState(attempt.status !== 'completed'); const colors = useThemeColors(); @@ -139,9 +141,11 @@ function PreparationStepRow({ step }: { step: PreparationStepSnapshot }) { {step.outputTruncated ? ( Earlier output omitted ) : null} - - {step.outputTail} - + ) : null} diff --git a/apps/mobile/src/components/agents/session-list-content-surface.ts b/apps/mobile/src/components/agents/session-list-content-surface.ts new file mode 100644 index 0000000000..11501a2d60 --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-content-surface.ts @@ -0,0 +1,40 @@ +/** + * Pure surface decision for the session-list body after the tray moved into + * the SectionList header. Encodes the single-render-site contract: + * - Never early-return on loading — keep one SectionList mounted so the + * tray's local expanded state survives skeleton → rows. + * - Full-screen error / first-use empty only after loading completes. + * - ListEmptyComponent prefers skeletons while loading over any body-empty + * kind (avoids flashing "No past sessions" / "No sessions yet" mid-load). + */ +type SessionListContentSurface = + | { kind: 'full-screen-error' } + | { kind: 'first-use-empty' } + | { kind: 'section-list'; listEmpty: 'loading-skeletons' | 'body-empty' | 'none' }; + +export function selectSessionListContentSurface(input: { + isLoading: boolean; + isError: boolean; + hasAnySessions: boolean; + hasPinnedActive: boolean; + hasHistoryContent: boolean; +}): SessionListContentSurface { + const { isLoading, isError, hasAnySessions, hasPinnedActive, hasHistoryContent } = input; + + // Gate non-list surfaces on !isLoading so a cold open (empty cache for the + // whole load) cannot flash first-use empty or full-screen error. + if (!isLoading && isError && !hasAnySessions && !hasPinnedActive) { + return { kind: 'full-screen-error' }; + } + if (!isLoading && !hasAnySessions) { + return { kind: 'first-use-empty' }; + } + + if (isLoading) { + return { kind: 'section-list', listEmpty: 'loading-skeletons' }; + } + if (!hasHistoryContent) { + return { kind: 'section-list', listEmpty: 'body-empty' }; + } + return { kind: 'section-list', listEmpty: 'none' }; +} diff --git a/apps/mobile/src/components/agents/session-list-content.test.ts b/apps/mobile/src/components/agents/session-list-content.test.ts new file mode 100644 index 0000000000..3d2bfd6ece --- /dev/null +++ b/apps/mobile/src/components/agents/session-list-content.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { selectSessionListContentSurface } from './session-list-content-surface'; + +function surface(overrides: Partial[0]> = {}) { + return selectSessionListContentSurface({ + isLoading: false, + isError: false, + hasAnySessions: true, + hasPinnedActive: false, + hasHistoryContent: true, + ...overrides, + }); +} + +describe('selectSessionListContentSurface', () => { + describe('loading (single SectionList site)', () => { + it('keeps the section-list path with skeleton empty while loading, even with empty cache', () => { + // Cold open: hasAnySessions is false for the whole load. Must NOT fall + // through to first-use empty — that would flash "No sessions yet". + expect( + surface({ + isLoading: true, + hasAnySessions: false, + hasPinnedActive: false, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'section-list', listEmpty: 'loading-skeletons' }); + }); + + it('shows skeletons under a populated tray while history is still loading', () => { + // Active query resolved first: tray visible via ListHeaderComponent, + // history still loading → skeletons, NOT "No past sessions". + expect( + surface({ + isLoading: true, + hasAnySessions: true, + hasPinnedActive: true, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'section-list', listEmpty: 'loading-skeletons' }); + }); + + it('does not surface full-screen error while still loading', () => { + expect( + surface({ + isLoading: true, + isError: true, + hasAnySessions: false, + hasPinnedActive: false, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'section-list', listEmpty: 'loading-skeletons' }); + }); + }); + + describe('after load — non-list surfaces', () => { + it('shows full-screen error only when load finished with nothing on screen', () => { + expect( + surface({ + isLoading: false, + isError: true, + hasAnySessions: false, + hasPinnedActive: false, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'full-screen-error' }); + }); + + it('suppresses full-screen error when the tray alone is on screen', () => { + // Populated tray counts as content; body empty goes into the list. + expect( + surface({ + isLoading: false, + isError: true, + hasAnySessions: true, + hasPinnedActive: true, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'section-list', listEmpty: 'body-empty' }); + }); + + it('shows first-use empty only after load with no sessions at all', () => { + expect( + surface({ + isLoading: false, + hasAnySessions: false, + hasPinnedActive: false, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'first-use-empty' }); + }); + }); + + describe('after load — section list', () => { + it('renders history rows with no ListEmptyComponent when sections exist', () => { + expect( + surface({ + isLoading: false, + hasAnySessions: true, + hasHistoryContent: true, + }) + ).toEqual({ kind: 'section-list', listEmpty: 'none' }); + }); + + it('uses body-empty ListEmptyComponent when history is empty but sessions exist', () => { + // Tray-only (or filtered empty) — body model decides the empty kind. + expect( + surface({ + isLoading: false, + hasAnySessions: true, + hasPinnedActive: true, + hasHistoryContent: false, + }) + ).toEqual({ kind: 'section-list', listEmpty: 'body-empty' }); + }); + }); + + describe('ListEmptyComponent precedence', () => { + it('prefers loading-skeletons over body-empty whenever isLoading', () => { + // Explicit precedence: isLoading ? skeletons : body-empty. + // hasHistoryContent false would otherwise be body-empty. + const loading = surface({ + isLoading: true, + hasAnySessions: true, + hasPinnedActive: true, + hasHistoryContent: false, + }); + const loaded = surface({ + isLoading: false, + hasAnySessions: true, + hasPinnedActive: true, + hasHistoryContent: false, + }); + expect(loading).toEqual({ kind: 'section-list', listEmpty: 'loading-skeletons' }); + expect(loaded).toEqual({ kind: 'section-list', listEmpty: 'body-empty' }); + }); + }); +}); diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index e2231da837..9a488599ee 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,7 +1,15 @@ import { useScrollToTop } from '@react-navigation/native'; import { useFocusEffect } from 'expo-router'; import { Bot, Plus } from 'lucide-react-native'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { ActivityIndicator, Platform, @@ -18,6 +26,7 @@ import { selectSessionListBodyModel, type SessionListBodyModel, } from '@/components/agents/session-list-body-model'; +import { selectSessionListContentSurface } from '@/components/agents/session-list-content-surface'; import { type SessionSection } from '@/components/agents/session-list-helpers'; import { shouldResetScrollOnCommittedQuery } from '@/components/agents/session-list-scroll-reset'; import { SessionListSectionHeader } from '@/components/agents/session-list-section-header'; @@ -62,6 +71,12 @@ type AgentSessionListContentProps = { onClearQuery: () => void; onCreateSession: () => void; sortBy: AgentSessionSortBy; + /** + * Pinned "Active now" tray rendered as `ListHeaderComponent` so it scrolls + * with history in one continuous gesture. Not a virtualized cell — Reanimated + * layout transitions on the tray keep working. Pass `null` when empty. + */ + activeNowSection: ReactElement | null; }; export function AgentSessionListContent({ @@ -82,6 +97,7 @@ export function AgentSessionListContent({ onClearQuery, onCreateSession, sortBy, + activeNowSection, }: Readonly) { const listRef = useRef>(null); useScrollToTop(listRef); @@ -129,6 +145,18 @@ export function AgentSessionListContent({ [activeIsError, hasActiveQuery, hasHistoryContent, hasPinnedActive, isError, isSearching] ); + const surface = useMemo( + () => + selectSessionListContentSurface({ + isLoading, + isError, + hasAnySessions, + hasPinnedActive, + hasHistoryContent, + }), + [hasAnySessions, hasHistoryContent, hasPinnedActive, isError, isLoading] + ); + const emptyStateAction = useMemo( () => (