From b97d6d13f3ff78dc93b7f8458cd1c74e00f294c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 27 Jul 2026 12:21:07 +0200 Subject: [PATCH] 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. --- .../app/e2e/agent-timeline-pagination.spec.ts | 20 ++++ .../app/e2e/helpers/agent-timeline-gate.ts | 55 +++++++++ .../app/e2e/helpers/timeline-pagination.ts | 37 ++++++ .../history-start-pagination.test.ts | 73 ++++++++++++ .../agent-stream/history-start-pagination.ts | 45 +++++++ .../app/src/agent-stream/strategy-native.tsx | 83 ++++++++++--- .../src/agent-stream/strategy-web.test.tsx | 110 +++++++++++++++++- .../app/src/agent-stream/strategy-web.tsx | 90 +++++++++++--- packages/app/src/agent-stream/strategy.ts | 1 + packages/app/src/agent-stream/view.tsx | 12 +- .../app/src/components/dictation-controls.tsx | 7 +- .../app/src/components/download-toast.tsx | 5 +- .../app/src/components/file-explorer-pane.tsx | 15 ++- packages/app/src/components/message.tsx | 11 +- .../components/provider-diagnostic-sheet.tsx | 12 +- .../app/src/components/question-form-card.tsx | 14 +-- .../src/components/realtime-voice-overlay.tsx | 5 +- .../src/components/sidebar-workspace-list.tsx | 8 +- .../sidebar/sidebar-workspace-row-content.tsx | 14 +-- packages/app/src/components/terminal-pane.tsx | 11 +- packages/app/src/components/ui/button.tsx | 5 +- .../app/src/components/ui/context-menu.tsx | 4 +- .../app/src/components/ui/dropdown-menu.tsx | 4 +- .../app/src/components/ui/loading-spinner.tsx | 5 +- packages/app/src/composer/index.tsx | 6 +- packages/app/src/composer/input/input.tsx | 6 +- .../components/desktop-updates-section.tsx | 5 +- .../components/pair-device-section.tsx | 15 ++- packages/app/src/file-pane/pane.tsx | 21 ++-- packages/app/src/git/actions-split-button.tsx | 11 +- packages/app/src/git/diff-pane.tsx | 8 +- .../src/hooks/use-load-older-agent-history.ts | 5 + packages/app/src/panels/agent-panel.tsx | 15 +-- .../src/panels/provider-subagent-panel.tsx | 6 +- packages/app/src/panels/setup-panel.tsx | 16 +-- .../workspace/workspace-desktop-tabs-row.tsx | 6 +- .../workspace-open-in-editor-button.tsx | 13 +-- .../screens/workspace/workspace-screen.tsx | 7 +- 38 files changed, 609 insertions(+), 177 deletions(-) create mode 100644 packages/app/src/agent-stream/history-start-pagination.test.ts create mode 100644 packages/app/src/agent-stream/history-start-pagination.ts diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index ebe7815aa..30c0bc379 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -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(); + } + }); }); diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/helpers/agent-timeline-gate.ts index 5d8cc0150..511d303db 100644 --- a/packages/app/e2e/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/helpers/agent-timeline-gate.ts @@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate { waitForForwardedResponse(): Promise; } +export interface AgentTimelineResponseGate { + release(): void; + waitForDelayedResponse(): Promise; +} + 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 { + let releaseRequested = false; + let delayedResponseSeen = false; + const delayedForwards: Array<() => void> = []; + let resolveDelayedResponse: (() => void) | null = null; + const delayedResponse = new Promise((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, + }; +} diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index f675eff3c..bf3023309 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -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 { + await page.setViewportSize({ width: 1280, height: 8_000 }); +} + +export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise { + 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 }> { + 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 { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); await scroll.hover(); diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts new file mode 100644 index 000000000..2801f479f --- /dev/null +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -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]); + }); +}); diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts new file mode 100644 index 000000000..180ea5cf0 --- /dev/null +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -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, + }; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 629f63fa0..6b67096e7 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -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(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): 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 ( - - + + {isLoadingOlderHistory ? ( + + ) : null} ); - }, [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. diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index f407fefbb..c125293cf 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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(); + 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, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index abac0fac1..7df586cef 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -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(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); 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 ( -
- +
+ {isLoadingOlderHistory ? ( + + ) : null}
); - }, [isLoadingOlderHistory]); + }, [hasOlderHistory, isLoadingOlderHistory]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index dd9a798ed..9a467c578 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -72,6 +72,7 @@ export interface StreamRenderInput { onNearHistoryStart: () => void; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; + olderHistoryProgressKey: string | null; scrollEnabled: boolean; listStyle: StyleProp; baseListContentContainerStyle: StyleProp; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 421fed73c..5af665d40 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -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; } -const ThemedActivityIndicator = withUnistyles(ActivityIndicator); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedCheckIcon = withUnistyles(Check); const ThemedXIcon = withUnistyles(X); @@ -1241,7 +1245,7 @@ function PermissionActionButton({ return ( {isRespondingAction ? ( - + ) : ( diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 477717d4e..fb2c4ad8e 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -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({ {actionsDisabled ? ( - + ) : null} {!actionsDisabled && isFailed ? ( @@ -221,7 +222,7 @@ export function DictationOverlay({ {actionsDisabled ? ( - + ) : null} {!actionsDisabled && isFailed ? ( diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx index b06417213..69aa42fdc 100644 --- a/packages/app/src/components/download-toast.tsx +++ b/packages/app/src/components/download-toast.tsx @@ -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() { {activeDownload.status === "downloading" ? ( - + ) : null} {activeDownload.status === "complete" ? ( diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index d0f1c227e..540a02bd9 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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 ; } - if (loading) return ; + if (loading) { + return ; + } return ; })()} @@ -549,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { if (showInitialLoading) { return ( - + {t("workspace.fileExplorer.states.loading")} ); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 87fd88d4a..8abe67928 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( - {loadState.status === "loading" ? : null} + {loadState.status === "loading" ? ( + + ) : null} {loadState.status === "error" ? ( {t("message.attachments.imageUnavailable")} @@ -1004,7 +1007,7 @@ function AssistantMarkdownImage({ if (query.isLoading || dataImageQuery.isLoading) { return ( - + ); } @@ -2244,7 +2247,7 @@ export const CompactionMarker = memo(function CompactionMarker({ {status === "loading" ? ( - + ) : ( )} diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index b4831acac..aecfaea4c 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -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 = ( - + {t("settings.providers.diagnostic.running")} @@ -504,7 +498,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) { if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) { return ( - + {t("settings.providers.models.loading")} ); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 22576a7e0..a1c19df69 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -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" ? ( - + ) : ( @@ -599,7 +593,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi testID="question-form-primary-action" > {respondingAction === "submit" ? ( - + ) : ( diff --git a/packages/app/src/components/realtime-voice-overlay.tsx b/packages/app/src/components/realtime-voice-overlay.tsx index 31ec826af..88b2e378b 100644 --- a/packages/app/src/components/realtime-voice-overlay.tsx +++ b/packages/app/src/components/realtime-voice-overlay.tsx @@ -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 ? ( - + ) : ( - + ); } @@ -855,7 +855,7 @@ function NewWorktreeButton({ > {({ hovered, pressed }) => loading ? ( - + ) : ( ({ 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 ( - + ); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index f5ba2864f..bba561452 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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 ? ( - + ) : null} diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 3a7a275db..e16097022 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -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 ( - + ); } diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 1869d8568..8a5a27156 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -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 ; + return ; } if (input.isSuccess) { return ; diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 8daac9efc..8c8badfa0 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -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 ; + return ; } if (isSuccess) { return ; diff --git a/packages/app/src/components/ui/loading-spinner.tsx b/packages/app/src/components/ui/loading-spinner.tsx index 43a72e5b5..f73fd037b 100644 --- a/packages/app/src/components/ui/loading-spinner.tsx +++ b/packages/app/src/components/ui/loading-spinner.tsx @@ -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 ; +export function LoadingSpinner({ color, size = "small", style }: LoadingSpinnerProps) { + return ; } diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index f753a04d4..35232cd02 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ? ( - + ) : ( ); @@ -984,7 +984,7 @@ function ComposerVoiceModeButton({ const renderTriggerContent = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { if (isVoiceSwitching) { - return ; + return ; } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ; diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index decb9e692..cbe92e330 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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 ; + return ; } if (submitIcon === "return") { return ; @@ -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 }); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 5fa4685c9..c9047f79b 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -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 ? ( - + ) : ( <> diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index 1160dbf4f..44cc592b5 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -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 ( - + {labels.loadingOffer} ); @@ -240,7 +247,7 @@ function PairDeviceQrContent(props: { if (props.qrQuery.isError) { return {props.unavailableLabel}; } - return ; + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index c34bf7108..eb902eb33 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -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 ( - + {t("panels.file.loading")} ); @@ -346,7 +347,7 @@ function FilePreviewBody({ if (!imagePreviewUri) { return ( - + {t("panels.file.loading")} ); diff --git a/packages/app/src/git/actions-split-button.tsx b/packages/app/src/git/actions-split-button.tsx index 2551f1e9e..1d589d237 100644 --- a/packages/app/src/git/actions-split-button.tsx +++ b/packages/app/src/git/actions-split-button.tsx @@ -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" ? ( - ({ 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["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; @@ -1779,7 +1777,7 @@ function DiffBodyContent({ if (isStatusLoading) { return ( - + {checkingRepositoryLabel} ); @@ -1801,7 +1799,7 @@ function DiffBodyContent({ if (isDiffLoading) { return ( - + ); } diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index c2cf7c02c..1bf8b9557 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -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, }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 508900401..240f300db 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ( - + ); @@ -716,7 +717,7 @@ function AgentPanelBody({ return ( - + ); @@ -1246,7 +1247,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {showHistorySyncOverlay ? ( - + ) : null} @@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {isArchivingCurrentAgent ? ( - + {t("agentPanel.states.archivingTitle")} {t("agentPanel.states.archivingSubtitle")} @@ -1600,7 +1601,7 @@ function AgentSessionUnavailableState({ {isConnecting || isPreparingSession ? ( <> - + {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, diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index d77fa3225..a5bac3b7b 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -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( () => ({ @@ -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) { diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index a183b8255..5087cf365 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -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 ; + return ; } if (status === "completed") { return ; @@ -247,7 +241,7 @@ function SetupPanel() { {isWaiting ? ( - + {t("workspace.setup.waiting")} ) : 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); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 7206b7f9e..2275bd543 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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 ( - diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 78fa387cf..15e9ef09f 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -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; } -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 ? ( - - + ); }