Skip to content

Commit 6b73b3d

Browse files
t3dotggclaude
andauthored
feat: paginate thread loading with user-anchored turn windows (#5493)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7aad791 commit 6b73b3d

24 files changed

Lines changed: 2093 additions & 30 deletions

apps/mobile/src/connection/environment-cache-store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import * as Schema from "effect/Schema";
1717
import * as MobileDatabase from "../persistence/mobile-database";
1818

1919
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
20-
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
20+
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
21+
// makes pre-pagination clients discard the record instead of decoding a
22+
// partial thread as complete (rollback safety).
23+
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3;
2124
const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
2225
const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
2326

apps/mobile/src/features/threads/ThreadDetailScreen.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface ThreadDetailScreenProps {
6161
readonly connectionStateLabel: EnvironmentConnectionPhase;
6262
/** Message sync status for the selected thread (drives the composer status pill). */
6363
readonly threadSyncStatus?: EnvironmentThreadStatus;
64+
/** Non-null when older turns exist beyond the loaded window. */
65+
readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null;
6466
readonly activeThreadBusy: boolean;
6567
readonly environmentId: EnvironmentId;
6668
readonly projectWorkspaceRoot: string | null;
@@ -371,6 +373,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
371373
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
372374
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
373375
skills={selectedProviderSkills}
376+
loadEarlier={props.loadEarlier ?? null}
374377
/>
375378
</View>
376379
) : (

apps/mobile/src/features/threads/ThreadFeed.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ export interface ThreadFeedProps {
164164
readonly usesAutomaticContentInsets?: boolean;
165165
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
166166
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
167+
/** Non-null when older turns exist beyond the loaded window. */
168+
readonly loadEarlier?: {
169+
readonly loading: boolean;
170+
readonly onLoadEarlier: () => void;
171+
} | null;
167172
}
168173

169174
function MessageAttachmentImage(props: {
@@ -1893,7 +1898,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18931898
onScroll={handleScroll}
18941899
scrollEventThrottle={16}
18951900
ListHeaderComponent={
1896-
usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
1901+
<>
1902+
{usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
1903+
{props.loadEarlier != null ? (
1904+
<Pressable
1905+
onPress={props.loadEarlier.onLoadEarlier}
1906+
disabled={props.loadEarlier.loading}
1907+
className="items-center py-2"
1908+
>
1909+
<Text className="text-xs text-foreground-secondary">
1910+
{props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"}
1911+
</Text>
1912+
</Pressable>
1913+
) : null}
1914+
</>
18971915
}
18981916
contentContainerStyle={{
18991917
paddingTop: 12,

apps/mobile/src/features/threads/ThreadRouteScreen.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
99
import * as Option from "effect/Option";
1010
import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";
11+
import {
12+
requestOlderThreadTurns,
13+
threadHasOlderTurns,
14+
} from "@t3tools/client-runtime/state/threads";
1115
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
1216
import { Platform, ScrollView, View } from "react-native";
1317
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -190,6 +194,20 @@ function ThreadRouteContent(
190194
useThreadSelection();
191195
const selectedThreadDetailState = props.selectedThreadDetailState;
192196
const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
197+
// "Load earlier turns" header state for windowed (paginated) thread loads.
198+
const loadEarlierTurns = useMemo(() => {
199+
if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) {
200+
return null;
201+
}
202+
return {
203+
loading:
204+
selectedThreadDetailState.page._tag === "Some" &&
205+
selectedThreadDetailState.page.value.loadingOlder,
206+
onLoadEarlier: () => {
207+
requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id);
208+
},
209+
};
210+
}, [selectedThread, selectedThreadDetailState]);
193211
const { selectedThreadCwd } = useSelectedThreadWorktree();
194212
const composer = useThreadComposerState();
195213
const gitState = useSelectedThreadGitState();
@@ -766,6 +784,7 @@ function ThreadRouteContent(
766784
draftAttachments={composer.draftAttachments}
767785
connectionStateLabel={routeConnectionState}
768786
threadSyncStatus={selectedThreadDetailState.status}
787+
loadEarlier={loadEarlierTurns}
769788
activeThreadBusy={composer.activeThreadBusy}
770789
environmentId={selectedThread.environmentId}
771790
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}

0 commit comments

Comments
 (0)