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 ? ( - - + ); }