Skip to content

Commit fb6d31c

Browse files
patrozagithub-actions[bot]
authored andcommitted
perf: import mobile pagination and bounded replay (upstream pingdotgg#3510) (#35)
Source: pingdotgg#3510 Source SHA: 034f493 Imported: native mobile lazy loading for older thread activity, a 1,000-event subscription catch-up ceiling with snapshot fallback, and synchronized stale snapshot watermarks. Adapted: applied above the refreshed pingdotgg#4018 web/server candidate and preserved Tim lifecycle handling plus our mobile composer changes. Excluded: pingdotgg#3510 server/web pagination duplicated by pingdotgg#4018, the later shared-hook refactor, formatting-only commits, and contract comments. The shared refactor can be revisited independently after production validation.
1 parent eb52aa4 commit fb6d31c

8 files changed

Lines changed: 298 additions & 12 deletions

File tree

.github/upstream-candidates.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
"sourceSha": "de8fd65934768173819b93adcd6b92af3e8c7fc3",
77
"status": "active",
88
"purpose": "Bound server thread history and lazily page older web activity"
9+
},
10+
{
11+
"upstreamPr": 3510,
12+
"sourceSha": "034f4936d7a1435887bb62ac3f2db61f08928cbf",
13+
"status": "active",
14+
"purpose": "Page mobile history and bound stale subscription catch-up"
915
}
1016
]
1117
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export interface ThreadDetailScreenProps {
6262
/** Message sync status for the selected thread (drives the composer status pill). */
6363
readonly threadSyncStatus?: EnvironmentThreadStatus;
6464
readonly activeThreadBusy: boolean;
65+
readonly hasMoreOlderActivities: boolean;
66+
readonly loadingOlderActivities: boolean;
67+
readonly onLoadOlderActivities: () => void;
6568
readonly environmentId: EnvironmentId;
6669
readonly projectWorkspaceRoot: string | null;
6770
readonly threadCwd: string | null;
@@ -372,6 +375,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
372375
usesAutomaticContentInsets={props.usesAutomaticContentInsets}
373376
onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
374377
skills={selectedProviderSkills}
378+
hasMoreOlder={props.hasMoreOlderActivities}
379+
loadingOlder={props.loadingOlderActivities}
380+
onLoadOlder={props.onLoadOlderActivities}
375381
/>
376382
</View>
377383
) : (

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ export interface ThreadFeedProps {
141141
readonly usesAutomaticContentInsets?: boolean;
142142
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
143143
readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
144+
/** Older history beyond the live activity window can be lazy-loaded on scroll-up. */
145+
readonly hasMoreOlder?: boolean;
146+
readonly loadingOlder?: boolean;
147+
readonly onLoadOlder?: () => void;
144148
}
145149

146150
function MessageAttachmentImage(props: {
@@ -1498,6 +1502,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14981502
? props.latestTurn.turnId
14991503
: null;
15001504

1505+
// Reaching the top (oldest) lazy-loads older history. The hook keys an
1506+
// in-flight guard by thread, so repeated fires during scroll coalesce.
1507+
const { hasMoreOlder, loadingOlder, onLoadOlder } = props;
1508+
const onStartReachedOlderHistory = useCallback(() => {
1509+
if (hasMoreOlder && !loadingOlder) {
1510+
onLoadOlder?.();
1511+
}
1512+
}, [hasMoreOlder, loadingOlder, onLoadOlder]);
1513+
15011514
useEffect(() => {
15021515
const previous = previousLatestTurnRef.current;
15031516
previousLatestTurnRef.current = props.latestTurn;
@@ -1790,9 +1803,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17901803
estimatedItemSize={180}
17911804
initialScrollAtEnd
17921805
onScroll={handleScroll}
1806+
onStartReached={onStartReachedOlderHistory}
1807+
onStartReachedThreshold={0.5}
17931808
scrollEventThrottle={16}
17941809
ListHeaderComponent={
1795-
usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
1810+
usesNativeAutomaticInsets && !loadingOlder ? null : (
1811+
<View style={{ height: usesNativeAutomaticInsets ? undefined : topContentInset }}>
1812+
{loadingOlder ? <ActivityIndicator style={{ marginTop: 8 }} /> : null}
1813+
</View>
1814+
)
17961815
}
17971816
contentContainerStyle={{
17981817
paddingTop: 12,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,9 @@ function ThreadRouteContent(
785785
connectionStateLabel={routeConnectionState}
786786
threadSyncStatus={selectedThreadDetailState.status}
787787
activeThreadBusy={composer.activeThreadBusy}
788+
hasMoreOlderActivities={composer.hasMoreOlderActivities}
789+
loadingOlderActivities={composer.loadingOlderActivities}
790+
onLoadOlderActivities={composer.onLoadOlderActivities}
788791
environmentId={selectedThread.environmentId}
789792
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
790793
threadCwd={selectedThreadCwd}

apps/mobile/src/state/use-thread-composer-state.ts

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { useAtomValue } from "@effect/atom-react";
2-
import { useCallback, useEffect, useMemo } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33

44
import {
55
CommandId,
66
MessageId,
77
type EnvironmentId,
88
type ModelSelection,
9+
type OrchestrationThreadActivity,
910
type ProviderInteractionMode,
1011
type RuntimeMode,
1112
type ThreadId,
@@ -36,11 +37,15 @@ import {
3637
useComposerDraft,
3738
} from "./use-composer-drafts";
3839
import { setPendingConnectionError } from "../state/use-remote-environment-registry";
40+
import { orchestrationEnvironment } from "../state/orchestration";
3941
import { useSelectedThreadDetail } from "../state/use-thread-detail";
4042
import { useThreadSelection } from "../state/use-thread-selection";
43+
import { useAtomCommand } from "./use-atom-command";
4144
import { enqueueThreadOutboxMessage } from "./thread-outbox";
4245
import { useThreadOutboxMessages } from "./use-thread-outbox";
4346

47+
const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = [];
48+
4449
export function appendReviewCommentToDraft(input: {
4550
readonly environmentId: EnvironmentId;
4651
readonly threadId: ThreadId;
@@ -89,9 +94,113 @@ export function useThreadComposerState() {
8994
() => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []),
9095
[queuedMessagesByThreadKey, selectedThreadKey],
9196
);
97+
98+
// ── Older-history lazy-load (mirrors web ChatView) ──────────────────────────
99+
// The detail snapshot windows activities to the most recent page (the server
100+
// sets `hasMoreActivities`); older pages are fetched on demand and prepended.
101+
const [olderActivities, setOlderActivities] = useState<
102+
ReadonlyArray<OrchestrationThreadActivity>
103+
>([]);
104+
const [olderLoaded, setOlderLoaded] = useState(false);
105+
const [olderHasMore, setOlderHasMore] = useState(false);
106+
const [loadingOlderActivities, setLoadingOlderActivities] = useState(false);
107+
const loadThreadActivities = useAtomCommand(orchestrationEnvironment.loadThreadActivities, {
108+
reportFailure: false,
109+
});
110+
111+
const activityRequestKey = selectedThreadShell
112+
? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}`
113+
: null;
114+
const activityRequestKeyRef = useRef(activityRequestKey);
115+
activityRequestKeyRef.current = activityRequestKey;
116+
useEffect(() => {
117+
setOlderActivities([]);
118+
setOlderLoaded(false);
119+
setOlderHasMore(false);
120+
setLoadingOlderActivities(false);
121+
}, [activityRequestKey]);
122+
123+
const liveActivities = selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES;
124+
const mergedActivities = useMemo(
125+
() => (olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities),
126+
[olderActivities, liveActivities],
127+
);
128+
// Before any page is loaded, the server tells us whether older history exists.
129+
const hasMoreOlderActivities = olderLoaded
130+
? olderHasMore
131+
: (selectedThreadDetail?.hasMoreActivities ?? false);
132+
133+
// Synchronous in-flight guard keyed by thread: the list fires onLoadOlder
134+
// repeatedly while pinned at the top, but loading *state* only updates next
135+
// render, so without this a fast scroll dispatches duplicate same-cursor calls.
136+
const inFlightOlderKeyRef = useRef<string | null>(null);
137+
const onLoadOlderActivities = useCallback(() => {
138+
if (!selectedThreadShell || !hasMoreOlderActivities) {
139+
return;
140+
}
141+
const oldestActivity = mergedActivities[0];
142+
if (!oldestActivity || !activityRequestKey) {
143+
return;
144+
}
145+
if (inFlightOlderKeyRef.current === activityRequestKey) {
146+
return;
147+
}
148+
const cursorInput =
149+
oldestActivity.sequence !== undefined
150+
? { beforeSequence: oldestActivity.sequence }
151+
: { beforeCreatedAt: oldestActivity.createdAt, beforeActivityId: oldestActivity.id };
152+
const requestKey = activityRequestKey;
153+
inFlightOlderKeyRef.current = requestKey;
154+
setLoadingOlderActivities(true);
155+
void loadThreadActivities({
156+
environmentId: selectedThreadShell.environmentId,
157+
input: { threadId: selectedThreadShell.id, ...cursorInput },
158+
})
159+
.then((result) => {
160+
if (activityRequestKeyRef.current !== requestKey) {
161+
return;
162+
}
163+
if (result._tag !== "Success") {
164+
return;
165+
}
166+
const page = result.value;
167+
setOlderActivities((prev) => {
168+
// Dedup against both already-loaded older pages and the live window,
169+
// since mobile merges everything into one array (duplicate ids would
170+
// produce duplicate React keys in the feed).
171+
const seen = new Set(prev.map((activity) => activity.id));
172+
for (const activity of liveActivities) {
173+
seen.add(activity.id);
174+
}
175+
const fresh = page.activities.filter((activity) => !seen.has(activity.id));
176+
return [...fresh, ...prev];
177+
});
178+
setOlderLoaded(true);
179+
setOlderHasMore(page.hasMore);
180+
})
181+
.finally(() => {
182+
if (inFlightOlderKeyRef.current === requestKey) {
183+
inFlightOlderKeyRef.current = null;
184+
}
185+
if (activityRequestKeyRef.current === requestKey) {
186+
setLoadingOlderActivities(false);
187+
}
188+
});
189+
}, [
190+
selectedThreadShell,
191+
hasMoreOlderActivities,
192+
mergedActivities,
193+
activityRequestKey,
194+
liveActivities,
195+
loadThreadActivities,
196+
]);
197+
92198
const selectedThreadFeed = useMemo(
93-
() => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []),
94-
[selectedThreadDetail],
199+
() =>
200+
selectedThreadDetail
201+
? buildThreadFeed({ ...selectedThreadDetail, activities: mergedActivities })
202+
: [],
203+
[selectedThreadDetail, mergedActivities],
95204
);
96205

97206
const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
@@ -299,6 +408,9 @@ export function useThreadComposerState() {
299408
runtimeMode,
300409
interactionMode,
301410
activeThreadBusy,
411+
hasMoreOlderActivities,
412+
loadingOlderActivities,
413+
onLoadOlderActivities,
302414
onChangeDraftMessage,
303415
onPickDraftImages,
304416
onPasteIntoDraft,

apps/server/src/orchestration/Services/OrchestrationEngine.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ export interface OrchestrationEngineShape {
2727
*
2828
* @param fromSequenceExclusive - Sequence cursor (exclusive).
2929
* @param limit - Maximum number of events to read. Defaults to the event
30-
* store's page-bounded default; pass a higher value when the caller must
31-
* read every event after the cursor (e.g. per-thread catch-up that filters
32-
* a small subset out of a potentially larger global range).
30+
* store's page-bounded default. Callers must keep this bounded; use a
31+
* projection snapshot instead of replaying an arbitrarily stale cursor.
3332
* @returns Stream containing ordered events.
3433
*/
3534
readonly readEvents: (

apps/server/src/server.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5986,6 +5986,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59865986
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
59875987
);
59885988

5989+
it.effect("subscribeThread replaces a stale cursor with a fresh snapshot", () =>
5990+
Effect.gen(function* () {
5991+
const snapshotSequence = 5_000;
5992+
const thread = makeDefaultOrchestrationReadModel().threads[0]!;
5993+
const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
5994+
let replayCalls = 0;
5995+
const messageEvent = {
5996+
sequence: snapshotSequence + 1,
5997+
eventId: EventId.make("event-stale-cursor-message"),
5998+
aggregateKind: "thread",
5999+
aggregateId: defaultThreadId,
6000+
occurredAt: "2026-01-01T00:00:01.000Z",
6001+
commandId: null,
6002+
causationEventId: null,
6003+
correlationId: null,
6004+
metadata: {},
6005+
type: "thread.message-sent",
6006+
payload: {
6007+
threadId: defaultThreadId,
6008+
messageId: MessageId.make("message-stale-cursor"),
6009+
role: "user",
6010+
text: "Published while loading the replacement snapshot",
6011+
turnId: null,
6012+
streaming: false,
6013+
createdAt: "2026-01-01T00:00:01.000Z",
6014+
updatedAt: "2026-01-01T00:00:01.000Z",
6015+
},
6016+
} satisfies Extract<OrchestrationEvent, { type: "thread.message-sent" }>;
6017+
6018+
yield* buildAppUnderTest({
6019+
layers: {
6020+
projectionSnapshotQuery: {
6021+
getSnapshotSequence: () => Effect.succeed({ snapshotSequence }),
6022+
getThreadDetailSnapshot: () =>
6023+
Effect.gen(function* () {
6024+
yield* Effect.sleep("25 millis");
6025+
yield* PubSub.publish(liveEvents, messageEvent);
6026+
return Option.some({
6027+
snapshotSequence,
6028+
thread,
6029+
});
6030+
}),
6031+
},
6032+
orchestrationEngine: {
6033+
streamDomainEvents: Stream.fromPubSub(liveEvents),
6034+
readEvents: () => {
6035+
replayCalls += 1;
6036+
return Stream.empty;
6037+
},
6038+
},
6039+
},
6040+
});
6041+
6042+
const wsUrl = yield* getWsServerUrl("/ws");
6043+
const result = yield* Effect.scoped(
6044+
withWsRpcClient(wsUrl, (client) =>
6045+
client[ORCHESTRATION_WS_METHODS.subscribeThread]({
6046+
threadId: defaultThreadId,
6047+
afterSequence: 1,
6048+
requestCompletionMarker: true,
6049+
}).pipe(Stream.take(3), Stream.runCollect),
6050+
),
6051+
).pipe(Effect.timeout("2 seconds"));
6052+
6053+
assert.equal(replayCalls, 0);
6054+
assert.equal(result[0]?.kind, "snapshot");
6055+
if (result[0]?.kind === "snapshot") {
6056+
assert.equal(result[0].snapshot.snapshotSequence, snapshotSequence);
6057+
assert.equal(result[0].snapshot.thread.id, defaultThreadId);
6058+
}
6059+
assert.deepEqual(result[1], { kind: "synchronized" });
6060+
assert.equal(result[2]?.kind, "event");
6061+
if (result[2]?.kind === "event") {
6062+
assert.equal(result[2].event.sequence, snapshotSequence + 1);
6063+
}
6064+
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
6065+
);
6066+
59896067
it.effect("subscribeShell coalesces a per-thread burst without stalling other threads", () =>
59906068
Effect.gen(function* () {
59916069
const busyThreadId = ThreadId.make("thread-busy");

0 commit comments

Comments
 (0)