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 12:21:07 +02:00
committed by GitHub
parent 80c8a08393
commit b97d6d13f3
38 changed files with 609 additions and 177 deletions

View File

@@ -1,7 +1,10 @@
import { test } from "./fixtures";
import {
expectLoadedTimelineDoesNotScroll,
expectTimelinePromptNotMounted,
expectTimelinePromptVisible,
holdNextOlderTimelinePage,
makeLoadedTimelineFitViewport,
openAgentTimeline,
scrollTimelineUntilOlderHistoryIsReachable,
seedLongMockAgentTimeline,
@@ -25,4 +28,21 @@ test.describe("Agent timeline pagination", () => {
await agent.cleanup();
}
});
test("loads older history when the initial page does not fill the viewport", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedLongMockAgentTimeline({ turns: 30 });
try {
await makeLoadedTimelineFitViewport(page);
const olderPage = await holdNextOlderTimelinePage(page, agent);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await expectLoadedTimelineDoesNotScroll(page);
await olderPage.expectLoading();
olderPage.release();
await expectTimelinePromptVisible(page, agent.oldestPrompt);
} finally {
await agent.cleanup();
}
});
});

View File

@@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate {
waitForForwardedResponse(): Promise<void>;
}
export interface AgentTimelineResponseGate {
release(): void;
waitForDelayedResponse(): Promise<void>;
}
function parseWebSocketJson(message: WebSocketMessage): unknown {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
@@ -118,3 +123,53 @@ export async function delayCreatedAgentInitialTailResponse(
waitForForwardedResponse: () => forwardedResponse,
};
}
export async function delayAgentOlderTimelineResponse(
page: Page,
agentId: string,
): Promise<AgentTimelineResponseGate> {
let releaseRequested = false;
let delayedResponseSeen = false;
const delayedForwards: Array<() => void> = [];
let resolveDelayedResponse: (() => void) | null = null;
const delayedResponse = new Promise<void>((resolve) => {
resolveDelayedResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
!delayedResponseSeen &&
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId &&
payload.direction === "before"
) {
delayedResponseSeen = true;
resolveDelayedResponse?.();
if (releaseRequested) {
ws.send(message);
return;
}
delayedForwards.push(() => ws.send(message));
return;
}
ws.send(message);
});
});
return {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForDelayedResponse: () => delayedResponse,
};
}

View File

@@ -1,5 +1,9 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import {
delayAgentOlderTimelineResponse,
type AgentTimelineResponseGate,
} from "./agent-timeline-gate";
interface LongTimelineAgentOptions {
turns: number;
@@ -53,6 +57,39 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string)
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
}
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 1280, height: 8_000 });
}
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect
.poll(async () =>
scroll.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error("Agent chat scroll element is not an HTMLElement");
}
return element.scrollHeight <= element.clientHeight;
}),
)
.toBe(true);
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
return {
...gate,
async expectLoading() {
await gate.waitForDelayedResponse();
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
},
};
}
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await scroll.hover();

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
} from "./history-start-pagination";
const visibleHistoryStart = {
distanceFromHistoryStart: 0,
hasOlderHistory: true,
isLoadingOlderHistory: false,
isReady: true,
progressKey: "epoch-1:20",
};
describe("history start pagination", () => {
it("loads once for each authoritative history cursor", () => {
const initial = createHistoryStartPaginationState();
const first = evaluateHistoryStartPagination(initial, visibleHistoryStart);
const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart);
const nextPage = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
progressKey: "epoch-1:10",
});
expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([
true,
false,
true,
]);
});
it("allows the same revision again after the user leaves the history edge", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const away = evaluateHistoryStartPagination(first.state, {
...visibleHistoryStart,
distanceFromHistoryStart: 200,
});
const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart);
expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]);
});
it("re-arms the same cursor when the user makes another upward edge gesture", () => {
const first = evaluateHistoryStartPagination(
createHistoryStartPaginationState(),
visibleHistoryStart,
);
const retried = evaluateHistoryStartPagination(
rearmHistoryStartPagination(first.state),
visibleHistoryStart,
);
expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]);
});
it("waits while history loading is unavailable or already active", () => {
const state = createHistoryStartPaginationState();
expect([
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, hasOlderHistory: false })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isLoadingOlderHistory: true })
.shouldLoad,
evaluateHistoryStartPagination(state, { ...visibleHistoryStart, progressKey: null })
.shouldLoad,
]).toEqual([false, false, false, false]);
});
});

View File

@@ -0,0 +1,45 @@
export const HISTORY_START_THRESHOLD_PX = 96;
export interface HistoryStartPaginationState {
requestedProgressKey: string | null;
}
export function createHistoryStartPaginationState(): HistoryStartPaginationState {
return { requestedProgressKey: null };
}
export function rearmHistoryStartPagination(
_state: HistoryStartPaginationState,
): HistoryStartPaginationState {
return createHistoryStartPaginationState();
}
export function evaluateHistoryStartPagination(
state: HistoryStartPaginationState,
input: {
distanceFromHistoryStart: number;
hasOlderHistory: boolean;
isLoadingOlderHistory: boolean;
isReady: boolean;
progressKey: string | null;
},
): { state: HistoryStartPaginationState; shouldLoad: boolean } {
if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) {
return { state: createHistoryStartPaginationState(), shouldLoad: false };
}
if (
!input.isReady ||
!input.hasOlderHistory ||
input.isLoadingOlderHistory ||
input.progressKey === null
) {
return { state, shouldLoad: false };
}
if (state.requestedProgressKey === input.progressKey) {
return { state, shouldLoad: false };
}
return {
state: { requestedProgressKey: input.progressKey },
shouldLoad: true,
};
}

View File

@@ -9,7 +9,6 @@ import {
} from "react";
import {
FlatList,
ActivityIndicator,
Keyboard,
Platform,
View,
@@ -17,8 +16,12 @@ import {
type ListRenderItemInfo,
type NativeScrollEvent,
type NativeSyntheticEvent,
type ViewStyle,
} from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
@@ -27,12 +30,28 @@ import {
isNearBottomForStreamRenderStrategy,
resolveBottomAnchorTransportBehavior,
} from "./strategy";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
} from "./history-start-pagination";
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const historyStartSlotStyle: ViewStyle = {
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
};
interface HistoryRowDisplayVariants {
regular?: StreamItem;
@@ -72,6 +91,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
@@ -95,6 +115,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const historyItems = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -123,6 +144,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
),
[displayStateHistoryRows, historyRowRevision?.contentById],
);
const evaluateHistoryStart = useStableEvent(() => {
const metrics = streamViewportMetricsRef.current;
const hasMeasuredViewport =
metrics.viewportMeasuredForKey === metrics.containerKey &&
metrics.contentMeasuredForKey === metrics.containerKey;
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && hasMeasuredViewport,
progressKey: olderHistoryProgressKey,
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
});
const clearNativeViewportSettling = useCallback(() => {
if (nativeViewportSettlingFrameIdRef.current !== null) {
@@ -222,14 +260,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => {
const keyboardEvents = [
@@ -308,17 +348,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const nearBottom = isScrollEventNearBottom(event);
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
streamViewportMetricsRef.current.contentHeight -
streamViewportMetricsRef.current.viewportHeight -
contentOffset.y;
if (
historyStartReadyRef.current &&
hasOlderHistory &&
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
evaluateHistoryStart();
if (
!isUserScrollActiveRef.current &&
@@ -336,9 +366,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const handleScrollBeginDrag = useStableEvent(() => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
// Defer drag end so momentum can take ownership, but capture the terminal
@@ -395,6 +431,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
previousViewportHeight,
viewportHeight,
});
evaluateHistoryStart();
});
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
@@ -410,8 +447,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
previousContentHeight,
contentHeight: nextContentHeight,
});
evaluateHistoryStart();
});
useEffect(() => {
evaluateHistoryStart();
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyItems);
@@ -451,15 +493,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
]);
const historyFooterContent = useMemo(() => {
if (!isLoadingOlderHistory) {
if (!hasOlderHistory && !isLoadingOlderHistory) {
return null;
}
return (
<View testID="load-older-history-spinner">
<ActivityIndicator size="small" />
<View
style={historyStartSlotStyle}
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
>
{isLoadingOlderHistory ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
</View>
);
}, [isLoadingOlderHistory]);
}, [hasOlderHistory, isLoadingOlderHistory]);
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
// data or the live header changes, preserving the row identities above.

View File

@@ -133,6 +133,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -176,6 +177,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -231,6 +233,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -314,6 +317,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -323,10 +327,6 @@ describe("createWebStreamStrategy", () => {
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
@@ -335,11 +335,109 @@ describe("createWebStreamStrategy", () => {
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
});
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
HTMLElement.prototype.scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
});
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
const renderInput = {
agentId: "agent",
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: false,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
}),
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
hasOlderHistory: true,
olderHistoryProgressKey: "epoch-1:20",
}),
);
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(onNearHistoryStart).not.toHaveBeenCalled();
});
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
@@ -378,6 +476,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -485,6 +584,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -581,6 +681,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -679,6 +780,7 @@ describe("createWebStreamStrategy", () => {
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,

View File

@@ -8,11 +8,19 @@ import React, {
useRef,
useState,
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { Theme } from "@/styles/theme";
import { estimateStreamItemHeight } from "./web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import { createStreamStrategy } from "./strategy";
import {
createHistoryStartPaginationState,
evaluateHistoryStartPagination,
rearmHistoryStartPagination,
} from "./history-start-pagination";
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
@@ -25,7 +33,11 @@ const USER_SCROLL_DELTA_EPSILON = 1;
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const historyStartSlotStyle: CSSProperties = {
display: "flex",
@@ -107,6 +119,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
olderHistoryProgressKey,
scrollEnabled,
isMobileBreakpoint,
} = props;
@@ -133,6 +146,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
const {
renderHistoryVirtualizedRow,
@@ -173,6 +187,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}, [rowVirtualizer]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
const evaluateHistoryStart = useStableEvent(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
const bottomAnchorSettled =
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
distanceFromHistoryStart: scrollContainer.scrollTop,
hasOlderHistory,
isLoadingOlderHistory,
isReady: historyStartReadyRef.current && bottomAnchorSettled,
progressKey: olderHistoryProgressKey,
});
historyStartPaginationStateRef.current = result.state;
if (result.shouldLoad) {
onNearHistoryStart();
}
});
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -231,8 +264,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollElementToBottom(scrollContainer, behavior);
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
syncNearBottom(scrollContainer, onNearBottomChange);
evaluateHistoryStart();
},
[onNearBottomChange],
[evaluateHistoryStart, onNearBottomChange],
);
const scheduleStickToBottom = useCallback(() => {
@@ -297,24 +331,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
if (
historyStartReadyRef.current &&
hasOlderHistory &&
currentScrollTop <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
evaluateHistoryStart();
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
useEffect(() => {
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
});
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
};
}, [props.agentId]);
}, [evaluateHistoryStart, props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
@@ -370,7 +400,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
updateScrollMetrics();
evaluateHistoryStart();
}, [
evaluateHistoryStart,
hasOlderHistory,
isLoadingOlderHistory,
olderHistoryProgressKey,
segments.historyMounted.length,
segments.historyVirtualized.length,
segments.liveHead.length,
@@ -386,8 +421,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
updateScrollMetrics();
evaluateHistoryStart();
const observer = new ResizeObserver(() => {
updateScrollMetrics();
evaluateHistoryStart();
if (!followOutputRef.current) {
return;
}
@@ -400,7 +437,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect();
};
}, [scheduleStickToBottom, updateScrollMetrics]);
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -410,8 +447,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
evaluateHistoryStart();
}
};
const handlePointerDown = () => {
@@ -434,8 +477,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const previousTouchY = lastTouchClientYRef.current;
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
);
}
pendingUserScrollUpIntentRef.current = true;
cancelPendingStickToBottom();
evaluateHistoryStart();
}
lastTouchClientYRef.current = touch.clientY;
};
@@ -464,7 +513,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [cancelPendingStickToBottom, handleDomScroll]);
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -546,15 +595,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
if (!isLoadingOlderHistory) {
if (!hasOlderHistory && !isLoadingOlderHistory) {
return null;
}
return (
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
<ActivityIndicator size="small" />
<div
style={historyStartSlotStyle}
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
>
{isLoadingOlderHistory ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
</div>
);
}, [isLoadingOlderHistory]);
}, [hasOlderHistory, isLoadingOlderHistory]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&

View File

@@ -72,6 +72,7 @@ export interface StreamRenderInput {
onNearHistoryStart: () => void;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
olderHistoryProgressKey: string | null;
scrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
forwardRef,
memo,
@@ -17,7 +18,6 @@ import {
Text,
Pressable,
Platform,
ActivityIndicator,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
@@ -247,6 +247,7 @@ export interface AgentStreamViewProps {
historyPagination?: {
hasOlder: boolean;
isLoadingOlder: boolean;
progressKey: string | null;
onLoadOlder: () => void;
};
}
@@ -388,10 +389,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
toast,
});
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
? {
isLoadingOlder: historyPagination.isLoadingOlder,
hasOlder: historyPagination.hasOlder,
progressKey: historyPagination.progressKey,
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
@@ -1029,6 +1031,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
@@ -1138,6 +1141,7 @@ function historyPaginationPropsEqual(
return (
left?.hasOlder === right?.hasOlder &&
left?.isLoadingOlder === right?.isLoadingOlder &&
left?.progressKey === right?.progressKey &&
left?.onLoadOlder === right?.onLoadOlder
);
}
@@ -1194,7 +1198,7 @@ function ToolCallSlot({
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCheckIcon = withUnistyles(Check);
const ThemedXIcon = withUnistyles(X);
@@ -1241,7 +1245,7 @@ function PermissionActionButton({
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
<ThemedLoadingSpinner size="small" uniProps={colorMapping} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} uniProps={colorMapping} />

View File

@@ -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 ? (

View File

@@ -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} />

View File

@@ -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>
);

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" />
)}

View File

@@ -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>
);

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>

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>
);
}

View File

@@ -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} />;

View File

@@ -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]} />;

View File

@@ -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} />;
}

View File

@@ -1,8 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Pressable,
Text,
ActivityIndicator,
StyleSheet as RNStyleSheet,
type PressableStateCallbackType,
} from "react-native";
@@ -884,7 +884,7 @@ function ComposerCancelButton({
? t("composer.cancel.cancelingAgent")
: t("composer.cancel.stopAgent");
const icon = isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
<LoadingSpinner size="small" color="white" />
) : (
<Square size={buttonIconSize} color="white" fill="white" />
);
@@ -984,7 +984,7 @@ function ComposerVoiceModeButton({
const renderTriggerContent = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
if (isVoiceSwitching) {
return <ActivityIndicator size="small" color="white" />;
return <LoadingSpinner size="small" color="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;

View File

@@ -1,10 +1,10 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
TextInput,
Pressable,
Platform,
ActivityIndicator,
useWindowDimensions,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
@@ -445,7 +445,7 @@ function SendButtonContent({
buttonIconSize: number;
}) {
if (isSubmitLoading) {
return <ThemedActivityIndicator size="small" uniProps={iconAccentForegroundMapping} />;
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
}
if (submitIcon === "return") {
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
@@ -2000,7 +2000,7 @@ const ThemedMic = withUnistyles(Mic);
const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });

View File

@@ -1,5 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import { Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -421,7 +422,7 @@ export function LocalDaemonSection() {
>
{isLoading || isLoadingSettings ? (
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
</View>
) : (
<>

View File

@@ -1,15 +1,22 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
import { Image, Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useQuery } from "@tanstack/react-query";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { RotateCw, Copy, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useState } from "react";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
type PairingViewState =
| { tag: "loading" }
@@ -184,7 +191,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
if (viewState.tag === "loading") {
return (
<View style={styles.centered}>
<ActivityIndicator size="small" />
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.hint}>{labels.loadingOffer}</Text>
</View>
);
@@ -240,7 +247,7 @@ function PairDeviceQrContent(props: {
if (props.qrQuery.isError) {
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
}
return <ActivityIndicator size="small" />;
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
const styles = StyleSheet.create((theme) => ({

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
useCallback,
useEffect,
@@ -8,14 +9,8 @@ import React, {
} from "react";
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
import type { FileVersion } from "@getpaseo/protocol/messages";
import {
ActivityIndicator,
Image as RNImage,
ScrollView as RNScrollView,
Text,
View,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -44,6 +39,12 @@ import { FileEditorModel, type FileEditorFile } from "./editor/model";
import { FileEditorView } from "./editor/view";
import { confirmDialog } from "@/utils/confirm-dialog";
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface CodeLineProps {
tokens: HighlightToken[];
@@ -264,7 +265,7 @@ function FilePreviewBody({
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);
@@ -346,7 +347,7 @@ function FilePreviewBody({
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
</View>
);

View File

@@ -1,11 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useMemo } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
} from "react-native";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -146,7 +141,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
accessibilityLabel={gitActions.primary.label}
>
{gitActions.primary.status === "pending" ? (
<ActivityIndicator
<LoadingSpinner
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}

View File

@@ -14,7 +14,6 @@ import { DiffStat } from "@/components/diff-stat";
import {
View,
Text,
ActivityIndicator,
Pressable,
FlatList,
type LayoutChangeEvent,
@@ -1320,7 +1319,7 @@ type PressableStyleFn = (
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
@@ -1693,7 +1692,6 @@ export function DiffOptionsMenu({
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
const EMPTY_PATH_LIST: string[] = [];
@@ -1779,7 +1777,7 @@ function DiffBodyContent({
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
</View>
);
@@ -1801,7 +1799,7 @@ function DiffBodyContent({
if (isDiffLoading) {
return (
<View style={styles.loadingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
</View>
);
}

View File

@@ -77,6 +77,10 @@ export function useLoadOlderAgentHistory({
useSessionStore((state) =>
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
) === true;
const progressKey = useSessionStore((state) => {
const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId);
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
});
const setOlderFetchInFlight = useSessionStore(
(state) => state.setAgentTimelineOlderFetchInFlight,
);
@@ -116,6 +120,7 @@ export function useLoadOlderAgentHistory({
return {
isLoadingOlder,
hasOlder,
progressKey,
loadOlder,
};
}

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { TFunction } from "i18next";
import { SquarePen } from "lucide-react-native";
@@ -11,7 +12,7 @@ import React, {
useSyncExternalStore,
} from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -215,7 +216,7 @@ function renderChatAgentNonReadyView(args: {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
</View>
);
@@ -716,7 +717,7 @@ function AgentPanelBody({
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
</View>
);
@@ -1246,7 +1247,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
@@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundColorMapping} />
<Text style={styles.archivingTitle}>{t("agentPanel.states.archivingTitle")}</Text>
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
</View>
@@ -1600,7 +1601,7 @@ function AgentSessionUnavailableState({
<View style={styles.centerState}>
{isConnecting || isPreparingSession ? (
<>
<ActivityIndicator size="large" />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>
{isPreparingSession
? t("agentPanel.unavailable.preparingSession", { serverLabel })
@@ -1628,7 +1629,7 @@ function AgentSessionUnavailableState({
);
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,

View File

@@ -130,6 +130,9 @@ function ProviderSubagentPanel() {
target.subagentId,
timeline,
]);
const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null;
const progressKey =
timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null;
const streamContext = useMemo<AgentScreenAgent>(
() => ({
@@ -147,9 +150,10 @@ function ProviderSubagentPanel() {
() => ({
hasOlder: timeline?.hasOlder === true,
isLoadingOlder,
progressKey,
onLoadOlder: loadOlder,
}),
[isLoadingOlder, loadOlder, timeline?.hasOlder],
[isLoadingOlder, loadOlder, progressKey, timeline?.hasOlder],
);
if (serverInfo && !supported) {

View File

@@ -1,14 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
ScrollView,
Text,
View,
} from "react-native";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
import invariant from "tiny-invariant";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { usePaneContext } from "@/panels/pane-context";
@@ -69,7 +63,7 @@ type CommandStatus = "running" | "completed" | "failed";
function CommandStatusIcon({ status }: { status: CommandStatus }) {
if (status === "running") {
return <ThemedActivityIndicator size={14} uniProps={foregroundColorMapping} />;
return <ThemedLoadingSpinner size={14} uniProps={foregroundColorMapping} />;
}
if (status === "completed") {
return <ThemedCheckCircle2 size={14} uniProps={greenColorMapping} />;
@@ -247,7 +241,7 @@ function SetupPanel() {
{isWaiting ? (
<View style={styles.waitingContainer}>
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
</View>
) : null}
@@ -449,7 +443,7 @@ function TopLevelSetupError({
);
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCheckCircle2 = withUnistyles(CheckCircle2);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedChevronRight = withUnistyles(ChevronRight);

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
useCallback,
useEffect,
@@ -8,7 +9,6 @@ import React, {
type SetStateAction,
} from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
@@ -94,7 +94,7 @@ const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedX = withUnistyles(X);
const ThemedCopy = withUnistyles(Copy);
const ThemedRotateCw = withUnistyles(RotateCw);
@@ -696,7 +696,7 @@ function TabChip({
const highlighted = closeHovered || pressed;
if (isClosingTab) {
return (
<ThemedActivityIndicator
<ThemedLoadingSpinner
size={12}
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
/>

View File

@@ -1,12 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { type ReactElement, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { Check, ChevronDown } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -47,7 +42,7 @@ interface OpenTarget {
onOpen: () => Promise<void> | void;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheckIcon = withUnistyles(Check);
@@ -235,7 +230,7 @@ export function WorkspaceOpenInEditorButton({
}
>
{openMutation.isPending ? (
<ThemedActivityIndicator
<ThemedLoadingSpinner
size="small"
uniProps={foregroundColorMapping}
style={styles.splitButtonSpinnerOnly}

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
memo,
useCallback,
@@ -12,7 +13,7 @@ import {
} from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { BackHandler, Keyboard, Pressable, Text, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, type Href } from "expo-router";
import * as Clipboard from "expo-clipboard";
@@ -241,7 +242,7 @@ function buildWorkspaceFileLocation(
return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd };
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -1373,7 +1374,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
if (!activeTabDescriptor && !hasHydratedAgents) {
return (
<View style={styles.emptyState}>
<ThemedActivityIndicator uniProps={mutedColorMapping} />
<ThemedLoadingSpinner uniProps={mutedColorMapping} />
</View>
);
}