diff --git a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx index 9532f032ed..f20ed3e3f9 100644 --- a/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx @@ -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); @@ -584,17 +589,30 @@ describe('AgentSessionListScreen live presentation', () => { const pending = Promise.withResolvers(); 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( @@ -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; @@ -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; @@ -639,8 +667,9 @@ 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[]) { @@ -648,8 +677,9 @@ describe('AgentSessionListScreen live presentation', () => { 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(); @@ -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', () => { diff --git a/apps/mobile/src/components/agents/session-list-screen.tsx b/apps/mobile/src/components/agents/session-list-screen.tsx index efda9ae69c..2060448b25 100644 --- a/apps/mobile/src/components/agents/session-list-screen.tsx +++ b/apps/mobile/src/components/agents/session-list-screen.tsx @@ -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; @@ -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 = ; + // 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 @@ -262,6 +284,12 @@ export function AgentSessionListScreen() { diff --git a/apps/mobile/src/components/home/agent-sessions-section.tsx b/apps/mobile/src/components/home/agent-sessions-section.tsx index 3016df5813..5c3767bd32 100644 --- a/apps/mobile/src/components/home/agent-sessions-section.tsx +++ b/apps/mobile/src/components/home/agent-sessions-section.tsx @@ -216,11 +216,27 @@ export function LiveSessionFeedback({ )} - + {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. + + + + ) : ( + + )} {content === 'rows' && sessions.isFetching && !sessions.isPaused && !refresh?.busy && ( )} - {/* 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' ? ( - - ) : null} {failure} ); diff --git a/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx b/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx index cf0383657f..3e02b4d391 100644 --- a/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx +++ b/apps/mobile/src/lib/active-sessions-live-sync-mount.tsx @@ -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'; @@ -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]); } diff --git a/apps/mobile/src/lib/active-sessions-live-sync.foreground.test.ts b/apps/mobile/src/lib/active-sessions-live-sync.foreground.test.ts new file mode 100644 index 0000000000..2986fe0db4 --- /dev/null +++ b/apps/mobile/src/lib/active-sessions-live-sync.foreground.test.ts @@ -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, + queryFn: ReturnType +) { + 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); + }); +}); diff --git a/apps/mobile/src/lib/active-sessions-live-sync.ts b/apps/mobile/src/lib/active-sessions-live-sync.ts index be5b09e613..bc821ced02 100644 --- a/apps/mobile/src/lib/active-sessions-live-sync.ts +++ b/apps/mobile/src/lib/active-sessions-live-sync.ts @@ -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, @@ -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 { diff --git a/apps/mobile/src/lib/hooks/use-agent-sessions.live.mounted.test.tsx b/apps/mobile/src/lib/hooks/use-agent-sessions.live.mounted.test.tsx index 90eb5e9750..079642bba2 100644 --- a/apps/mobile/src/lib/hooks/use-agent-sessions.live.mounted.test.tsx +++ b/apps/mobile/src/lib/hooks/use-agent-sessions.live.mounted.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable typescript-eslint/no-deprecated -- DOM-free React Native hook integration */ +/* eslint-disable typescript-eslint/no-deprecated, max-lines -- DOM-free React Native hook integration; the mount gate matrix exceeds the default line limit */ import { createElement } from 'react'; import { onlineManager } from '@tanstack/react-query'; import { act } from 'react-test-renderer'; @@ -58,7 +58,12 @@ vi.mock('@/lib/hooks/use-user-web-connection-state', () => ({ vi.mock('@/components/agents/user-web-connection-provider', () => ({ useUserWebConnection: () => connection, })); -vi.mock('react-native', () => ({ InteractionManager: { runAfterInteractions: vi.fn() } })); +// The app-level mount subscribes to foreground transitions; the mock exposes +// the same subscribe/remove contract as React Native. +vi.mock('react-native', () => ({ + InteractionManager: { runAfterInteractions: vi.fn() }, + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, +})); let client = makeTestQueryClient(); let connection = makeConnection(); diff --git a/services/notifications/.dev.vars.example b/services/notifications/.dev.vars.example index d8a5f46764..6d2b69d76e 100644 --- a/services/notifications/.dev.vars.example +++ b/services/notifications/.dev.vars.example @@ -9,3 +9,11 @@ WORKER_ENV=development # Set to `log` here to enable; leave unset / empty to keep the real Expo # send path. PUSH_SINK_MODE= + +# Base origin of the web app for the internal glanceable-agents-snapshot +# route (see ENVIRONMENT.md). Production pins `https://app.kilo.ai` in +# wrangler.jsonc, which is single-config; the local dev override points the +# worker at the local Next.js server so the route answers with the shared +# INTERNAL_API_SECRET instead of a cross-environment 401. +# @url nextjs +KILO_WEB_API_BASE_URL=https://app.kilo.ai diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index bb28899335..f4b65367e3 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -1776,7 +1776,8 @@ describe('buildGlanceableExpoMessages', () => { { token: 'ExponentPushToken[aaa]', locale: null }, { token: 'ExponentPushToken[bbb]', locale: 'es' }, ], - snapshot + snapshot, + 'default' ); expect(messages).toHaveLength(2); @@ -1792,6 +1793,28 @@ describe('buildGlanceableExpoMessages', () => { } expect(messages.map(m => m.to)).toEqual(['ExponentPushToken[aaa]', 'ExponentPushToken[bbb]']); }); + + it('builds the iOS wake at default priority and the Android wake at high priority', () => { + const iosMessages = buildGlanceableExpoMessages( + [{ token: 'ExponentPushToken[ios]', locale: null }], + snapshot, + 'default' + ); + const androidMessages = buildGlanceableExpoMessages( + [{ token: 'ExponentPushToken[android]', locale: null }], + snapshot, + 'high' + ); + + expect(iosMessages[0].priority).toBe('default'); + expect(androidMessages[0].priority).toBe('high'); + // Only the transport priority and destination token differ between platforms. + expect(androidMessages[0]).toEqual({ + ...iosMessages[0], + to: 'ExponentPushToken[android]', + priority: 'high', + }); + }); }); describe('deliverGlanceableSnapshot', () => { @@ -1890,6 +1913,7 @@ describe('deliverGlanceableSnapshot', () => { expect(calls.expoSends[0][0].to).toBe('ExponentPushToken[aaa]'); expect(calls.expoSends[0][0].tag).toBe('deadbeef'); expect(calls.expoSends[0][0]._contentAvailable).toBe(true); + expect(calls.expoSends[0][0].priority).toBe('high'); }); it('sends nothing on Android when the user has no Expo tokens even with an ongoing token', async () => { @@ -1962,6 +1986,7 @@ describe('deliverGlanceableSnapshot', () => { expect(calls.expoSends[0]).toHaveLength(1); expect(calls.expoSends[0][0].to).toBe('ExponentPushToken[ios]'); expect(calls.expoSends[0][0]._contentAvailable).toBe(true); + expect(calls.expoSends[0][0].priority).toBe('default'); expect(calls.expoSends[0][0].title).toBeUndefined(); expect(calls.expoSends[0][0].body).toBeUndefined(); }); diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index 8099d91c40..d7c79b47e3 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -77,7 +77,8 @@ export function toGlanceableContentState( export function buildGlanceableExpoMessages( tokens: readonly ExpoPushToken[], - snapshot: ActiveAgentsGlanceable + snapshot: ActiveAgentsGlanceable, + priority: 'default' | 'high' ): ExpoPushMessage[] { return tokens.map( ({ token }) => @@ -91,7 +92,12 @@ export function buildGlanceableExpoMessages( // `applyGlanceablePushData` path, so the push never rings or interrupts. _contentAvailable: true, sound: null, - priority: 'default', + // FCM defers normal-priority data messages while Android is + // backgrounded/Doze, so the Android wake must be `high` to reach the + // ongoing notification and widget without waiting for the app to open. + // iOS stays `default`: APNs background `content-available` pushes use + // priority 5, and Live Activity freshness rides the direct APNs path. + priority, channelId: 'active-agents', // Android collapse key = the opaque scope key, so every aggregate update // for one user+org collapses into the same ongoing notification. @@ -194,14 +200,20 @@ export async function deliverGlanceableSnapshot( // timeline through the background task while the app is not foregrounded. if (deps.isCurrent && !(await deps.isCurrent())) return; if (iosExpoTokens.length > 0) { - await deps.sendExpoPush(buildGlanceableExpoMessages(iosExpoTokens, snapshot), deps.isCurrent); + await deps.sendExpoPush( + buildGlanceableExpoMessages(iosExpoTokens, snapshot, 'default'), + deps.isCurrent + ); } if (await deps.hasAndroidOngoingToken(params.userId, params.organizationId)) { const expoTokens = await deps.listAndroidExpoTokens(params.userId, params.organizationId); if (deps.isCurrent && !(await deps.isCurrent())) return; if (expoTokens.length > 0) { - await deps.sendExpoPush(buildGlanceableExpoMessages(expoTokens, snapshot), deps.isCurrent); + await deps.sendExpoPush( + buildGlanceableExpoMessages(expoTokens, snapshot, 'high'), + deps.isCurrent + ); } } } diff --git a/services/notifications/vitest.config.mts b/services/notifications/vitest.config.mts index 7aee80578f..9197842456 100644 --- a/services/notifications/vitest.config.mts +++ b/services/notifications/vitest.config.mts @@ -8,6 +8,13 @@ export default defineConfig({ cloudflareTest({ wrangler: { configPath: './wrangler.jsonc' }, miniflare: { + // Worker unit tests must not inherit the developer-local `.dev.vars` + // `PUSH_SINK_MODE=log` that the E2E stack enables: the sink would + // replace the real Expo send and make the dispatch tests observe + // `delivered` instead of the ticket outcomes they assert. Pin the + // production default-off value here; the sink tests opt in explicitly + // through `setPushSinkModeForTesting`. + bindings: { PUSH_SINK_MODE: '' }, serviceBindings: { EVENT_SERVICE: 'event-service-stub', SELF: kCurrentWorker,