mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ interface CreatedAgentTimelineGate {
|
||||
waitForForwardedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentTimelineResponseGate {
|
||||
release(): void;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
@@ -118,3 +123,53 @@ export async function delayCreatedAgentInitialTailResponse(
|
||||
waitForForwardedResponse: () => forwardedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayAgentOlderTimelineResponse(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineResponseGate> {
|
||||
let releaseRequested = false;
|
||||
let delayedResponseSeen = false;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
!delayedResponseSeen &&
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId &&
|
||||
payload.direction === "before"
|
||||
) {
|
||||
delayedResponseSeen = true;
|
||||
resolveDelayedResponse?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import {
|
||||
delayAgentOlderTimelineResponse,
|
||||
type AgentTimelineResponseGate,
|
||||
} from "./agent-timeline-gate";
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
@@ -53,6 +57,39 @@ export async function expectTimelinePromptNotMounted(page: Page, prompt: string)
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function makeLoadedTimelineFitViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 8_000 });
|
||||
}
|
||||
|
||||
export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight <= element.clientHeight;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
): Promise<AgentTimelineResponseGate & { expectLoading(): Promise<void> }> {
|
||||
const gate = await delayAgentOlderTimelineResponse(page, agent.agentId);
|
||||
return {
|
||||
...gate,
|
||||
async expectLoading() {
|
||||
await gate.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
45
packages/app/src/agent-stream/history-start-pagination.ts
Normal file
45
packages/app/src/agent-stream/history-start-pagination.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
@@ -17,8 +16,12 @@ import {
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
@@ -27,12 +30,28 @@ import {
|
||||
isNearBottomForStreamRenderStrategy,
|
||||
resolveBottomAnchorTransportBehavior,
|
||||
} from "./strategy";
|
||||
import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
rearmHistoryStartPagination,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const historyStartSlotStyle: ViewStyle = {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 32,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
};
|
||||
|
||||
interface HistoryRowDisplayVariants {
|
||||
regular?: StreamItem;
|
||||
@@ -72,6 +91,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
listStyle,
|
||||
baseListContentContainerStyle,
|
||||
@@ -95,6 +115,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
|
||||
|
||||
const historyItems = useMemo(() => {
|
||||
if (segments.historyVirtualized.length === 0) {
|
||||
@@ -123,6 +144,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
),
|
||||
[displayStateHistoryRows, historyRowRevision?.contentById],
|
||||
);
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const metrics = streamViewportMetricsRef.current;
|
||||
const hasMeasuredViewport =
|
||||
metrics.viewportMeasuredForKey === metrics.containerKey &&
|
||||
metrics.contentMeasuredForKey === metrics.containerKey;
|
||||
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
|
||||
distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && hasMeasuredViewport,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
});
|
||||
historyStartPaginationStateRef.current = result.state;
|
||||
if (result.shouldLoad) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
const clearNativeViewportSettling = useCallback(() => {
|
||||
if (nativeViewportSettlingFrameIdRef.current !== null) {
|
||||
@@ -222,14 +260,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
|
||||
const frame = requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
@@ -308,17 +348,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const nearBottom = isScrollEventNearBottom(event);
|
||||
onNearBottomChange(nearBottom);
|
||||
|
||||
const distanceFromOldestEdge =
|
||||
streamViewportMetricsRef.current.contentHeight -
|
||||
streamViewportMetricsRef.current.viewportHeight -
|
||||
contentOffset.y;
|
||||
if (
|
||||
historyStartReadyRef.current &&
|
||||
hasOlderHistory &&
|
||||
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
|
||||
) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
evaluateHistoryStart();
|
||||
|
||||
if (
|
||||
!isUserScrollActiveRef.current &&
|
||||
@@ -336,9 +366,15 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
|
||||
const handleScrollBeginDrag = useStableEvent(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
clearPendingUserScrollEnd();
|
||||
isUserScrollActiveRef.current = true;
|
||||
bottomAnchorController.beginUserScroll();
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
// Defer drag end so momentum can take ownership, but capture the terminal
|
||||
@@ -395,6 +431,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
previousViewportHeight,
|
||||
viewportHeight,
|
||||
});
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
const handleContentSizeChange = useStableEvent((_width: number, height: number) => {
|
||||
@@ -410,8 +447,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
previousContentHeight,
|
||||
contentHeight: nextContentHeight,
|
||||
});
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
evaluateHistoryStart();
|
||||
}, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]);
|
||||
|
||||
const renderItem = useStableEvent(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
|
||||
const rendered = renderHistoryMountedRow(item, index, historyItems);
|
||||
@@ -451,15 +493,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
]);
|
||||
|
||||
const historyFooterContent = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
if (!hasOlderHistory && !isLoadingOlderHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View testID="load-older-history-spinner">
|
||||
<ActivityIndicator size="small" />
|
||||
<View
|
||||
style={historyStartSlotStyle}
|
||||
testID={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
|
||||
>
|
||||
{isLoadingOlderHistory ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
}, [hasOlderHistory, isLoadingOlderHistory]);
|
||||
|
||||
// RN's FlatList strictMode keeps its internal renderItem wrapper stable when
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
|
||||
@@ -133,6 +133,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -176,6 +177,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -231,6 +233,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -314,6 +317,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -323,10 +327,6 @@ describe("createWebStreamStrategy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
@@ -335,11 +335,109 @@ describe("createWebStreamStrategy", () => {
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
scrollContainer?.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for bottom anchoring before evaluating a delayed initial tail", async () => {
|
||||
HTMLElement.prototype.scrollTo = vi.fn(function (
|
||||
this: HTMLElement,
|
||||
options?: ScrollToOptions | number,
|
||||
y?: number,
|
||||
) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const onNearHistoryStart = vi.fn();
|
||||
const renderInput = {
|
||||
agentId: "agent",
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
});
|
||||
expect(onNearHistoryStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps initial route entry anchored when delayed route readiness arrives before user scroll", async () => {
|
||||
@@ -378,6 +476,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -485,6 +584,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -581,6 +681,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -679,6 +780,7 @@ describe("createWebStreamStrategy", () => {
|
||||
onNearHistoryStart: vi.fn(),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
|
||||
@@ -8,11 +8,19 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
import {
|
||||
createHistoryStartPaginationState,
|
||||
evaluateHistoryStartPagination,
|
||||
rearmHistoryStartPagination,
|
||||
} from "./history-start-pagination";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
isMobileBreakpoint: boolean;
|
||||
@@ -25,7 +33,11 @@ const USER_SCROLL_DELTA_EPSILON = 1;
|
||||
const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const historyStartSlotStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -107,6 +119,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearHistoryStart,
|
||||
isLoadingOlderHistory,
|
||||
hasOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
@@ -133,6 +146,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
|
||||
const historyStartReadyRef = useRef(false);
|
||||
const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState());
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -173,6 +187,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [rowVirtualizer]);
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const evaluateHistoryStart = useStableEvent(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
const bottomAnchorSettled =
|
||||
!followOutputRef.current || isScrollContainerNearBottom(scrollContainer);
|
||||
const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, {
|
||||
distanceFromHistoryStart: scrollContainer.scrollTop,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
isReady: historyStartReadyRef.current && bottomAnchorSettled,
|
||||
progressKey: olderHistoryProgressKey,
|
||||
});
|
||||
historyStartPaginationStateRef.current = result.state;
|
||||
if (result.shouldLoad) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
});
|
||||
|
||||
const measureVirtualizedRowElement = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -231,8 +264,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollElementToBottom(scrollContainer, behavior);
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||
evaluateHistoryStart();
|
||||
},
|
||||
[onNearBottomChange],
|
||||
[evaluateHistoryStart, onNearBottomChange],
|
||||
);
|
||||
|
||||
const scheduleStickToBottom = useCallback(() => {
|
||||
@@ -297,24 +331,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
updateScrollMetrics();
|
||||
if (
|
||||
historyStartReadyRef.current &&
|
||||
hasOlderHistory &&
|
||||
currentScrollTop <= HISTORY_START_THRESHOLD_PX
|
||||
) {
|
||||
onNearHistoryStart();
|
||||
}
|
||||
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
|
||||
evaluateHistoryStart();
|
||||
}, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
historyStartPaginationStateRef.current = createHistoryStartPaginationState();
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
historyStartReadyRef.current = true;
|
||||
evaluateHistoryStart();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
historyStartReadyRef.current = false;
|
||||
};
|
||||
}, [props.agentId]);
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActivationReady) {
|
||||
@@ -370,7 +400,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
segments.historyMounted.length,
|
||||
segments.historyVirtualized.length,
|
||||
segments.liveHead.length,
|
||||
@@ -386,8 +421,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -400,7 +437,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scheduleStickToBottom, updateScrollMetrics]);
|
||||
}, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
@@ -410,8 +447,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
};
|
||||
const handlePointerDown = () => {
|
||||
@@ -434,8 +477,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
const previousTouchY = lastTouchClientYRef.current;
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
if (!isLoadingOlderHistory) {
|
||||
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
|
||||
historyStartPaginationStateRef.current,
|
||||
);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
evaluateHistoryStart();
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY;
|
||||
};
|
||||
@@ -464,7 +513,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
}, [cancelPendingStickToBottom, handleDomScroll]);
|
||||
}, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
@@ -546,15 +595,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return renderLiveAuxiliary();
|
||||
}, [renderLiveAuxiliary]);
|
||||
const historyStartSlot = useMemo(() => {
|
||||
if (!isLoadingOlderHistory) {
|
||||
if (!hasOlderHistory && !isLoadingOlderHistory) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
|
||||
<ActivityIndicator size="small" />
|
||||
<div
|
||||
style={historyStartSlotStyle}
|
||||
data-testid={isLoadingOlderHistory ? "load-older-history-spinner" : undefined}
|
||||
>
|
||||
{isLoadingOlderHistory ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}, [isLoadingOlderHistory]);
|
||||
}, [hasOlderHistory, isLoadingOlderHistory]);
|
||||
const shouldRenderEmpty =
|
||||
!boundary.hasMountedHistory &&
|
||||
!boundary.hasVirtualizedHistory &&
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface StreamRenderInput {
|
||||
onNearHistoryStart: () => void;
|
||||
isLoadingOlderHistory: boolean;
|
||||
hasOlderHistory: boolean;
|
||||
olderHistoryProgressKey: string | null;
|
||||
scrollEnabled: boolean;
|
||||
listStyle: StyleProp<ViewStyle>;
|
||||
baseListContentContainerStyle: StyleProp<ViewStyle>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
@@ -247,6 +247,7 @@ export interface AgentStreamViewProps {
|
||||
historyPagination?: {
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
progressKey: string | null;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
}
|
||||
@@ -388,10 +389,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
|
||||
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
|
||||
? {
|
||||
isLoadingOlder: historyPagination.isLoadingOlder,
|
||||
hasOlder: historyPagination.hasOlder,
|
||||
progressKey: historyPagination.progressKey,
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
@@ -1029,6 +1031,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
olderHistoryProgressKey: progressKey,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
@@ -1138,6 +1141,7 @@ function historyPaginationPropsEqual(
|
||||
return (
|
||||
left?.hasOlder === right?.hasOlder &&
|
||||
left?.isLoadingOlder === right?.isLoadingOlder &&
|
||||
left?.progressKey === right?.progressKey &&
|
||||
left?.onLoadOlder === right?.onLoadOlder
|
||||
);
|
||||
}
|
||||
@@ -1194,7 +1198,7 @@ function ToolCallSlot({
|
||||
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
const ThemedXIcon = withUnistyles(X);
|
||||
|
||||
@@ -1241,7 +1245,7 @@ function PermissionActionButton({
|
||||
return (
|
||||
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
||||
{isRespondingAction ? (
|
||||
<ThemedActivityIndicator size="small" uniProps={colorMapping} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={colorMapping} />
|
||||
) : (
|
||||
<View style={permissionStyles.optionContent}>
|
||||
<Icon size={14} uniProps={colorMapping} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -98,7 +99,7 @@ export function DictationControls({
|
||||
</Pressable>
|
||||
{actionsDisabled ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foreground} />
|
||||
</View>
|
||||
) : null}
|
||||
{!actionsDisabled && isFailed ? (
|
||||
@@ -221,7 +222,7 @@ export function DictationOverlay({
|
||||
<View style={overlayStyles.actionButtonsContainer}>
|
||||
{actionsDisabled ? (
|
||||
<View style={overlayStyles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
{!actionsDisabled && isFailed ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, X, XCircle } from "lucide-react-native";
|
||||
@@ -69,7 +70,7 @@ export function DownloadToast() {
|
||||
<View style={containerStyle} pointerEvents="box-none">
|
||||
<View style={styles.toast}>
|
||||
{activeDownload.status === "downloading" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foreground} />
|
||||
) : null}
|
||||
{activeDownload.status === "complete" ? (
|
||||
<Check size={18} color={theme.colors.primary} />
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObj
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
Pressable,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
@@ -42,6 +42,11 @@ const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "size" },
|
||||
];
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
@@ -163,7 +168,9 @@ function TreeRowItem({
|
||||
if (!isDirectory) {
|
||||
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
if (loading) {
|
||||
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
})()}
|
||||
</View>
|
||||
@@ -549,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
if (showInitialLoading) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
StyleProp,
|
||||
@@ -172,6 +172,7 @@ const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -865,7 +866,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={stateSurfaceStyle}>
|
||||
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
|
||||
{loadState.status === "loading" ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
{loadState.status === "error" ? (
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||
{t("message.attachments.imageUnavailable")}
|
||||
@@ -1004,7 +1007,7 @@ function AssistantMarkdownImage({
|
||||
if (query.isLoading || dataImageQuery.isLoading) {
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
<ActivityIndicator size="small" />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -2244,7 +2247,7 @@ export const CompactionMarker = memo(function CompactionMarker({
|
||||
<View style={compactionStylesheet.line} />
|
||||
<View style={compactionStylesheet.label}>
|
||||
{status === "loading" ? (
|
||||
<ActivityIndicator size="small" color="#a1a1aa" />
|
||||
<LoadingSpinner size="small" color="#a1a1aa" />
|
||||
) : (
|
||||
<Scissors size={12} color="#a1a1aa" />
|
||||
)}
|
||||
|
||||
@@ -3,13 +3,7 @@ import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-re
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
@@ -365,7 +359,7 @@ function DiagnosticSubSheet({
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
@@ -504,7 +498,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
@@ -581,7 +575,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
testID="question-form-dismiss"
|
||||
>
|
||||
{respondingAction === "dismiss" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
@@ -599,7 +593,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
testID="question-form-primary-action"
|
||||
>
|
||||
{respondingAction === "submit" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<Check size={14} color={submitActionTextColor} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
@@ -75,7 +76,7 @@ export function RealtimeVoiceOverlay({
|
||||
style={stopButtonStyle}
|
||||
>
|
||||
{isSwitching ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.palette.white} />
|
||||
<LoadingSpinner size="small" color={theme.colors.palette.white} />
|
||||
) : (
|
||||
<Square
|
||||
size={theme.iconSize.lg}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
@@ -143,7 +143,7 @@ const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
@@ -746,7 +746,7 @@ function ProjectLeadingVisualStatus({
|
||||
if (isArchiving) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -855,7 +855,7 @@ function NewWorktreeButton({
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedPlus
|
||||
size={15}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Pressable, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleAlert,
|
||||
@@ -54,7 +48,7 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
@@ -207,7 +201,7 @@ function WorkspaceStatusIndicator({
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
|
||||
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size={8} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
|
||||
@@ -836,7 +831,7 @@ export function TerminalPane({
|
||||
|
||||
{showLoadingOverlay ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
default as React,
|
||||
useCallback,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type {
|
||||
PressableProps,
|
||||
PressableStateCallbackType,
|
||||
@@ -44,7 +45,7 @@ function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps)
|
||||
if (loading) {
|
||||
return (
|
||||
<View>
|
||||
<ActivityIndicator size="small" color={iconColor} />
|
||||
<LoadingSpinner size="small" color={iconColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
@@ -630,7 +630,7 @@ function resolveLeadingContent(input: {
|
||||
successColor: string;
|
||||
}): ReactElement | null {
|
||||
if (input.isPending) {
|
||||
return <ActivityIndicator size={16} color={input.pendingColor} />;
|
||||
return <LoadingSpinner size={16} color={input.pendingColor} />;
|
||||
}
|
||||
if (input.isSuccess) {
|
||||
return <CheckCircle size={16} color={input.successColor} />;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
@@ -717,7 +717,7 @@ function resolveDropdownItemLeadingContent(input: {
|
||||
}): ReactElement | null {
|
||||
const { isPending, isSuccess, leading, theme } = input;
|
||||
if (isPending) {
|
||||
return <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
|
||||
return <LoadingSpinner size={16} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
if (isSuccess) {
|
||||
return <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||
interface LoadingSpinnerProps {
|
||||
color: string;
|
||||
size?: ActivityIndicatorProps["size"];
|
||||
style?: ActivityIndicatorProps["style"];
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
|
||||
return <ActivityIndicator size={size} color={color} />;
|
||||
export function LoadingSpinner({ color, size = "small", style }: LoadingSpinnerProps) {
|
||||
return <ActivityIndicator size={size} color={color} style={style} />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
StyleSheet as RNStyleSheet,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
@@ -884,7 +884,7 @@ function ComposerCancelButton({
|
||||
? t("composer.cancel.cancelingAgent")
|
||||
: t("composer.cancel.stopAgent");
|
||||
const icon = isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
<LoadingSpinner size="small" color="white" />
|
||||
) : (
|
||||
<Square size={buttonIconSize} color="white" fill="white" />
|
||||
);
|
||||
@@ -984,7 +984,7 @@ function ComposerVoiceModeButton({
|
||||
const renderTriggerContent = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (isVoiceSwitching) {
|
||||
return <ActivityIndicator size="small" color="white" />;
|
||||
return <LoadingSpinner size="small" color="white" />;
|
||||
}
|
||||
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
|
||||
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
@@ -445,7 +445,7 @@ function SendButtonContent({
|
||||
buttonIconSize: number;
|
||||
}) {
|
||||
if (isSubmitLoading) {
|
||||
return <ThemedActivityIndicator size="small" uniProps={iconAccentForegroundMapping} />;
|
||||
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
if (submitIcon === "return") {
|
||||
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
@@ -2000,7 +2000,7 @@ const ThemedMic = withUnistyles(Mic);
|
||||
const ThemedMicOff = withUnistyles(MicOff);
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedTextInput = withUnistyles(TextInput);
|
||||
|
||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, { type ReactElement, useCallback, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -421,7 +422,7 @@ export function LocalDaemonSection() {
|
||||
>
|
||||
{isLoading || isLoadingSettings ? (
|
||||
<View style={[settingsStyles.card, styles.loadingCard]}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
|
||||
import { Image, Text, TextInput, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { RotateCw, Copy, Check } from "lucide-react-native";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useState } from "react";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
type PairingViewState =
|
||||
| { tag: "loading" }
|
||||
@@ -184,7 +191,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
||||
if (viewState.tag === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="small" />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.hint}>{labels.loadingOffer}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -240,7 +247,7 @@ function PairDeviceQrContent(props: {
|
||||
if (props.qrQuery.isError) {
|
||||
return <Text style={styles.hint}>{props.unavailableLabel}</Text>;
|
||||
}
|
||||
return <ActivityIndicator size="small" />;
|
||||
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -8,14 +9,8 @@ import React, {
|
||||
} from "react";
|
||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -44,6 +39,12 @@ import { FileEditorModel, type FileEditorFile } from "./editor/model";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -264,7 +265,7 @@ function FilePreviewBody({
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -346,7 +347,7 @@ function FilePreviewBody({
|
||||
if (!imagePreviewUri) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -146,7 +141,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
accessibilityLabel={gitActions.primary.label}
|
||||
>
|
||||
{gitActions.primary.status === "pending" ? (
|
||||
<ActivityIndicator
|
||||
<LoadingSpinner
|
||||
size="small"
|
||||
color={theme.colors.foreground}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
FlatList,
|
||||
type LayoutChangeEvent,
|
||||
@@ -1320,7 +1319,7 @@ type PressableStyleFn = (
|
||||
|
||||
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedAlignJustify = withUnistyles(AlignJustify);
|
||||
const ThemedColumns2 = withUnistyles(Columns2);
|
||||
const ThemedPilcrow = withUnistyles(Pilcrow);
|
||||
@@ -1693,7 +1692,6 @@ export function DiffOptionsMenu({
|
||||
}
|
||||
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
|
||||
const EMPTY_PATH_LIST: string[] = [];
|
||||
@@ -1779,7 +1777,7 @@ function DiffBodyContent({
|
||||
if (isStatusLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<Text style={styles.loadingText}>{checkingRepositoryLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -1801,7 +1799,7 @@ function DiffBodyContent({
|
||||
if (isDiffLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { TFunction } from "i18next";
|
||||
import { SquarePen } from "lucide-react-native";
|
||||
@@ -11,7 +12,7 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -215,7 +216,7 @@ function renderChatAgentNonReadyView(args: {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -716,7 +717,7 @@ function AgentPanelBody({
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -1246,7 +1247,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1255,7 +1256,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundColorMapping} />
|
||||
<Text style={styles.archivingTitle}>{t("agentPanel.states.archivingTitle")}</Text>
|
||||
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
|
||||
</View>
|
||||
@@ -1600,7 +1601,7 @@ function AgentSessionUnavailableState({
|
||||
<View style={styles.centerState}>
|
||||
{isConnecting || isPreparingSession ? (
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.loadingText}>
|
||||
{isPreparingSession
|
||||
? t("agentPanel.unavailable.preparingSession", { serverLabel })
|
||||
@@ -1628,7 +1629,7 @@ function AgentSessionUnavailableState({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
@@ -130,6 +130,9 @@ function ProviderSubagentPanel() {
|
||||
target.subagentId,
|
||||
timeline,
|
||||
]);
|
||||
const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||
const progressKey =
|
||||
timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null;
|
||||
|
||||
const streamContext = useMemo<AgentScreenAgent>(
|
||||
() => ({
|
||||
@@ -147,9 +150,10 @@ function ProviderSubagentPanel() {
|
||||
() => ({
|
||||
hasOlder: timeline?.hasOlder === true,
|
||||
isLoadingOlder,
|
||||
progressKey,
|
||||
onLoadOlder: loadOlder,
|
||||
}),
|
||||
[isLoadingOlder, loadOlder, timeline?.hasOlder],
|
||||
[isLoadingOlder, loadOlder, progressKey, timeline?.hasOlder],
|
||||
);
|
||||
|
||||
if (serverInfo && !supported) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
@@ -69,7 +63,7 @@ type CommandStatus = "running" | "completed" | "failed";
|
||||
|
||||
function CommandStatusIcon({ status }: { status: CommandStatus }) {
|
||||
if (status === "running") {
|
||||
return <ThemedActivityIndicator size={14} uniProps={foregroundColorMapping} />;
|
||||
return <ThemedLoadingSpinner size={14} uniProps={foregroundColorMapping} />;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return <ThemedCheckCircle2 size={14} uniProps={greenColorMapping} />;
|
||||
@@ -247,7 +241,7 @@ function SetupPanel() {
|
||||
|
||||
{isWaiting ? (
|
||||
<View style={styles.waitingContainer}>
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -449,7 +443,7 @@ function TopLevelSetupError({
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedCheckCircle2 = withUnistyles(CheckCircle2);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -8,7 +9,6 @@ import React, {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -94,7 +94,7 @@ const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
@@ -696,7 +696,7 @@ function TabChip({
|
||||
const highlighted = closeHovered || pressed;
|
||||
if (isClosingTab) {
|
||||
return (
|
||||
<ThemedActivityIndicator
|
||||
<ThemedLoadingSpinner
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { type ReactElement, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Check, ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -47,7 +42,7 @@ interface OpenTarget {
|
||||
onOpen: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedEditorTargetIcon = withUnistyles(EditorTargetIcon);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
@@ -235,7 +230,7 @@ export function WorkspaceOpenInEditorButton({
|
||||
}
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<ThemedActivityIndicator
|
||||
<ThemedLoadingSpinner
|
||||
size="small"
|
||||
uniProps={foregroundColorMapping}
|
||||
style={styles.splitButtonSpinnerOnly}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -241,7 +242,7 @@ function buildWorkspaceFileLocation(
|
||||
return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd };
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedEllipsisVertical = withUnistyles(EllipsisVertical);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
@@ -1373,7 +1374,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React
|
||||
if (!activeTabDescriptor && !hasHydratedAgents) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<ThemedActivityIndicator uniProps={mutedColorMapping} />
|
||||
<ThemedLoadingSpinner uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user