From 4020d6fe0cca57554ee282046631af4883589fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 01:39:07 +0200 Subject: [PATCH 01/15] chore(mobile): install expo-linear-gradient --- apps/mobile/package.json | 1 + pnpm-lock.yaml | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9d533c25a6..395b77c376 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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.16", "expo-linking": "55.0.16", "expo-localization": "~55.0.16", "expo-location": "55.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 590007df0b..db16a4a5be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,9 @@ importers: expo-insights: specifier: 55.0.18 version: 55.0.18(expo@55.0.27) + expo-linear-gradient: + specifier: ~55.0.16 + version: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) expo-linking: specifier: 55.0.16 version: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -12199,6 +12202,13 @@ packages: expo: '*' react: '*' + expo-linear-gradient@55.0.16: + resolution: {integrity: sha512-zzNB2Hdv+i3zIO8GPN1oU6BeqRJXQSwKgmdJXNf735Fsplxk1bjmqdYNXHamge7uHTb6M+X6aY9Mtjbrf/4S2g==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-linking@55.0.16: resolution: {integrity: sha512-O+Idexholc5rlr6Z4W/+TewTLQVnbEXztoLgLEbJwh5/A1SsJ+eq9yGGPwd4rcQ+fEBDiExr6qZxbUArnUGHfw==} peerDependencies: @@ -26254,7 +26264,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/expect@3.2.4': dependencies: @@ -26332,7 +26342,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/utils@3.2.4': dependencies: @@ -28894,6 +28904,12 @@ snapshots: expo: 55.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(bufferutil@4.1.0)(expo-router@55.0.16)(react-dom@19.2.6(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) react: 19.2.0 + expo-linear-gradient@55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): + dependencies: + expo: 55.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(bufferutil@4.1.0)(expo-router@55.0.16)(react-dom@19.2.6(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + react: 19.2.0 + react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) + expo-linking@55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0): dependencies: expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6)) From e6921022c120f16f76908c8ecd77b9178cec70b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 02:04:55 +0200 Subject: [PATCH 02/15] feat(mobile): add shimmer sweep to shared Skeleton Replace the per-instance opacity pulse with a soft horizontal gradient highlight driven by one refcounted module-level Reanimated clock shared across all Skeleton instances. Reduced motion keeps the static muted block; the component API is unchanged. --- apps/mobile/src/components/ui/skeleton.tsx | 112 +++++++++++++++++++-- 1 file changed, 101 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/components/ui/skeleton.tsx b/apps/mobile/src/components/ui/skeleton.tsx index adb93f8dd3..90b24a30f2 100644 --- a/apps/mobile/src/components/ui/skeleton.tsx +++ b/apps/mobile/src/components/ui/skeleton.tsx @@ -1,6 +1,10 @@ import { useEffect } from 'react'; +import { LinearGradient } from 'expo-linear-gradient'; +import { type LayoutChangeEvent, useColorScheme, View } from 'react-native'; import Animated, { cancelAnimation, + Easing, + makeMutable, useAnimatedStyle, useReducedMotion, useSharedValue, @@ -14,24 +18,110 @@ type SkeletonProps = { className?: string; }; +/** Soft horizontal highlight over `bg-muted` — concrete rgba, not className. */ +const LIGHT_SHIMMER = [ + 'rgba(255, 255, 255, 0)', + 'rgba(255, 255, 255, 0.45)', + 'rgba(255, 255, 255, 0)', +] as const; +const DARK_SHIMMER = [ + 'rgba(255, 255, 255, 0)', + 'rgba(255, 255, 255, 0.1)', + 'rgba(255, 255, 255, 0)', +] as const; + +const SHIMMER_DURATION_MS = 1800; + +/** LinearGradient is not NativeWind-mapped; fill the absolute overlay. */ +const GRADIENT_FILL = { flex: 1 } as const; + +/** + * One module-level Reanimated clock shared by every animating Skeleton. + * Refcounted: 0→1 starts withRepeat; 1→0 cancels. Reduced-motion instances + * never touch this clock. + */ +const shimmerProgress = makeMutable(0); +let shimmerRefCount = 0; + +function retainShimmerClock(): void { + shimmerRefCount += 1; + if (shimmerRefCount === 1) { + shimmerProgress.value = 0; + shimmerProgress.value = withRepeat( + withTiming(1, { + duration: SHIMMER_DURATION_MS, + easing: Easing.inOut(Easing.ease), + }), + -1, + false + ); + } +} + +function releaseShimmerClock(): void { + if (shimmerRefCount <= 0) { + return; + } + shimmerRefCount -= 1; + if (shimmerRefCount === 0) { + cancelAnimation(shimmerProgress); + shimmerProgress.value = 0; + } +} + export function Skeleton({ className }: Readonly) { const reducedMotion = useReducedMotion(); - const opacity = useSharedValue(0.4); + const colorScheme = useColorScheme(); + const layoutWidth = useSharedValue(0); useEffect(() => { - if (!reducedMotion) { - opacity.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true); + if (reducedMotion) { + return undefined; } - + retainShimmerClock(); return () => { - cancelAnimation(opacity); + releaseShimmerClock(); }; - }, [opacity, reducedMotion]); + }, [reducedMotion]); + + const shimmerStyle = useAnimatedStyle(() => { + const width = layoutWidth.value; + // Before first onLayout, keep the gradient invisible / off-canvas. + if (width <= 0) { + return { + opacity: 0, + transform: [{ translateX: 0 }], + }; + } + // progress 0→1 maps translateX from -width (fully left) to +width (fully right). + const translateX = -width + shimmerProgress.value * width * 2; + return { + opacity: 1, + transform: [{ translateX }], + }; + }); + + const onLayout = (event: LayoutChangeEvent) => { + layoutWidth.value = event.nativeEvent.layout.width; + }; + + // Reduced motion: static muted block at opacity 0.7 — no gradient, no clock. + if (reducedMotion) { + return ; + } - const animatedStyle = useAnimatedStyle(() => ({ - // Static muted block when reduced motion is on — no shimmer loop. - opacity: reducedMotion ? 0.7 : opacity.value, - })); + const shimmerColors = colorScheme === 'dark' ? DARK_SHIMMER : LIGHT_SHIMMER; - return ; + return ( + + + + + + ); } From 9db31d8893206d9d253f47d01d7626f0b0e60320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 02:05:11 +0200 Subject: [PATCH 03/15] fix(mobile): suppress transcript pagination header jump on older-page load The transient loading skeleton in the list header collapsed in the same update that prepended an older page; FlashList's content-position maintenance compensates prepended items but not the header height change, producing a visible jump at the page boundary. Suppress the placeholder at the render-model layer (loading maps to the omitted banner when one is visible, else hidden) and start the older-page fetch two viewports before the boundary so the prepend lands before the user reaches it. Mirror the fix in the kilo-chat message list and remove the now-unused isFetchingOlder prop end to end. --- .../agents/session-message-list-state.test.ts | 5 +-- .../agents/session-message-list-state.ts | 13 ++++--- .../agents/session-message-list.tsx | 2 +- ...ion-pagination-header-render-model.test.ts | 36 +++++++++++++++---- .../session-pagination-header-render-model.ts | 31 +++++++++------- .../agents/session-pagination-header.tsx | 13 ------- .../kilo-chat/conversation-screen.tsx | 1 - .../src/components/kilo-chat/message-list.tsx | 12 +------ 8 files changed, 61 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/components/agents/session-message-list-state.test.ts b/apps/mobile/src/components/agents/session-message-list-state.test.ts index 0c8d8f0773..0e7b8ebaa8 100644 --- a/apps/mobile/src/components/agents/session-message-list-state.test.ts +++ b/apps/mobile/src/components/agents/session-message-list-state.test.ts @@ -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, diff --git a/apps/mobile/src/components/agents/session-message-list-state.ts b/apps/mobile/src/components/agents/session-message-list-state.ts index cc1e8e93fe..eb18d81435 100644 --- a/apps/mobile/src/components/agents/session-message-list-state.ts +++ b/apps/mobile/src/components/agents/session-message-list-state.ts @@ -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' } diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index 83b2522c61..9b16d19dc5 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -17,7 +17,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 = { sessionId: string; diff --git a/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts b/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts index 5282f50bc1..f0254b37d3 100644 --- a/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts +++ b/apps/mobile/src/components/agents/session-pagination-header-render-model.test.ts @@ -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', @@ -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); diff --git a/apps/mobile/src/components/agents/session-pagination-header-render-model.ts b/apps/mobile/src/components/agents/session-pagination-header-render-model.ts index eb11df0320..2dae99cf81 100644 --- a/apps/mobile/src/components/agents/session-pagination-header-render-model.ts +++ b/apps/mobile/src/components/agents/session-pagination-header-render-model.ts @@ -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; @@ -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 { @@ -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') { @@ -69,9 +80,5 @@ export function selectSessionPaginationHeaderRenderModel( }; } - return { - kind: 'omitted', - testID: 'session-pagination-header-omitted', - text: omittedMessage(state.count), - }; + return omittedRenderModel(state.count); } diff --git a/apps/mobile/src/components/agents/session-pagination-header.tsx b/apps/mobile/src/components/agents/session-pagination-header.tsx index 010e864df7..6a2916764b 100644 --- a/apps/mobile/src/components/agents/session-pagination-header.tsx +++ b/apps/mobile/src/components/agents/session-pagination-header.tsx @@ -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, @@ -33,18 +32,6 @@ export function SessionPaginationHeader({ return null; } - if (model.kind === 'loading') { - return ( - - - - ); - } - if (model.kind === 'retryable') { return ( void; - isFetchingOlder: boolean; pendingAction: PendingAction | null; scrollToNewestRequest: number; onExecuteAction: (message: Message, groupId: string, value: ExecApprovalDecision) => void; @@ -50,7 +48,6 @@ export function MessageList({ members, botName, fetchOlder, - isFetchingOlder, pendingAction, scrollToNewestRequest, onExecuteAction, @@ -205,18 +202,11 @@ export function MessageList({ onContentSizeChange={handleContentSizeChange} scrollEventThrottle={16} onStartReached={fetchOlder} - onStartReachedThreshold={0.5} + onStartReachedThreshold={2} maintainVisibleContentPosition={{ // Start rendering from the bottom so the newest message is visible on first render. startRenderingFromBottom: true, }} - ListHeaderComponent={ - isFetchingOlder ? ( - - - - ) : null - } /> ); From 03d0e72ad3090cd2ecc2803ec7a4a7b45587e09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 02:05:25 +0200 Subject: [PATCH 04/15] fix(mobile): stabilize notifications settings loading layout Match category and master-row placeholders to the settled row geometry, and gate the master Switch and Enable-notifications CTA on a new pure deriveGateSettled predicate so a fully enabled user never sees a transient Enable card or a Switch with a transient wrong value while the push-token queries settle. --- .../src/components/notifications-screen.tsx | 56 +++++++++++++++---- .../lib/hooks/agent-push-preference.test.ts | 37 ++++++++++++ .../src/lib/hooks/agent-push-preference.ts | 30 ++++++++++ 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/components/notifications-screen.tsx b/apps/mobile/src/components/notifications-screen.tsx index 2bbc290b8f..cacd67111b 100644 --- a/apps/mobile/src/components/notifications-screen.tsx +++ b/apps/mobile/src/components/notifications-screen.tsx @@ -28,6 +28,7 @@ import { useAuth } from '@/lib/auth/auth-context'; import { applyAgentPushOptimistic, deriveAgentPushEditable, + deriveGateSettled, deriveShowEnableCta, NOTIFICATION_CATEGORY_KEYS, type NotificationCategoryKey, @@ -197,6 +198,7 @@ export function NotificationsScreen() { data: permissionGranted = false, isLoading: permissionLoading, isError: permissionError, + isFetched: permissionFetched, refetch: refetchPermission, } = useQuery({ queryKey: permissionQueryKey, @@ -209,6 +211,7 @@ export function NotificationsScreen() { const { data: deviceToken, isError: deviceTokenError, + isFetched: deviceTokenFetched, refetch: refetchDeviceToken, } = useQuery({ queryKey: deviceTokenQueryKey, @@ -219,6 +222,7 @@ export function NotificationsScreen() { const { data: pushTokens, isError: pushTokensError, + isFetched: pushTokensFetched, refetch: refetchPushTokens, } = useQuery({ ...trpc.user.getMyPushTokens.queryOptions(), @@ -228,6 +232,16 @@ export function NotificationsScreen() { const serverRegistered = deviceToken != null && (pushTokens ?? []).some(t => t.token === deviceToken); + // Each *Settled is isFetched || isError for the enabled query. Do not invent + // a "disabled → settled" mapping for deviceToken — deriveGateSettled + // short-circuits when permission is denied so disabled flags are never read. + const gateSettled = deriveGateSettled({ + permissionSettled: permissionFetched || permissionError, + permissionGranted, + pushTokensSettled: pushTokensFetched || pushTokensError, + deviceTokenSettled: deviceTokenFetched || deviceTokenError, + }); + const { data: preferences, isLoading: preferencesLoading, @@ -429,7 +443,12 @@ export function NotificationsScreen() { : 'Permission or device registration is off.'} - {permissionLoading && } + {/* Master trailing slot — first match wins: + 1. permissionLoading → skeleton + 2. permissionError → InlineRetry + 3. !gateSettled → skeleton (token queries still settling) + 4. else → real Switch (+ optional isMasterBusy spinner) */} + {permissionLoading && } {!permissionLoading && permissionError && ( void refetchPermission()} /> )} - {!permissionLoading && !permissionError && ( + {!permissionLoading && !permissionError && !gateSettled && ( + + )} + {!permissionLoading && !permissionError && gateSettled && ( <> {isMasterBusy && } - {/* Empty-state CTA: only shown when the master gate is closed. The - retryable unhappy path (a category mutation rejection) is handled - by the toggle itself — there is no terminal failure mode for - these preferences, so a non-retryable CTA is structurally absent. */} - {showEnableCta && !permissionLoading && !permissionError && ( + {/* Empty-state CTA: only shown when the master gate is closed and the + gate has settled (avoids a transient flash while token queries + resolve for an already-registered user). The retryable unhappy + path (a category mutation rejection) is handled by the toggle + itself — there is no terminal failure mode for these preferences, + so a non-retryable CTA is structurally absent. */} + {!permissionLoading && !permissionError && gateSettled && showEnableCta && ( @@ -497,11 +521,19 @@ export function NotificationsScreen() { {preferencesLoading && ( <> - - - - - + {CATEGORY_META.map(meta => ( + + + + + + + + + ))} )} {preferencesError && ( diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts index 762bd73907..2ed4f1e815 100644 --- a/apps/mobile/src/lib/hooks/agent-push-preference.test.ts +++ b/apps/mobile/src/lib/hooks/agent-push-preference.test.ts @@ -5,6 +5,7 @@ import { applyAgentPushOptimistic, DEFAULT_NOTIFICATION_PREFERENCE, deriveAgentPushEditable, + deriveGateSettled, deriveShowEnableCta, NOTIFICATION_CATEGORY_KEYS, type NotificationCategoryKey, @@ -85,6 +86,42 @@ describe('deriveShowEnableCta (empty-state CTA presence)', () => { }); }); +describe('deriveGateSettled (master gate settle flap)', () => { + // Truth table: permissionSettled, granted, pushTokensSettled, deviceTokenSettled → result + const cases: [boolean, boolean, boolean, boolean, boolean, string][] = [ + [false, false, false, false, false, 'permission loading'], + [false, true, true, true, false, 'permission loading ignores settled tokens'], + // Denied / permission-error (granted falsy): short-circuit; token flags irrelevant + [true, false, false, false, true, 'denied short-circuits unsettled tokens'], + [true, false, true, true, true, 'denied with settled tokens still true'], + // Granted: both token queries must settle (isFetched || isError each) + [true, true, false, true, false, 'granted, pushTokens in flight'], + [true, true, true, false, false, 'granted, deviceToken in flight'], + [true, true, false, false, false, 'granted, both tokens in flight'], + [true, true, true, true, true, 'granted, both tokens settled'], + ]; + + for (const [ + permissionSettled, + permissionGranted, + pushTokensSettled, + deviceTokenSettled, + expected, + label, + ] of cases) { + it(label, () => { + expect( + deriveGateSettled({ + permissionSettled, + permissionGranted, + pushTokensSettled, + deviceTokenSettled, + }) + ).toBe(expected); + }); + } +}); + describe('readAgentPushPreference', () => { it('returns the default for the requested category when the cache has no snapshot', () => { const qc = makeQueryClient(); diff --git a/apps/mobile/src/lib/hooks/agent-push-preference.ts b/apps/mobile/src/lib/hooks/agent-push-preference.ts index 00fe8e71ab..3458073949 100644 --- a/apps/mobile/src/lib/hooks/agent-push-preference.ts +++ b/apps/mobile/src/lib/hooks/agent-push-preference.ts @@ -63,6 +63,36 @@ export function deriveShowEnableCta(notificationsEnabled: boolean): boolean { return !notificationsEnabled; } +type GateSettledArgs = Readonly<{ + permissionSettled: boolean; + permissionGranted: boolean; + pushTokensSettled: boolean; + deviceTokenSettled: boolean; +}>; + +/** + * Whether the master push gate has enough settled inputs to render the real + * Switch / Enable CTA without a transient wrong value or layout flap. + * + * Short-circuits when permission is denied (or errored → granted is falsy): + * token queries are irrelevant to a closed gate, and `deviceToken` is + * `enabled: permissionGranted` so it would never settle while denied. + */ +export function deriveGateSettled({ + permissionSettled, + permissionGranted, + pushTokensSettled, + deviceTokenSettled, +}: GateSettledArgs): boolean { + if (!permissionSettled) { + return false; + } + if (!permissionGranted) { + return true; + } + return pushTokensSettled && deviceTokenSettled; +} + /** Map the legacy single-key cache shape to the new per-category shape. */ function readFromSnapshot(snapshot: NotificationPreferencesSnapshot): NotificationPreferences { if (!snapshot) { From 3ccce72f3857c84b75daa9926e11c28cc07e2649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 02:46:49 +0200 Subject: [PATCH 05/15] fix(mobile): preserve and control agents session-list scroll --- .../agents/session-list-content.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/components/agents/session-list-content.tsx b/apps/mobile/src/components/agents/session-list-content.tsx index 646508215a..d82c88d62c 100644 --- a/apps/mobile/src/components/agents/session-list-content.tsx +++ b/apps/mobile/src/components/agents/session-list-content.tsx @@ -1,6 +1,7 @@ +import { useScrollToTop } from '@react-navigation/native'; import { useFocusEffect } from 'expo-router'; import { Bot, Plus } from 'lucide-react-native'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Platform, @@ -78,6 +79,9 @@ export function AgentSessionListContent({ onCreateSession, sortBy, }: Readonly) { + const listRef = useRef>(null); + useScrollToTop(listRef); + const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); const { fontScale } = useWindowDimensions(); @@ -127,23 +131,18 @@ export function AgentSessionListContent({ ); // The tabs navigator uses `freezeOnBlur`, so while the session detail screen - // is pushed the Agents list is frozen. react-freeze reveals the previously - // rendered (cached) cells on return WITHOUT re-running them, so the attention - // store's `useSyncExternalStore` subscription does not re-render the list and - // the detail-screen mount ack is not reflected. Snapshot the attention - // revision only when the tab (re)gains focus, via `useFocusEffect`, which - // fires reliably after unfreeze. Keying the list on that focus snapshot - // remounts it exactly when an ack/reconcile happened while the list was away - // (e.g. returning from a session that was just opened) so frozen cells re-read - // the ack store — while a revision bump for some unrelated session that occurs - // *during* browsing does not touch the snapshot, so scroll is preserved. + // is pushed the Agents list is frozen. On return, each row re-reads the ack + // store via its own `useSyncExternalStore` subscription + // (`useSessionAttentionRevision`). Snapshot the attention revision only when + // the tab (re)gains focus via `useFocusEffect` (fires after unfreeze) and + // pass it as `extraData` so visible cells re-render without remounting the + // list — preserving scroll. Remount only on sort change (`key={sortBy}`). const [attentionFocusRevision, setAttentionFocusRevision] = useState(getRevisionSnapshot); useFocusEffect( useCallback(() => { setAttentionFocusRevision(getRevisionSnapshot()); }, []) ); - const attentionListKey = `${sortBy}:${attentionFocusRevision}`; const handleRefresh = useCallback(() => { void (async () => { @@ -252,12 +251,13 @@ export function AgentSessionListContent({ return ( - key={attentionListKey} + ref={listRef} + key={sortBy} sections={sections} renderItem={renderItem} renderSectionHeader={renderSectionHeader} keyExtractor={keyExtractor} - extraData={attentionListKey} + extraData={attentionFocusRevision} ListEmptyComponent={emptyComponent} ListFooterComponent={ isFetchingNextPage ? ( From 88f59ba402f0df9ca31821114d361d7a55db0e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 26 Jul 2026 02:47:02 +0200 Subject: [PATCH 06/15] feat(mobile): show session origin platform icon in agents list --- .../agents/session-platform-icon.test.ts | 54 ++++++++ .../agents/session-platform-icon.tsx | 66 +++++++++ .../session-row-accessibility-label.test.ts | 71 ++++++++++ .../agents/session-row-accessibility-label.ts | 16 ++- .../src/components/agents/session-row.tsx | 27 ++++ .../src/components/icons/slack-icon.tsx | 14 +- .../ui/session-row-eyebrow-right.test.ts | 130 ++++++++++++++++-- .../ui/session-row-eyebrow-right.ts | 29 ++-- apps/mobile/src/components/ui/session-row.tsx | 41 +++++- 9 files changed, 418 insertions(+), 30 deletions(-) create mode 100644 apps/mobile/src/components/agents/session-platform-icon.test.ts create mode 100644 apps/mobile/src/components/agents/session-platform-icon.tsx diff --git a/apps/mobile/src/components/agents/session-platform-icon.test.ts b/apps/mobile/src/components/agents/session-platform-icon.test.ts new file mode 100644 index 0000000000..bc82180410 --- /dev/null +++ b/apps/mobile/src/components/agents/session-platform-icon.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { sessionPlatformIconKind } from './session-platform-icon'; + +// The module under test is a .tsx that imports Lucide / brand icons (RN). +// Stub those so the pure mapper can be unit-tested in the node environment. +// (`vi.mock` calls are hoisted above the import by vitest.) +vi.mock('lucide-react-native', () => ({ + Cloud: () => null, + Code: () => null, + Terminal: () => null, +})); +vi.mock('@/components/icons/github-icon', () => ({ + GitHubIcon: () => null, +})); +vi.mock('@/components/icons/slack-icon', () => ({ + SlackIcon: () => null, +})); + +describe('sessionPlatformIconKind', () => { + it('maps cloud-agent and cloud-agent-web to cloud', () => { + expect(sessionPlatformIconKind('cloud-agent')).toBe('cloud'); + expect(sessionPlatformIconKind('cloud-agent-web')).toBe('cloud'); + }); + + it('maps cli to terminal', () => { + expect(sessionPlatformIconKind('cli')).toBe('terminal'); + }); + + it('maps vscode and agent-manager to code', () => { + expect(sessionPlatformIconKind('vscode')).toBe('code'); + expect(sessionPlatformIconKind('agent-manager')).toBe('code'); + }); + + it('maps slack to slack', () => { + expect(sessionPlatformIconKind('slack')).toBe('slack'); + }); + + it('maps github to github', () => { + expect(sessionPlatformIconKind('github')).toBe('github'); + }); + + it('returns null for unmapped and absent platforms', () => { + expect(sessionPlatformIconKind('unknown')).toBeNull(); + expect(sessionPlatformIconKind('other')).toBeNull(); + expect(sessionPlatformIconKind('gastown')).toBeNull(); + expect(sessionPlatformIconKind('linear')).toBeNull(); + expect(sessionPlatformIconKind('app-builder')).toBeNull(); + expect(sessionPlatformIconKind('agent-builder')).toBeNull(); + expect(sessionPlatformIconKind(null)).toBeNull(); + expect(sessionPlatformIconKind(undefined)).toBeNull(); + expect(sessionPlatformIconKind('')).toBeNull(); + }); +}); diff --git a/apps/mobile/src/components/agents/session-platform-icon.tsx b/apps/mobile/src/components/agents/session-platform-icon.tsx new file mode 100644 index 0000000000..4cf11d7aa6 --- /dev/null +++ b/apps/mobile/src/components/agents/session-platform-icon.tsx @@ -0,0 +1,66 @@ +import { type ReactElement } from 'react'; +import { Cloud, Code, Terminal } from 'lucide-react-native'; + +import { GitHubIcon } from '@/components/icons/github-icon'; +import { SlackIcon } from '@/components/icons/slack-icon'; + +type SessionPlatformIconKind = 'cloud' | 'terminal' | 'code' | 'slack' | 'github'; + +const PLATFORM_TO_KIND: Readonly> = { + 'cloud-agent': 'cloud', + 'cloud-agent-web': 'cloud', + cli: 'terminal', + vscode: 'code', + 'agent-manager': 'code', + slack: 'slack', + github: 'github', +}; + +/** + * Map a backend `created_on_platform` string to a list/detail icon kind. + * Unmapped / absent platforms return null so callers render nothing + * (subtle-by-design — no wrong or generic glyph). + */ +export function sessionPlatformIconKind( + platform: string | null | undefined +): SessionPlatformIconKind | null { + if (platform == null || platform === '') { + return null; + } + return PLATFORM_TO_KIND[platform] ?? null; +} + +type SessionPlatformIconProps = Readonly<{ + platform: string | null | undefined; + size: number; + color: string; +}>; + +/** + * Pure renderer for the session-origin platform glyph. Returns null when + * the platform is unmapped. No wrapper View, testID, or a11y props — + * call sites own those. + */ +export function SessionPlatformIcon({ + platform, + size, + color, +}: SessionPlatformIconProps): ReactElement | null { + const kind = sessionPlatformIconKind(platform); + if (kind === 'cloud') { + return ; + } + if (kind === 'terminal') { + return ; + } + if (kind === 'code') { + return ; + } + if (kind === 'slack') { + return ; + } + if (kind === 'github') { + return ; + } + return null; +} diff --git a/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts b/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts index c45f295d14..25061cdf03 100644 --- a/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts +++ b/apps/mobile/src/components/agents/session-row-accessibility-label.test.ts @@ -236,4 +236,75 @@ describe('sessionRowAccessibilityLabel', () => { ).toBe('Orphan, 1 day ago'); }); }); + + describe('platform segment (title → needs input → badge → meta → platform)', () => { + it('repo-badged row with icon: appends from CLOUD AGENT', () => { + // Shape of a repo-badged mapped row (e.g. seed session 09). Fixed + // literal inputs only — never derive from live timestamps. + expect( + sessionRowAccessibilityLabel({ + title: 'X', + needsInput: false, + badge: 'CLOUD', + meta: '9 hours ago', + platform: 'cloud-agent', + }) + ).toBe('X, CLOUD, 9 hours ago, from CLOUD AGENT'); + }); + + it('platform-badged row without repo: caller omits platform (label unchanged)', () => { + // Shape of seed session 08: badge already speaks CLI; caller does + // not pass platform. Gating lives in the caller. + expect( + sessionRowAccessibilityLabel({ + title: 'X', + needsInput: false, + badge: 'CLI', + meta: 'cost 1 dollar 10 cents, 8 hours ago', + }) + ).toBe('X, CLI, cost 1 dollar 10 cents, 8 hours ago'); + }); + + it('needs-input row: no platform segment', () => { + expect( + sessionRowAccessibilityLabel({ + title: 'X', + needsInput: true, + badge: 'CLOUD', + meta: null, + }) + ).toBe('X, needs input, CLOUD'); + }); + + it('unmapped / absent platform: no segment appended', () => { + // Caller passes nothing when kind is null. + expect( + sessionRowAccessibilityLabel({ + title: 'X', + needsInput: false, + badge: 'GASTOWN', + meta: '1 hour ago', + }) + ).toBe('X, GASTOWN, 1 hour ago'); + // Direct call with null/undefined also appends nothing. + expect( + sessionRowAccessibilityLabel({ + title: 'X', + needsInput: false, + badge: 'GASTOWN', + meta: '1 hour ago', + platform: null, + }) + ).toBe('X, GASTOWN, 1 hour ago'); + expect( + sessionRowAccessibilityLabel({ + title: 'X', + needsInput: false, + badge: 'GASTOWN', + meta: '1 hour ago', + platform: undefined, + }) + ).toBe('X, GASTOWN, 1 hour ago'); + }); + }); }); diff --git a/apps/mobile/src/components/agents/session-row-accessibility-label.ts b/apps/mobile/src/components/agents/session-row-accessibility-label.ts index a513ef2509..06b3702a8c 100644 --- a/apps/mobile/src/components/agents/session-row-accessibility-label.ts +++ b/apps/mobile/src/components/agents/session-row-accessibility-label.ts @@ -1,3 +1,4 @@ +import { platformLabel } from '@/lib/platform-label'; import { parseTimestamp, timeAgo } from '@/lib/utils'; /** @@ -106,6 +107,14 @@ type SessionRowAccessibilityLabelInputs = { * no meta). */ meta?: string | null; + /** + * Backend `created_on_platform` string. When truthy, appended as the + * FINAL spoken part (`from ${platformLabel(platform)}`). The caller + * gates this: only pass when an icon is rendered, not needs-input, and + * the eyebrow badge is a repo name (otherwise the badge already speaks + * the platform label and appending would be redundant). + */ + platform?: string | null; }; /** @@ -113,7 +122,8 @@ type SessionRowAccessibilityLabelInputs = { * content in the order the row renders parts: title, then `needs input` * (only when the needs-input eyebrow is shown), then the always-visible * left-eyebrow badge, then the meta text (only when the row visibly - * renders meta). Empty parts are skipped; the order is fixed. + * renders meta), then an optional platform origin (`from