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
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,12 @@ describe('AgentSessionListScreen live presentation', () => {
expect(listSkeletons()[0]?.props.className).toContain('h-[76px]');
}
expect(text().includes('Nothing running right now')).toBe(Boolean(test.empty));
expect(text().includes('Could not load active sessions')).toBe(Boolean(test.error));
// With cached rows on screen, a retryable failure is a refresh failure and
// speaks through the reserved status line, not the load-failure block.
expect(text().includes('Could not load active sessions')).toBe(
Boolean(test.error) && !test.rows
);
expect(text().includes("Couldn't refresh")).toBe(Boolean(test.error) && Boolean(test.rows));
expect(text().includes('Updating')).toBe(Boolean(test.updating));
expect(text().includes('Loading…')).toBe(Boolean(test.skeleton));
expect(nodes('FlatList')).toHaveLength(test.rows ? 1 : 0);
Expand Down Expand Up @@ -584,17 +589,30 @@ describe('AgentSessionListScreen live presentation', () => {
const pending = Promise.withResolvers<boolean>();
state.refetch.mockReturnValue(pending.promise);
await renderScreen();
act(() => {
press('Retry');
press('Retry');
});
expect(action('Retry').props.disabled).toBe(true);
expect(action('Retry').props.accessibilityState).toMatchObject({
busy: true,
disabled: true,
});
if (cached) {
// With cached rows the reserved status line owns the retry: no
// disabled button, and the second tap must not start a second fetch.
expect(text()).toContain("Couldn't refresh");
act(() => {
press('Retry');
press('Retry');
});
expect(state.refetch).toHaveBeenCalledTimes(1);
expect(state.announcements).toContain('Updating');
} else {
act(() => {
press('Retry');
press('Retry');
});
expect(action('Retry').props.disabled).toBe(true);
expect(action('Retry').props.accessibilityState).toMatchObject({
busy: true,
disabled: true,
});
expect(state.refetch).toHaveBeenCalledTimes(1);
}
expect(action('Retry connection').props.disabled).toBe(false);
const queryRetry = action('Retry');
const queryRetry = cached ? undefined : action('Retry');
const socketRetry = action('Retry connection');
expect(
nodes('View').filter(
Expand All @@ -603,13 +621,22 @@ describe('AgentSessionListScreen live presentation', () => {
view.findAll(node => node === queryRetry || node === socketRetry).length > 0
)
).toHaveLength(0);
expect(state.refetch).toHaveBeenCalledTimes(1);
await act(async () => {
pending.resolve(false);
await pending.promise;
});
expect(action('Retry').props.disabled).toBe(false);
expect(text()).toContain('Could not load active sessions');
if (cached) {
// A rejected pull holds the in-flight feedback through the anti-flicker
// beat before the failure line takes over.
await act(async () => {
await new Promise(resolve => {
setTimeout(resolve, PULL_FEEDBACK_MIN_BEAT_MS + 100);
});
});
} else {
expect(action('Retry').props.disabled).toBe(false);
}
expect(text()).toContain(cached ? "Couldn't refresh" : 'Could not load active sessions');
state.refetch.mockImplementation(async () => {
await Promise.resolve();
state.live.terminalError = null;
Expand All @@ -621,6 +648,7 @@ describe('AgentSessionListScreen live presentation', () => {
});
await renderScreen();
expect(text()).not.toContain('Could not load active sessions');
expect(text()).not.toContain("Couldn't refresh");
expect(nodes('FlatList')).toHaveLength(cached ? 1 : 0);
state.socketRetry.mockImplementation(() => {
state.connection.reconnectExhausted = false;
Expand All @@ -639,17 +667,19 @@ describe('AgentSessionListScreen live presentation', () => {
state.live.hasAcceptedSuccess = false;
state.live.terminalError = failure;
await renderScreen();
const message = 'Could not load active sessions';
expect(text()).toContain(message);
const loadFailure = 'Could not load active sessions';
const refreshFailure = "Couldn't refresh";
expect(text()).toContain(loadFailure);
expect(nodes('CenteredState')).toHaveLength(1);

async function updateSocketRows(activeSessions: ActiveSession[]) {
state.live.activeSessions = activeSessions;
await renderScreen();
expect(nodes('RemoteSessionRow')).toHaveLength(activeSessions.length);
expect(nodes('CenteredState')).toHaveLength(activeSessions.length === 0 ? 1 : 0);
const message = activeSessions.length === 0 ? loadFailure : refreshFailure;
expect(text()).toContain(message);
expect(state.announcements).toEqual([message]);
expect(state.announcements).toContain(message);
await act(async () => {
press('Retry');
await Promise.resolve();
Expand Down Expand Up @@ -966,6 +996,22 @@ describe('AgentSessionListScreen live counts', () => {
expect(reserved?.props.accessibilityElementsHidden).toBe(true);
expect(nodes('FlatList')).toHaveLength(orgLoaded ? 1 : 0);
});

it('keeps the retained count and rows through a retryable refresh failure', async () => {
state.live.activeSessions = [row];
state.live.terminalError = failure;
await renderScreen();

// The last snapshot stays legible: the count is not blanked, and the
// failure cannot grow an in-flow block that pushes the kept rows down.
expect(header().props.eyebrow).toBe('1 LIVE');
expect(nodes('FlatList')).toHaveLength(1);
expect(nodes('RemoteSessionRow')).toHaveLength(1);
expect(nodes('CenteredState')).toHaveLength(0);
expect(text()).toContain("Couldn't refresh");
expect(text()).not.toContain('Could not load active sessions');
expect(nodes('View').filter(node => node.props.className === 'min-h-5')).toHaveLength(1);
});
});

describe('AgentSessionListScreen live filtering', () => {
Expand Down
34 changes: 31 additions & 3 deletions apps/mobile/src/components/agents/session-list-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export function AgentSessionListScreen() {
const content = liveSessionContent(context, sessions);
const hasLiveRows = content === 'rows';
const showFab = context.isReady && content !== 'empty';
// A failed foreground refresh keeps the cached rows on screen. That failure
// must speak through the reserved status line (one inline "Couldn't refresh"
// with Retry) instead of the load-failure block, which would push the kept
// rows down. Treat it exactly like a failed pull when rows are still shown.
const retryableRowsFailure = hasLiveRows && sessions.terminalError?.kind === 'retryable';

const query = useLiveSessionQuery(activeSessions);
const { visibleSessions, isSearching } = query;
Expand All @@ -76,9 +81,26 @@ export function AgentSessionListScreen() {
// fetch cannot pin the spinner with no next action.
const pull = usePullRefresh(refetchRequest);
const handleRefresh = pull.startPull;
const { markSettled } = pull;
const { markSettled, startRetry } = pull;
const refreshControl = <RefreshControl refreshing={pull.refreshing} onRefresh={handleRefresh} />;

// The reserved status line's Retry replaces the removed in-flow failure
// block, so it inherits that block's idempotence: a second tap before the
// first refetch settles must not start a second one.
const retryLock = useRef(false);
useEffect(() => {
if (!pull.busy && !pull.refreshing) {
retryLock.current = false;
}
}, [pull.busy, pull.refreshing]);
const handleRefreshRetry = useCallback(() => {
if (retryLock.current) {
return;
}
retryLock.current = true;
startRetry();
}, [startRetry]);

// Focus return and app-foreground refreshes run outside the pull lifecycle.
// A failed pull leaves the reserved line on "Couldn't refresh" + Retry; when
// one of these refreshes lands an accepted result the list is up to date, so
Expand Down Expand Up @@ -262,6 +284,12 @@ export function AgentSessionListScreen() {
<ScreenHeader
title={t('common.agents')}
eyebrow={
// The count is an assertion about the current snapshot, so it is
// withheld whenever that snapshot cannot be confirmed: while the
// list is unresolved (loading or membership unknown) and while the
// live query is in an error state, exactly as the tab badge is.
// The cached rows themselves stay on screen, so a failed refresh
// never blanks the list it kept.
!sessions.isLoading && !sessions.isError && (hasLiveRows || content === 'empty')
? t('agents.liveCount', { count: activeSessions.length })
: undefined
Expand Down Expand Up @@ -292,8 +320,8 @@ export function AgentSessionListScreen() {
centered={query.hasLoaded && content === 'error'}
refresh={{
busy: pull.refreshing || pull.busy,
failed: pull.failed,
onRetry: pull.startRetry,
failed: pull.failed || retryableRowsFailure,
onRetry: handleRefreshRetry,
}}
refreshControl={refreshControl}
/>
Expand Down
37 changes: 21 additions & 16 deletions apps/mobile/src/components/home/agent-sessions-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,29 +216,34 @@ export function LiveSessionFeedback({
</Button>
)}
</View>
<AccessibleStatus
message={content === 'pending' ? t('common.loading') : null}
tone="status"
className="absolute size-px overflow-hidden"
/>
{refresh && content === 'rows' ? (
// The live tab's reserved status line: screen-reader Updating while
// the pull is in flight, visible "Couldn't refresh" + Retry when it
// failed. It takes the slot of the (layout-free) loading status so the
// column has the same children either way, and its height is allocated
// whenever rows are shown: a failure that arrives while the kept rows
// are on screen replaces empty space instead of pushing the rows down.
<View className="min-h-5">
<SessionListRefreshStatus
busy={refresh.busy}
failed={refresh.failed}
onRetry={handleRefreshRetry}
/>
</View>
) : (
<AccessibleStatus
message={content === 'pending' ? t('common.loading') : null}
tone="status"
className="absolute size-px overflow-hidden"
/>
)}
{content === 'rows' && sessions.isFetching && !sessions.isPaused && !refresh?.busy && (
<AccessibleStatus
message={t('agents.sessionList.updating')}
tone="status"
className="absolute size-px overflow-hidden"
/>
)}
{/* The live tab's reserved status line: screen-reader Updating while
the pull is in flight, visible "Couldn't refresh" + Retry when it
failed. Home passes no refresh state and keeps its a11y-only
announcement. */}
{refresh && content === 'rows' ? (
<SessionListRefreshStatus
busy={refresh.busy}
failed={refresh.failed}
onRetry={handleRefreshRetry}
/>
) : null}
{failure}
</View>
);
Expand Down
14 changes: 13 additions & 1 deletion apps/mobile/src/lib/active-sessions-live-sync-mount.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { AppState } from 'react-native';
import { type QueryFunction, useQuery, useQueryClient } from '@tanstack/react-query';

import { useUserWebConnection } from '@/components/agents/user-web-connection-provider';
Expand Down Expand Up @@ -113,7 +114,18 @@ function useActiveSessionsLiveSync(): void {
return undefined;
}
const sync = new ActiveSessionsLiveSync({ connection, queryClient, queryKey, queryFn });
return sync.attach();
const detach = sync.attach();
// One refresh per foreground transition. `change` fires on the transition
// only, so this rides an existing wakeup instead of adding a poll.
const appStateSubscription = AppState.addEventListener('change', nextState => {
if (nextState === 'active' && !isSignOutActive()) {
sync.scheduleRefresh('foreground');
}
});
return () => {
appStateSubscription.remove();
detach();
};
}, [connection, enabled, authEpoch, queryClient, queryFn, queryKey]);
}

Expand Down
92 changes: 92 additions & 0 deletions apps/mobile/src/lib/active-sessions-live-sync.foreground.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest';

import { getActiveSessionsQueryMetadata } from '@/lib/query-client';

import {
ActiveSessionsLiveSync,
makeCached,
makeConnection,
makeFakeQueryClient,
makeQueryFn,
QUERY_KEY,
setupTimers,
} from '@/lib/active-sessions-live-sync.test-helpers';

setupTimers();

let sync: ActiveSessionsLiveSync | null = null;

afterEach(() => {
sync?.detach();
sync = null;
});

function attach(
qc: ReturnType<typeof makeFakeQueryClient>,
queryFn: ReturnType<typeof makeQueryFn>
) {
sync = new ActiveSessionsLiveSync({
connection: makeConnection(),
queryClient: qc,
queryKey: QUERY_KEY,
queryFn,
});
sync.attach();
return sync;
}

describe('ActiveSessionsLiveSync — foreground refresh', () => {
it('issues exactly one fetch for one foreground schedule', async () => {
const qc = makeFakeQueryClient();
const queryFn = makeQueryFn();
const owner = attach(qc, queryFn);

owner.scheduleRefresh('foreground');
await owner.getFetchQueue();
expect(queryFn).toHaveBeenCalledTimes(1);

qc.__triggerFetchResolve({ sessions: [] });
await owner.getFetchCompletion();

// A resolved foreground refresh clears its reason and never re-kicks a poll.
expect(queryFn).toHaveBeenCalledTimes(1);
expect(owner.getPendingReasons()).toEqual(new Set());
const query = qc.getQueryCache().find({ queryKey: QUERY_KEY, exact: true });
expect(getActiveSessionsQueryMetadata(query).acceptedRevision).toBe(1);
});

it('coalesces a foreground reason with a concurrent reconnect into one fetch', async () => {
const qc = makeFakeQueryClient();
const queryFn = makeQueryFn();
const owner = attach(qc, queryFn);

// Both land before the fetch starts, so the existing coalescing owns them.
owner.scheduleRefresh('foreground');
owner.scheduleRefresh('reconnect');
await owner.getFetchQueue();
expect(queryFn).toHaveBeenCalledTimes(1);

qc.__triggerFetchResolve({ sessions: [] });
await owner.getFetchCompletion();
expect(owner.getPendingReasons()).toEqual(new Set());
});

it('keeps the previous counts until the foreground fetch resolves', async () => {
const previous = { sessions: [makeCached({ id: 'old', status: 'running' })] };
const next = { sessions: [makeCached({ id: 'new', status: 'idle' })] };
const qc = makeFakeQueryClient(previous);
const queryFn = makeQueryFn(next);
const owner = attach(qc, queryFn);

owner.scheduleRefresh('foreground');
await owner.getFetchQueue();

// The surface must not blank or jump while the refresh is in flight.
expect(qc.__getCached()).toEqual(previous);

qc.__triggerFetchResolve(next);
await owner.getFetchCompletion();

expect(qc.__getCached()).toEqual(next);
});
});
7 changes: 3 additions & 4 deletions apps/mobile/src/lib/active-sessions-live-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { isSignOutActive } from './auth/sign-out-state';
import { captureActiveSessionsQueryRefresh, fenceActiveSessionsQuery } from './query-client';

const ENRICHMENT_RETRY_MIN_INTERVAL_MS = 10_000;
type RefreshReason = 'enrichment' | 'cli-connected' | 'cli-disconnected' | 'reconnect' | 'manual';
type LiveSyncReason = 'enrichment' | 'cli-connected' | 'cli-disconnected' | 'reconnect' | 'manual';
type RefreshReason = LiveSyncReason | 'foreground';
type WriteUpdater = (current: CachedActiveSession[]) => CachedActiveSession[];
export type LiveSyncConnection = Pick<
UserWebConnection,
Expand Down Expand Up @@ -136,9 +137,7 @@ export class ActiveSessionsLiveSync {
if (!this.isCurrentAttachment(epoch)) {
return { accepted: false, canceled: true };
}
return {
accepted: !this.pendingReasons.has('manual') && refresh.hasAcceptedResult(),
};
return { accepted: !this.pendingReasons.has('manual') && refresh.hasAcceptedResult() };
}

async getWriteQueue(): Promise<void> {
Expand Down
Loading