Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4020d6f
chore(mobile): install expo-linear-gradient
iscekic Jul 25, 2026
e692102
feat(mobile): add shimmer sweep to shared Skeleton
iscekic Jul 26, 2026
9db31d8
fix(mobile): suppress transcript pagination header jump on older-page…
iscekic Jul 26, 2026
03d0e72
fix(mobile): stabilize notifications settings loading layout
iscekic Jul 26, 2026
3ccce72
fix(mobile): preserve and control agents session-list scroll
iscekic Jul 26, 2026
88f59ba
feat(mobile): show session origin platform icon in agents list
iscekic Jul 26, 2026
6656151
feat(mobile): show session platform icon on session detail header
iscekic Jul 26, 2026
a00608c
fix(mobile): scroll session list to top on committed search query
iscekic Jul 26, 2026
0affde1
Merge feat/mobile-session-list-ux for unified session-list scroll work
iscekic Jul 26, 2026
d8bc240
fix(mobile): order active sessions deterministically by creation time
iscekic Jul 26, 2026
d3f30c5
fix(mobile): scroll active-now section with the session list
iscekic Jul 26, 2026
f674ff7
fix(mobile): make monospace message blocks horizontally reachable
iscekic Jul 26, 2026
68ba3de
fix(mobile): deliver mono block horizontal scroll via gesture-handler
iscekic Jul 26, 2026
bc44e50
test(mobile): add E2E latency harness for tRPC and WS delays
iscekic Jul 26, 2026
a6bc403
fix(cloud-agent-sdk): keep session loading until initial history repl…
iscekic Jul 26, 2026
8ce790e
Merge origin/main into fix/mobile-list-order-and-open
iscekic Jul 27, 2026
fc8d8e7
fix(mobile): restore active-now list header lost in merge resolution
iscekic Jul 27, 2026
203e247
Merge remote-tracking branch 'origin/main' into fix/mobile-list-order…
iscekic Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import '../global.css';
import '@/lib/cloud-agent-runtime';

import { installE2EWebSocketLatency } from '@/lib/e2e-ws-latency';

import {
JetBrainsMono_500Medium,
JetBrainsMono_600SemiBold,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions apps/mobile/src/components/agents/active-now-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions apps/mobile/src/components/agents/mono-scroll-block-model.ts
Original file line number Diff line number Diff line change
@@ -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,
};
94 changes: 94 additions & 0 deletions apps/mobile/src/components/agents/mono-scroll-block.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
86 changes: 86 additions & 0 deletions apps/mobile/src/components/agents/mono-scroll-block.tsx
Original file line number Diff line number Diff line change
@@ -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<MonoScrollBlockProps>) {
const { displayText, isTruncated } = prepareMonoScrollContent(content, maxLength);
const [heightPin, setHeightPin] = useState<MonoScrollHeightPin | undefined>(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 (
<View className={containerClassName}>
<ScrollView
{...MONO_SCROLL_VIEW_PROPS}
// Explicit height from measured content — see D10 / component doc.
// eslint-disable-next-line react-native/no-inline-styles -- measured height cannot be a Tailwind class
style={contentHeight === undefined ? undefined : { height: contentHeight }}
>
<Text
selectable
onLayout={handleContentLayout}
className={cn('shrink-0 self-start font-mono text-xs leading-4', textClassName)}
>
{displayText}
</Text>
</ScrollView>
{isTruncated ? (
<Text accessibilityLabel="Content truncated" className="mt-1 text-xs text-muted-foreground">
Truncated
</Text>
) : null}
</View>
);
}
10 changes: 7 additions & 3 deletions apps/mobile/src/components/agents/preparation-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -139,9 +141,11 @@ function PreparationStepRow({ step }: { step: PreparationStepSnapshot }) {
{step.outputTruncated ? (
<Text className="text-xs text-muted-foreground">Earlier output omitted</Text>
) : null}
<Text selectable className="rounded bg-secondary p-2 font-mono text-xs">
{step.outputTail}
</Text>
<MonoScrollBlock
content={step.outputTail}
containerClassName="rounded bg-secondary p-2"
textClassName="text-foreground"
/>
</>
) : null}
</View>
Expand Down
40 changes: 40 additions & 0 deletions apps/mobile/src/components/agents/session-list-content-surface.ts
Original file line number Diff line number Diff line change
@@ -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' };
}
Loading