Load complete chat history when reaching the top (#2481)

* fix(chat): load older timeline history consistently

Pagination depended on scroll events, so short or compacted initial pages could never request older history. Reevaluate edge visibility as layout and history change, and standardize loading indicators on the canonical app spinner.

* fix(chat): keep older history loading reliably

Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops.
This commit is contained in:
Mohamed Boudra
2026-07-27 18:21:07 +08:00
committed by GitHub
parent 80c8a08393
commit b97d6d13f3
38 changed files with 609 additions and 177 deletions
@@ -1,5 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -98,7 +99,7 @@ export function DictationControls({
</Pressable>
{actionsDisabled ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foreground} />
<LoadingSpinner size="small" color={theme.colors.foreground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
@@ -221,7 +222,7 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? (
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed ? (
@@ -1,7 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, X, XCircle } from "lucide-react-native";
@@ -69,7 +70,7 @@ export function DownloadToast() {
<View style={containerStyle} pointerEvents="box-none">
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
<LoadingSpinner size="small" color={theme.colors.foreground} />
) : null}
{activeDownload.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObj
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
ListRenderItemInfo,
Pressable,
@@ -12,7 +11,7 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
@@ -24,6 +23,7 @@ import {
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
@@ -42,6 +42,11 @@ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "size" },
];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -163,7 +168,9 @@ function TreeRowItem({
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
}
if (loading) return <ActivityIndicator size="small" />;
if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
@@ -549,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
if (showInitialLoading) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
</View>
);
+7 -4
View File
@@ -1,9 +1,9 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
Image,
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type LayoutChangeEvent,
StyleProp,
@@ -172,6 +172,7 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -865,7 +866,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
return (
<View style={frameStyle}>
<View style={stateSurfaceStyle}>
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
{loadState.status === "loading" ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
{loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")}
@@ -1004,7 +1007,7 @@ function AssistantMarkdownImage({
if (query.isLoading || dataImageQuery.isLoading) {
return (
<View style={stateFrameStyle}>
<ActivityIndicator size="small" />
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
);
}
@@ -2244,7 +2247,7 @@ export const CompactionMarker = memo(function CompactionMarker({
<View style={compactionStylesheet.line} />
<View style={compactionStylesheet.label}>
{status === "loading" ? (
<ActivityIndicator size="small" color="#a1a1aa" />
<LoadingSpinner size="small" color="#a1a1aa" />
) : (
<Scissors size={12} color="#a1a1aa" />
)}
@@ -3,13 +3,7 @@ import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-re
import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
Text,
View,
} from "react-native";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AdaptiveModalSheet,
@@ -365,7 +359,7 @@ function DiagnosticSubSheet({
body = (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
</SurfaceCard>
@@ -504,7 +498,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
return (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
</View>
);
@@ -1,12 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native";
@@ -581,7 +575,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
testID="question-form-dismiss"
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
@@ -599,7 +593,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
testID="question-form-primary-action"
>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
) : (
<View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} />
@@ -1,6 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Pressable, View } from "react-native";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout";
@@ -75,7 +76,7 @@ export function RealtimeVoiceOverlay({
style={stopButtonStyle}
>
{isSwitching ? (
<ActivityIndicator size="small" color={theme.colors.palette.white} />
<LoadingSpinner size="small" color={theme.colors.palette.white} />
) : (
<Square
size={theme.iconSize.lg}
@@ -1,8 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
Pressable,
ActivityIndicator,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
@@ -143,7 +143,7 @@ const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedPlus = withUnistyles(Plus);
@@ -746,7 +746,7 @@ function ProjectLeadingVisualStatus({
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
@@ -855,7 +855,7 @@ function NewWorktreeButton({
>
{({ hovered, pressed }) =>
loading ? (
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedPlus
size={15}
@@ -1,13 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
Text,
View,
type GestureResponderEvent,
type ViewStyle,
} from "react-native";
import { Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleAlert,
@@ -54,7 +48,7 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
@@ -207,7 +201,7 @@ function WorkspaceStatusIndicator({
if (loading) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
@@ -1,11 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
@@ -836,7 +831,7 @@ export function TerminalPane({
{showLoadingOverlay ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
+3 -2
View File
@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
default as React,
useCallback,
@@ -8,7 +9,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
@@ -44,7 +45,7 @@ function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps)
if (loading) {
return (
<View>
<ActivityIndicator size="small" color={iconColor} />
<LoadingSpinner size="small" color={iconColor} />
</View>
);
}
@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
type ComponentProps,
createContext,
@@ -14,7 +15,6 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
Modal,
Platform,
@@ -630,7 +630,7 @@ function resolveLeadingContent(input: {
successColor: string;
}): ReactElement | null {
if (input.isPending) {
return <ActivityIndicator size={16} color={input.pendingColor} />;
return <LoadingSpinner size={16} color={input.pendingColor} />;
}
if (input.isSuccess) {
return <CheckCircle size={16} color={input.successColor} />;
@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
createContext,
useCallback,
@@ -14,7 +15,6 @@ import {
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Modal,
Pressable,
Text,
@@ -717,7 +717,7 @@ function resolveDropdownItemLeadingContent(input: {
}): ReactElement | null {
const { isPending, isSuccess, leading, theme } = input;
if (isPending) {
return <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
return <LoadingSpinner size={16} color={theme.colors.foregroundMuted} />;
}
if (isSuccess) {
return <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
@@ -3,8 +3,9 @@ import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps {
color: string;
size?: ActivityIndicatorProps["size"];
style?: ActivityIndicatorProps["style"];
}
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
return <ActivityIndicator size={size} color={color} />;
export function LoadingSpinner({ color, size = "small", style }: LoadingSpinnerProps) {
return <ActivityIndicator size={size} color={color} style={style} />;
}