Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +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-linking": "55.0.16",
"expo-localization": "~55.0.16",
"expo-location": "55.1.11",
Expand Down
86 changes: 86 additions & 0 deletions apps/mobile/src/components/agents/older-messages-a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';

import {
OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT,
shouldAnnounceOlderMessagesArrival,
} from '@/components/agents/older-messages-a11y';

describe('shouldAnnounceOlderMessagesArrival', () => {
it('does not announce on the initial paint (list first becomes initialized)', () => {
expect(
shouldAnnounceOlderMessagesArrival({
wasInitialized: false,
previousCount: 0,
nextCount: 20,
previousNewestKey: null,
nextNewestKey: 'newest',
})
).toBe(false);
});

it('announces when count grows and the newest key stays stable (older page prepend)', () => {
expect(
shouldAnnounceOlderMessagesArrival({
wasInitialized: true,
previousCount: 20,
nextCount: 40,
previousNewestKey: 'newest',
nextNewestKey: 'newest',
})
).toBe(true);
});

it('does not announce on append when the newest key changes', () => {
expect(
shouldAnnounceOlderMessagesArrival({
wasInitialized: true,
previousCount: 20,
nextCount: 21,
previousNewestKey: 'old-newest',
nextNewestKey: 'new-newest',
})
).toBe(false);
});

it('does not announce when a fetch completes with zero prepended items', () => {
expect(
shouldAnnounceOlderMessagesArrival({
wasInitialized: true,
previousCount: 20,
nextCount: 20,
previousNewestKey: 'newest',
nextNewestKey: 'newest',
})
).toBe(false);
});

it('does not announce when count shrinks', () => {
expect(
shouldAnnounceOlderMessagesArrival({
wasInitialized: true,
previousCount: 20,
nextCount: 10,
previousNewestKey: 'newest',
nextNewestKey: 'newest',
})
).toBe(false);
});

it('does not announce when newest keys are missing', () => {
expect(
shouldAnnounceOlderMessagesArrival({
wasInitialized: true,
previousCount: 0,
nextCount: 5,
previousNewestKey: null,
nextNewestKey: 'newest',
})
).toBe(false);
});
});

describe('OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT', () => {
it('is stable screen-reader copy for both message lists', () => {
expect(OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT).toBe('Earlier messages loaded');
});
});
36 changes: 36 additions & 0 deletions apps/mobile/src/components/agents/older-messages-a11y.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
type ShouldAnnounceOlderMessagesArrivalInputs = {
wasInitialized: boolean;
previousCount: number;
nextCount: number;
previousNewestKey: string | null;
nextNewestKey: string | null;
};

/**
* Whether assistive technology should hear that earlier messages arrived.
*
* Announces only on a real prepend after the list has already painted: count
* grows while the newest item identity stays stable. Skips initial load,
* appends (newest key changes), and empty prepends (count unchanged).
*/
export function shouldAnnounceOlderMessagesArrival({
wasInitialized,
previousCount,
nextCount,
previousNewestKey,
nextNewestKey,
}: ShouldAnnounceOlderMessagesArrivalInputs): boolean {
if (!wasInitialized) {
return false;
}
if (nextCount <= previousCount) {
return false;
}
if (previousNewestKey == null || nextNewestKey == null) {
return false;
}
return previousNewestKey === nextNewestKey;
}

/** Screen-reader copy when an older page actually prepends items. */
export const OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT = 'Earlier messages loaded';
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ describe('selectSessionMessageListHeaderState', () => {
});

it('hides omitted noise while a page is loading and the count is non-zero', () => {
// The skeleton replaces the calm informational message; once the page
// resolves, the omitted message returns only if no error overrides it.
// State layer still prioritizes loading over omitted. The render model
// maps loading+omitted>0 back to the omitted banner so it stays stable
// through the load (no skeleton, no hide/show flap).
expect(
selectSessionMessageListHeaderState({
isLoadingOlderMessages: true,
Expand Down
13 changes: 8 additions & 5 deletions apps/mobile/src/components/agents/session-message-list-state.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { type OlderMessagesError } from 'cloud-agent-sdk';

/**
* Pagination header state for `SessionMessageList`. The component renders
* exactly one of these per render: a loading skeleton, a calm inline
* Pagination header state for `SessionMessageList`. The state selector
* still emits exactly one of these per render: loading, a calm inline
* message (with or without a Retry CTA), or nothing.
*
* Priority is enforced by `selectSessionMessageListHeaderState`:
* 1. The most recent typed failure wins so the user can always act on it
* (or, for non-retryable terminals, sees a stable final message).
* 2. While a page is loading, the skeleton replaces the omitted message
* so the two never collide visually.
* 3. The omitted-item count only surfaces when the load path is healthy.
* 2. While a page is loading, the state layer still prioritizes `loading`
* over `omitted`. The render model maps that loading state to the
* omitted banner when omitted count > 0 (keeps the banner stable
* through the load), otherwise to hidden — no transient skeleton.
* 3. The omitted-item count only surfaces from the state layer when the
* load path is healthy (not loading and no error).
*/
type SessionMessageListHeaderState =
| { kind: 'hidden' }
Expand Down
38 changes: 36 additions & 2 deletions apps/mobile/src/components/agents/session-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import { FlashList, type FlashListRef, type ListRenderItem } from '@shopify/flas
import { type OlderMessagesError } from 'cloud-agent-sdk';
import { ChevronDown } from 'lucide-react-native';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Pressable, View, type ViewStyle } from 'react-native';
import { AccessibilityInfo, Pressable, View, type ViewStyle } from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

import { useSessionListAutoScroll } from '@/components/agents/use-session-list-auto-scroll';
import { SessionPaginationHeader } from '@/components/agents/session-pagination-header';
import { shouldTriggerOlderMessagesLoad } from '@/components/agents/session-message-list-state';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import {
OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT,
shouldAnnounceOlderMessagesArrival,
} from '@/components/agents/older-messages-a11y';

const listStyle = { flex: 1 } satisfies ViewStyle;
const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle;
Expand All @@ -17,7 +21,7 @@ const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle;
// flight. The manager dedupes too, but the UI guard keeps us from issuing
// repeated `onStartReached` callbacks during a single drag, which would
// otherwise spam the FlashList event log.
const ON_START_REACHED_THRESHOLD = 0.5;
const ON_START_REACHED_THRESHOLD = 2;

type SessionMessageListProps<T> = {
sessionId: string;
Expand Down Expand Up @@ -109,6 +113,36 @@ export function SessionMessageList<T>({
inFlightRef.current = false;
}, [sessionId]);

// Non-visual a11y signal for older-page arrival (visual loading skeleton
// was removed). Announce only when items were actually prepended.
const olderArrivalInitializedRef = useRef(false);
const olderArrivalCountRef = useRef(0);
const olderArrivalNewestKeyRef = useRef<string | null>(null);
useEffect(() => {
olderArrivalInitializedRef.current = false;
olderArrivalCountRef.current = 0;
olderArrivalNewestKeyRef.current = null;
}, [sessionId]);
useEffect(() => {
const newestItem = items.at(-1);
const nextNewestKey = newestItem === undefined ? null : keyExtractor(newestItem);
const nextCount = items.length;
if (
shouldAnnounceOlderMessagesArrival({
wasInitialized: olderArrivalInitializedRef.current,
previousCount: olderArrivalCountRef.current,
nextCount,
previousNewestKey: olderArrivalNewestKeyRef.current,
nextNewestKey,
})
) {
AccessibilityInfo.announceForAccessibility(OLDER_MESSAGES_ARRIVED_ANNOUNCEMENT);
}
olderArrivalInitializedRef.current = true;
olderArrivalCountRef.current = nextCount;
olderArrivalNewestKeyRef.current = nextNewestKey;
}, [items, keyExtractor]);

// Defensive: the structural list ref is required by the hook but
// downstream types may infer it as nullable.
const listRefSafe = listRef as unknown as React.RefObject<FlashListRef<T>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,32 @@ describe('selectSessionPaginationHeaderRenderModel', () => {
expect(headerModel()).toEqual({ kind: 'hidden' });
});

it('returns loading with testID and progressbar role', () => {
it('hides the transient loading placeholder when no omitted banner is showing', () => {
expect(headerModel({ isLoadingOlderMessages: true })).toEqual({
kind: 'loading',
testID: 'session-pagination-header-loading',
accessibilityRole: 'progressbar',
text: null,
kind: 'hidden',
});
});

it('keeps the omitted banner stable while a page is loading', () => {
expect(headerModel({ isLoadingOlderMessages: true, olderMessagesOmittedItemCount: 5 })).toEqual(
{
kind: 'omitted',
testID: 'session-pagination-header-omitted',
text: '5 earlier items from this session could not be displayed.',
}
);
});

it('keeps singular omitted text stable while a page is loading', () => {
expect(headerModel({ isLoadingOlderMessages: true, olderMessagesOmittedItemCount: 1 })).toEqual(
{
kind: 'omitted',
testID: 'session-pagination-header-omitted',
text: 'Some earlier items from this session could not be displayed.',
}
);
});

it('renders retryable text and a Retry CTA', () => {
expect(headerModel({ olderMessagesError: error('retryable') })).toEqual({
kind: 'retryable',
Expand Down Expand Up @@ -76,14 +93,19 @@ describe('selectSessionPaginationHeaderRenderModel', () => {

it('only includes a retry CTA for the retryable state', () => {
const hidden = headerModel();
const loading = headerModel({ isLoadingOlderMessages: true });
const loadingHidden = headerModel({ isLoadingOlderMessages: true });
const loadingOmitted = headerModel({
isLoadingOlderMessages: true,
olderMessagesOmittedItemCount: 3,
});
const invalidData = headerModel({ olderMessagesError: error('invalid_data') });
const tooLarge = headerModel({ olderMessagesError: error('too_large') });
const omitted = headerModel({ olderMessagesOmittedItemCount: 3 });
const retryable = headerModel({ olderMessagesError: error('retryable') });

expect('retry' in hidden).toBe(false);
expect('retry' in loading).toBe(false);
expect('retry' in loadingHidden).toBe(false);
expect('retry' in loadingOmitted).toBe(false);
expect('retry' in invalidData).toBe(false);
expect('retry' in tooLarge).toBe(false);
expect('retry' in omitted).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ function omittedMessage(count: number): string {

export type SessionPaginationHeaderRenderModel =
| { kind: 'hidden' }
| { kind: 'loading'; testID: string; accessibilityRole: 'progressbar'; text: null }
| {
kind: 'retryable';
testID: string;
Expand All @@ -26,6 +25,14 @@ export type SessionPaginationHeaderRenderModel =
| { kind: 'too_large'; testID: string; text: string }
| { kind: 'omitted'; testID: string; text: string };

function omittedRenderModel(count: number): SessionPaginationHeaderRenderModel {
return {
kind: 'omitted',
testID: 'session-pagination-header-omitted',
text: omittedMessage(count),
};
}

export function selectSessionPaginationHeaderRenderModel(
inputs: SessionMessageListHeaderStateInputs
): SessionPaginationHeaderRenderModel {
Expand All @@ -35,13 +42,17 @@ export function selectSessionPaginationHeaderRenderModel(
return { kind: 'hidden' };
}

// Suppress the transient loading placeholder so FlashList mVCP is not
// disturbed by a header height collapse when the older page arrives.
// When an omitted banner is already visible (count > 0), keep it stable
// through the load instead of hiding it — a hide/show flap would reintroduce
// the same jump. The state layer still prioritizes `loading` over `omitted`;
// this mapping is render-model only.
if (state.kind === 'loading') {
return {
kind: 'loading',
testID: 'session-pagination-header-loading',
accessibilityRole: 'progressbar',
text: null,
};
if (inputs.olderMessagesOmittedItemCount > 0) {
return omittedRenderModel(inputs.olderMessagesOmittedItemCount);
}
return { kind: 'hidden' };
}

if (state.kind === 'retryable') {
Expand Down Expand Up @@ -69,9 +80,5 @@ export function selectSessionPaginationHeaderRenderModel(
};
}

return {
kind: 'omitted',
testID: 'session-pagination-header-omitted',
text: omittedMessage(state.count),
};
return omittedRenderModel(state.count);
}
13 changes: 0 additions & 13 deletions apps/mobile/src/components/agents/session-pagination-header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { View } from 'react-native';

import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
import {
selectSessionPaginationHeaderRenderModel,
Expand Down Expand Up @@ -33,18 +32,6 @@ export function SessionPaginationHeader({
return null;
}

if (model.kind === 'loading') {
return (
<View
testID={model.testID}
className="items-start gap-1 px-4 py-2"
accessibilityRole={model.accessibilityRole}
>
<Skeleton className="h-16 w-3/4 rounded-2xl rounded-tl-sm" />
</View>
);
}

if (model.kind === 'retryable') {
return (
<View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ export function ConversationScreen({
members={conversationMembers}
botName={instanceLabel}
fetchOlder={fetchOlder}
isFetchingOlder={messagesQuery.isFetchingNextPage}
pendingAction={messageController.pendingAction}
scrollToNewestRequest={messageController.scrollToNewestRequest}
onExecuteAction={messageController.handleExecuteAction}
Expand Down
Loading