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

* fix(chat): load older timeline history consistently

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

* fix(chat): keep older history loading reliably

Use authoritative timeline cursor progress so merged rows cannot stall pagination. Wait for bottom anchoring before evaluating the history edge, and re-arm retries on a fresh upward gesture without automatic failure loops.
This commit is contained in:
Mohamed Boudra
2026-07-27 12:21:07 +02:00
committed by GitHub
parent 80c8a08393
commit b97d6d13f3
38 changed files with 609 additions and 177 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native"; import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -98,7 +99,7 @@ export function DictationControls({
</Pressable> </Pressable>
{actionsDisabled ? ( {actionsDisabled ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foreground} /> <LoadingSpinner size="small" color={theme.colors.foreground} />
</View> </View>
) : null} ) : null}
{!actionsDisabled && isFailed ? ( {!actionsDisabled && isFailed ? (
@@ -221,7 +222,7 @@ export function DictationOverlay({
<View style={overlayStyles.actionButtonsContainer}> <View style={overlayStyles.actionButtonsContainer}>
{actionsDisabled ? ( {actionsDisabled ? (
<View style={overlayStyles.loadingContainer}> <View style={overlayStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.accentForeground} /> <LoadingSpinner size="small" color={theme.colors.accentForeground} />
</View> </View>
) : null} ) : null}
{!actionsDisabled && isFailed ? ( {!actionsDisabled && isFailed ? (

View File

@@ -1,7 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, X, XCircle } from "lucide-react-native"; import { Check, X, XCircle } from "lucide-react-native";
@@ -69,7 +70,7 @@ export function DownloadToast() {
<View style={containerStyle} pointerEvents="box-none"> <View style={containerStyle} pointerEvents="box-none">
<View style={styles.toast}> <View style={styles.toast}>
{activeDownload.status === "downloading" ? ( {activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} /> <LoadingSpinner size="small" color={theme.colors.foreground} />
) : null} ) : null}
{activeDownload.status === "complete" ? ( {activeDownload.status === "complete" ? (
<Check size={18} color={theme.colors.primary} /> <Check size={18} color={theme.colors.primary} />

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObj
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator,
FlatList, FlatList,
ListRenderItemInfo, ListRenderItemInfo,
Pressable, Pressable,
@@ -12,7 +11,7 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } 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 { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
@@ -24,6 +23,7 @@ import {
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { Theme } from "@/styles/theme";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu"; import { FileActionsMenu } from "@/components/file-actions-menu";
@@ -42,6 +42,11 @@ const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "size" }, { value: "size" },
]; ];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
return `${size} B`; return `${size} B`;
@@ -163,7 +168,9 @@ function TreeRowItem({
if (!isDirectory) { if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />; return <MaterialFileIcon fileName={entry.name} size={16} />;
} }
if (loading) return <ActivityIndicator size="small" />; if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
return <TreeChevron expanded={isExpanded} />; return <TreeChevron expanded={isExpanded} />;
})()} })()}
</View> </View>
@@ -549,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
if (showInitialLoading) { if (showInitialLoading) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<ActivityIndicator size="small" /> <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text> <Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
</View> </View>
); );

View File

@@ -1,9 +1,9 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
View, View,
Text, Text,
Image, Image,
Pressable, Pressable,
ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
type LayoutChangeEvent, type LayoutChangeEvent,
StyleProp, StyleProp,
@@ -172,6 +172,7 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight); const ThemedChevronRightIcon = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -865,7 +866,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
return ( return (
<View style={frameStyle}> <View style={frameStyle}>
<View style={stateSurfaceStyle}> <View style={stateSurfaceStyle}>
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null} {loadState.status === "loading" ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
{loadState.status === "error" ? ( {loadState.status === "error" ? (
<Text style={assistantMessageStylesheet.imageErrorText}> <Text style={assistantMessageStylesheet.imageErrorText}>
{t("message.attachments.imageUnavailable")} {t("message.attachments.imageUnavailable")}
@@ -1004,7 +1007,7 @@ function AssistantMarkdownImage({
if (query.isLoading || dataImageQuery.isLoading) { if (query.isLoading || dataImageQuery.isLoading) {
return ( return (
<View style={stateFrameStyle}> <View style={stateFrameStyle}>
<ActivityIndicator size="small" /> <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View> </View>
); );
} }
@@ -2244,7 +2247,7 @@ export const CompactionMarker = memo(function CompactionMarker({
<View style={compactionStylesheet.line} /> <View style={compactionStylesheet.line} />
<View style={compactionStylesheet.label}> <View style={compactionStylesheet.label}>
{status === "loading" ? ( {status === "loading" ? (
<ActivityIndicator size="small" color="#a1a1aa" /> <LoadingSpinner size="small" color="#a1a1aa" />
) : ( ) : (
<Scissors size={12} color="#a1a1aa" /> <Scissors size={12} color="#a1a1aa" />
)} )}

View File

@@ -3,13 +3,7 @@ import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-re
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
Text,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
AdaptiveModalSheet, AdaptiveModalSheet,
@@ -365,7 +359,7 @@ function DiagnosticSubSheet({
body = ( body = (
<SurfaceCard key={visible ? "visible" : "hidden"}> <SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}> <View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text> <Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View> </View>
</SurfaceCard> </SurfaceCard>
@@ -504,7 +498,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) { if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
return ( return (
<View style={sheetStyles.emptyState}> <View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text> <Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
</View> </View>
); );

View File

@@ -1,12 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native";
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native"; import { Check, X } from "lucide-react-native";
@@ -581,7 +575,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
testID="question-form-dismiss" testID="question-form-dismiss"
> >
{respondingAction === "dismiss" ? ( {respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
) : ( ) : (
<View style={styles.actionContent}> <View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} /> <X size={14} color={theme.colors.foregroundMuted} />
@@ -599,7 +593,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
testID="question-form-primary-action" testID="question-form-primary-action"
> >
{respondingAction === "submit" ? ( {respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.accentForeground} /> <LoadingSpinner size="small" color={theme.colors.accentForeground} />
) : ( ) : (
<View style={styles.actionContent}> <View style={styles.actionContent}>
<Check size={14} color={submitActionTextColor} /> <Check size={14} color={submitActionTextColor} />

View File

@@ -1,6 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native"; import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout"; import { FOOTER_HEIGHT } from "@/constants/layout";
@@ -75,7 +76,7 @@ export function RealtimeVoiceOverlay({
style={stopButtonStyle} style={stopButtonStyle}
> >
{isSwitching ? ( {isSwitching ? (
<ActivityIndicator size="small" color={theme.colors.palette.white} /> <LoadingSpinner size="small" color={theme.colors.palette.white} />
) : ( ) : (
<Square <Square
size={theme.iconSize.lg} size={theme.iconSize.lg}

View File

@@ -1,8 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
View, View,
Text, Text,
Pressable, Pressable,
ActivityIndicator,
ScrollView, ScrollView,
type GestureResponderEvent, type GestureResponderEvent,
type PressableStateCallbackType, type PressableStateCallbackType,
@@ -143,7 +143,7 @@ const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1; const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedPlus = withUnistyles(Plus); const ThemedPlus = withUnistyles(Plus);
@@ -746,7 +746,7 @@ function ProjectLeadingVisualStatus({
if (isArchiving) { if (isArchiving) {
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} /> <ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
</View> </View>
); );
} }
@@ -855,7 +855,7 @@ function NewWorktreeButton({
> >
{({ hovered, pressed }) => {({ hovered, pressed }) =>
loading ? ( loading ? (
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} /> <ThemedLoadingSpinner size={14} uniProps={foregroundMutedColorMapping} />
) : ( ) : (
<ThemedPlus <ThemedPlus
size={15} size={15}

View File

@@ -1,13 +1,7 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
ActivityIndicator,
Pressable,
Text,
View,
type GestureResponderEvent,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
CircleAlert, CircleAlert,
@@ -54,7 +48,7 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor); const ThemedMonitor = withUnistyles(Monitor);
@@ -207,7 +201,7 @@ function WorkspaceStatusIndicator({
if (loading) { if (loading) {
return ( return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading"> <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} /> <ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
</View> </View>
); );
} }

View File

@@ -1,11 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input"; import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
@@ -836,7 +831,7 @@ export function TerminalPane({
{showLoadingOverlay ? ( {showLoadingOverlay ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading"> <View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
</View> </View>
) : null} ) : null}
</View> </View>

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
default as React, default as React,
useCallback, useCallback,
@@ -8,7 +9,7 @@ import {
type ReactElement, type ReactElement,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import type { import type {
PressableProps, PressableProps,
PressableStateCallbackType, PressableStateCallbackType,
@@ -44,7 +45,7 @@ function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps)
if (loading) { if (loading) {
return ( return (
<View> <View>
<ActivityIndicator size="small" color={iconColor} /> <LoadingSpinner size="small" color={iconColor} />
</View> </View>
); );
} }

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
type ComponentProps, type ComponentProps,
createContext, createContext,
@@ -14,7 +15,6 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator,
Dimensions, Dimensions,
Modal, Modal,
Platform, Platform,
@@ -630,7 +630,7 @@ function resolveLeadingContent(input: {
successColor: string; successColor: string;
}): ReactElement | null { }): ReactElement | null {
if (input.isPending) { if (input.isPending) {
return <ActivityIndicator size={16} color={input.pendingColor} />; return <LoadingSpinner size={16} color={input.pendingColor} />;
} }
if (input.isSuccess) { if (input.isSuccess) {
return <CheckCircle size={16} color={input.successColor} />; return <CheckCircle size={16} color={input.successColor} />;

View File

@@ -1,3 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
createContext, createContext,
useCallback, useCallback,
@@ -14,7 +15,6 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator,
Modal, Modal,
Pressable, Pressable,
Text, Text,
@@ -717,7 +717,7 @@ function resolveDropdownItemLeadingContent(input: {
}): ReactElement | null { }): ReactElement | null {
const { isPending, isSuccess, leading, theme } = input; const { isPending, isSuccess, leading, theme } = input;
if (isPending) { if (isPending) {
return <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />; return <LoadingSpinner size={16} color={theme.colors.foregroundMuted} />;
} }
if (isSuccess) { if (isSuccess) {
return <CheckCircle size={16} color={theme.colors.palette.green[500]} />; return <CheckCircle size={16} color={theme.colors.palette.green[500]} />;

View File

@@ -3,8 +3,9 @@ import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
interface LoadingSpinnerProps { interface LoadingSpinnerProps {
color: string; color: string;
size?: ActivityIndicatorProps["size"]; size?: ActivityIndicatorProps["size"];
style?: ActivityIndicatorProps["style"];
} }
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) { export function LoadingSpinner({ color, size = "small", style }: LoadingSpinnerProps) {
return <ActivityIndicator size={size} color={color} />; return <ActivityIndicator size={size} color={color} style={style} />;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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