Skip to content

Commit 75d25e2

Browse files
olafuraclaude
andcommitted
fix: harden lazy-load reset and dedup against window reshape (PR review)
Address the follow-up review on the reconnect/revert fix (web + mobile): - Stale in-flight load after reset: the reset cleared older pages but an in-flight load only checked the thread key (unchanged on a same-thread reshape), so its late result repopulated cleared state. Add a generation counter that bumps on every reset; the load captures it and drops its result if it changed. - Reset trigger now also fires when the live window SHRINKS (checkpoint revert that removes rows without changing the oldest row), not only when the oldest id changes (reconnect) — a pure append (same oldest, larger count) still keeps the loaded pages. Fixes the revert cases on web and mobile. - Empty page no longer churns: when dedup leaves nothing new, return without a new array reference and stop, so an unadvanced cursor can't loop identical requests. - Dedup against the full merged set (older + live) on both platforms. Also add a server regression test proving the "unsequenced cursor hides sequenced history" concern is a non-issue: NULL-sequence rows always sort oldest, so when the oldest loaded row is unsequenced every sequenced row is already in the window — the `sequence IS NULL` cursor reaches all older rows without stranding any. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8c40dd5 commit 75d25e2

3 files changed

Lines changed: 199 additions & 56 deletions

File tree

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

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -113,21 +113,46 @@ export function useThreadComposerState() {
113113
const activityRequestKey = selectedThreadShell
114114
? `${selectedThreadShell.environmentId}\u0000${selectedThreadShell.id}`
115115
: null;
116-
const activityRequestKeyRef = useRef(activityRequestKey);
117-
activityRequestKeyRef.current = activityRequestKey;
118-
119116
const liveActivities = selectedThreadDetail?.activities ?? EMPTY_ACTIVITIES;
120-
// The live window's oldest activity is stable while new activities only
121-
// append; it changes when the window is re-snapshotted (reconnect) or rows are
122-
// removed (checkpoint revert), either of which can make prepended older pages
123-
// stale or gappy — so reset the lazy-load state when it (or the thread) changes.
124117
const liveOldestActivityId = liveActivities[0]?.id ?? null;
118+
const liveActivityCount = liveActivities.length;
119+
// Bumps on every lazy-load reset so a late in-flight load can't repopulate the
120+
// freshly-cleared state (the thread key alone doesn't change on a same-thread
121+
// window reshape).
122+
const olderActivitiesGenRef = useRef(0);
123+
// The request key of an in-flight older-history load — coalesces the duplicate
124+
// dispatches the list fires before the loading state updates.
125+
const inFlightOlderKeyRef = useRef<string | null>(null);
126+
// Reset the lazy-loaded older pages when the live window is *reshaped* rather
127+
// than purely appended-to: a different thread or a re-snapshot (reconnect)
128+
// changes its oldest row, and a checkpoint revert removes rows so the count
129+
// shrinks. A pure append (same thread, same oldest, larger count) keeps them.
130+
const olderWindowRef = useRef({
131+
key: activityRequestKey,
132+
oldest: liveOldestActivityId,
133+
count: liveActivityCount,
134+
});
125135
useEffect(() => {
136+
const prev = olderWindowRef.current;
137+
olderWindowRef.current = {
138+
key: activityRequestKey,
139+
oldest: liveOldestActivityId,
140+
count: liveActivityCount,
141+
};
142+
const reshaped =
143+
activityRequestKey !== prev.key ||
144+
liveOldestActivityId !== prev.oldest ||
145+
liveActivityCount < prev.count;
146+
if (!reshaped) {
147+
return;
148+
}
149+
olderActivitiesGenRef.current += 1;
150+
inFlightOlderKeyRef.current = null;
126151
setOlderActivities([]);
127152
setOlderLoaded(false);
128153
setOlderHasMore(false);
129154
setLoadingOlderActivities(false);
130-
}, [activityRequestKey, liveOldestActivityId]);
155+
}, [activityRequestKey, liveOldestActivityId, liveActivityCount]);
131156
const mergedActivities = useMemo(
132157
() =>
133158
olderActivities.length > 0 ? [...olderActivities, ...liveActivities] : liveActivities,
@@ -138,10 +163,6 @@ export function useThreadComposerState() {
138163
? olderHasMore
139164
: (selectedThreadDetail?.hasMoreActivities ?? false);
140165

141-
// Synchronous in-flight guard keyed by thread: the list fires onLoadOlder
142-
// repeatedly while pinned at the top, but loading *state* only updates next
143-
// render, so without this a fast scroll dispatches duplicate same-cursor calls.
144-
const inFlightOlderKeyRef = useRef<string | null>(null);
145166
const onLoadOlderActivities = useCallback(() => {
146167
if (!selectedThreadShell || !hasMoreOlderActivities) {
147168
return;
@@ -158,39 +179,42 @@ export function useThreadComposerState() {
158179
? { beforeSequence: oldestActivity.sequence }
159180
: { beforeCreatedAt: oldestActivity.createdAt, beforeActivityId: oldestActivity.id };
160181
const requestKey = activityRequestKey;
182+
const gen = olderActivitiesGenRef.current;
161183
inFlightOlderKeyRef.current = requestKey;
162184
setLoadingOlderActivities(true);
163185
void loadThreadActivities({
164186
environmentId: selectedThreadShell.environmentId,
165187
input: { threadId: selectedThreadShell.id, ...cursorInput },
166188
})
167189
.then((result) => {
168-
if (activityRequestKeyRef.current !== requestKey) {
190+
// Window/thread reset while in flight — drop the page so it can't
191+
// repopulate state cleared by the reset.
192+
if (olderActivitiesGenRef.current !== gen) {
169193
return;
170194
}
171195
if (result._tag !== "Success") {
172196
return;
173197
}
174198
const page = result.value;
175-
setOlderActivities((prev) => {
176-
// Dedup against both already-loaded older pages and the live window,
177-
// since mobile merges everything into one array (duplicate ids would
178-
// produce duplicate React keys in the feed).
179-
const seen = new Set(prev.map((activity) => activity.id));
180-
for (const activity of liveActivities) {
181-
seen.add(activity.id);
182-
}
183-
const fresh = page.activities.filter((activity) => !seen.has(activity.id));
184-
return [...fresh, ...prev];
185-
});
199+
// Dedup against everything already loaded (older pages + live window).
200+
// Loads are serialized by the in-flight ref, so mergedActivities is a
201+
// current snapshot here.
202+
const seen = new Set(mergedActivities.map((activity) => activity.id));
203+
const fresh = page.activities.filter((activity) => !seen.has(activity.id));
204+
if (fresh.length === 0) {
205+
// Nothing new — stop rather than re-dispatch the same (unadvanced)
206+
// cursor in a loop, and don't churn the array reference.
207+
setOlderLoaded(true);
208+
setOlderHasMore(false);
209+
return;
210+
}
211+
setOlderActivities((prev) => [...fresh, ...prev]);
186212
setOlderLoaded(true);
187213
setOlderHasMore(page.hasMore);
188214
})
189215
.finally(() => {
190-
if (inFlightOlderKeyRef.current === requestKey) {
216+
if (olderActivitiesGenRef.current === gen) {
191217
inFlightOlderKeyRef.current = null;
192-
}
193-
if (activityRequestKeyRef.current === requestKey) {
194218
setLoadingOlderActivities(false);
195219
}
196220
});
@@ -199,7 +223,6 @@ export function useThreadComposerState() {
199223
hasMoreOlderActivities,
200224
mergedActivities,
201225
activityRequestKey,
202-
liveActivities,
203226
loadThreadActivities,
204227
]);
205228

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,103 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
13091309
}),
13101310
);
13111311

1312+
it.effect(
1313+
"unsequenced cursor reaches all older rows without stranding sequenced ones",
1314+
() =>
1315+
// Regression for the "unsequenced cursor hides sequenced history" concern:
1316+
// sequenced rows always sort newer than NULL-sequence (legacy) rows, so when
1317+
// the oldest loaded row is unsequenced every sequenced row is already in the
1318+
// window — the `sequence IS NULL` cursor can't strand sequenced rows.
1319+
Effect.gen(function* () {
1320+
const snapshotQuery = yield* ProjectionSnapshotQuery;
1321+
const sql = yield* SqlClient.SqlClient;
1322+
yield* sql`DELETE FROM projection_projects`;
1323+
yield* sql`DELETE FROM projection_threads`;
1324+
yield* sql`DELETE FROM projection_thread_activities`;
1325+
yield* sql`DELETE FROM projection_state`;
1326+
yield* sql`
1327+
INSERT INTO projection_projects (
1328+
project_id, title, workspace_root, default_model_selection_json,
1329+
scripts_json, created_at, updated_at, deleted_at
1330+
) VALUES (
1331+
'project-1', 'Project 1', '/tmp/project-1',
1332+
'{"provider":"codex","model":"gpt-5-codex"}', '[]',
1333+
'2026-04-01T00:00:00.000Z', '2026-04-01T00:00:01.000Z', NULL
1334+
)
1335+
`;
1336+
yield* sql`
1337+
INSERT INTO projection_threads (
1338+
thread_id, project_id, title, model_selection_json, runtime_mode,
1339+
interaction_mode, branch, worktree_path, latest_turn_id,
1340+
latest_user_message_at, pending_approval_count, pending_user_input_count,
1341+
has_actionable_proposed_plan, created_at, updated_at, archived_at, deleted_at
1342+
) VALUES (
1343+
'thread-1', 'project-1', 'Thread 1',
1344+
'{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default',
1345+
NULL, NULL, NULL, NULL, 0, 0, 0,
1346+
'2026-04-01T00:00:02.000Z', '2026-04-01T00:00:03.000Z', NULL, NULL
1347+
)
1348+
`;
1349+
// 600 legacy unsequenced rows (older) + 3 sequenced rows (newer). The
1350+
// window keeps the 3 sequenced + the most-recent 497 unsequenced, so the
1351+
// oldest loaded row is unsequenced and 103 older unsequenced remain.
1352+
yield* Effect.forEach(
1353+
Array.from({ length: 600 }, (_u, index) => index + 1),
1354+
(n) =>
1355+
sql`
1356+
INSERT INTO projection_thread_activities (
1357+
activity_id, thread_id, turn_id, tone, kind, summary, payload_json,
1358+
sequence, created_at
1359+
) VALUES (
1360+
${`unseq-${String(n).padStart(4, "0")}`}, 'thread-1', NULL,
1361+
'info', 'runtime.note', ${`unseq-${n}`}, '{}', NULL,
1362+
${`2026-04-01T00:00:01.${String(n).padStart(3, "0")}Z`}
1363+
)
1364+
`,
1365+
{ discard: true },
1366+
);
1367+
yield* Effect.forEach(
1368+
[1, 2, 3],
1369+
(seq) =>
1370+
sql`
1371+
INSERT INTO projection_thread_activities (
1372+
activity_id, thread_id, turn_id, tone, kind, summary, payload_json,
1373+
sequence, created_at
1374+
) VALUES (
1375+
${`seq-${seq}`}, 'thread-1', NULL, 'info', 'runtime.note',
1376+
${`seq-${seq}`}, '{}', ${seq}, ${`2026-04-01T09:00:0${seq}.000Z`}
1377+
)
1378+
`,
1379+
{ discard: true },
1380+
);
1381+
1382+
const detail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
1383+
assert.equal(detail._tag, "Some");
1384+
if (detail._tag !== "Some") return;
1385+
const windowed = detail.value.activities;
1386+
assert.equal(windowed.length, 500);
1387+
// Sequenced rows are the newest (end of the ascending window); the oldest
1388+
// loaded row is unsequenced — exactly the case the concern is about.
1389+
assert.equal(windowed.at(-1)?.summary, "seq-3");
1390+
assert.equal(windowed[0]?.sequence, undefined);
1391+
1392+
// The client pages with the unsequenced cursor of the oldest loaded row.
1393+
const oldest = windowed[0];
1394+
assert.ok(oldest);
1395+
const olderPage = yield* snapshotQuery.getThreadActivitiesPage({
1396+
threadId: ThreadId.make("thread-1"),
1397+
beforeCreatedAt: oldest.createdAt,
1398+
beforeActivityId: oldest.id,
1399+
limit: 500,
1400+
});
1401+
// The 103 older unsequenced rows come back, none are sequenced, and no
1402+
// sequenced row was stranded (all 3 are already in the window).
1403+
assert.equal(olderPage.activities.length, 103);
1404+
assert.equal(olderPage.hasMore, false);
1405+
assert.ok(olderPage.activities.every((a) => a.sequence === undefined));
1406+
}),
1407+
);
1408+
13121409
it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () =>
13131410
Effect.gen(function* () {
13141411
const snapshotQuery = yield* ProjectionSnapshotQuery;

apps/web/src/components/ChatView.tsx

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,20 +1702,46 @@ function ChatViewContent(props: ChatViewProps) {
17021702
const activeThreadActivityRequestKey = activeThread
17031703
? `${activeThread.environmentId}\u0000${activeThread.id}`
17041704
: null;
1705-
const activeThreadActivityRequestKeyRef = useRef(activeThreadActivityRequestKey);
1706-
activeThreadActivityRequestKeyRef.current = activeThreadActivityRequestKey;
17071705
const liveThreadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
1708-
// The live window's oldest activity is stable while new activities only
1709-
// append; it changes when the window is re-snapshotted (reconnect) or rows are
1710-
// removed (checkpoint revert), either of which can make prepended older pages
1711-
// stale or gappy — so reset the lazy-load state when it (or the thread) changes.
17121706
const liveOldestActivityId = liveThreadActivities[0]?.id ?? null;
1707+
const liveActivityCount = liveThreadActivities.length;
1708+
// Bumps on every lazy-load reset so a late in-flight load can't repopulate the
1709+
// freshly-cleared state (the thread key alone doesn't change on a same-thread
1710+
// window reshape).
1711+
const olderActivitiesGenRef = useRef(0);
1712+
// The request key of an in-flight older-history load — coalesces the duplicate
1713+
// dispatches a fast scroll-to-top fires before the loading state updates.
1714+
const inFlightOlderKeyRef = useRef<string | null>(null);
1715+
// Reset the lazy-loaded older pages when the live window is *reshaped* rather
1716+
// than purely appended-to: a different thread or a re-snapshot (reconnect)
1717+
// changes its oldest row, and a checkpoint revert removes rows so the count
1718+
// shrinks. A pure append (same thread, same oldest, larger count) keeps them.
1719+
const olderWindowRef = useRef({
1720+
key: activeThreadActivityRequestKey,
1721+
oldest: liveOldestActivityId,
1722+
count: liveActivityCount,
1723+
});
17131724
useEffect(() => {
1725+
const prev = olderWindowRef.current;
1726+
olderWindowRef.current = {
1727+
key: activeThreadActivityRequestKey,
1728+
oldest: liveOldestActivityId,
1729+
count: liveActivityCount,
1730+
};
1731+
const reshaped =
1732+
activeThreadActivityRequestKey !== prev.key ||
1733+
liveOldestActivityId !== prev.oldest ||
1734+
liveActivityCount < prev.count;
1735+
if (!reshaped) {
1736+
return;
1737+
}
1738+
olderActivitiesGenRef.current += 1;
1739+
inFlightOlderKeyRef.current = null;
17141740
setOlderActivities([]);
17151741
setOlderLoaded(false);
17161742
setOlderHasMore(false);
17171743
setLoadingOlderActivities(false);
1718-
}, [activeThreadActivityRequestKey, liveOldestActivityId]);
1744+
}, [activeThreadActivityRequestKey, liveOldestActivityId, liveActivityCount]);
17191745

17201746
const threadActivities = useMemo(
17211747
() =>
@@ -1729,11 +1755,6 @@ function ChatViewContent(props: ChatViewProps) {
17291755
const hasMoreOlderActivities = olderLoaded
17301756
? olderHasMore
17311757
: (activeThread?.hasMoreActivities ?? false);
1732-
// Tracks the request key of an in-flight older-history load. The scroll
1733-
// handler fires onLoadOlder on every frame while at the top, but the loading
1734-
// *state* only updates on the next render — without a synchronous guard a fast
1735-
// scroll-to-top dispatches several duplicate requests for the same cursor.
1736-
const inFlightOlderKeyRef = useRef<string | null>(null);
17371758
const loadOlderActivities = useCallback(() => {
17381759
if (!activeThread || !hasMoreOlderActivities) {
17391760
return;
@@ -1750,39 +1771,42 @@ function ChatViewContent(props: ChatViewProps) {
17501771
? { beforeSequence: oldestActivity.sequence }
17511772
: { beforeCreatedAt: oldestActivity.createdAt, beforeActivityId: oldestActivity.id };
17521773
const requestKey = activeThreadActivityRequestKey;
1774+
const gen = olderActivitiesGenRef.current;
17531775
inFlightOlderKeyRef.current = requestKey;
17541776
setLoadingOlderActivities(true);
17551777
void loadThreadActivities({
17561778
environmentId: activeThread.environmentId,
17571779
input: { threadId: activeThread.id, ...cursorInput },
17581780
})
17591781
.then((result) => {
1760-
if (activeThreadActivityRequestKeyRef.current !== requestKey) {
1782+
// The window/thread was reset while this was in flight — drop the page
1783+
// so it can't repopulate state cleared by the reset.
1784+
if (olderActivitiesGenRef.current !== gen) {
17611785
return;
17621786
}
17631787
if (result._tag !== "Success") {
17641788
return;
17651789
}
17661790
const page = result.value;
1767-
setOlderActivities((prev) => {
1768-
// Dedup against both already-loaded older pages and the live window so
1769-
// an overlap at the window boundary can't leave duplicate ids (which
1770-
// would break timeline keys and work-log derivation).
1771-
const seen = new Set(prev.map((activity) => activity.id));
1772-
for (const activity of liveThreadActivities) {
1773-
seen.add(activity.id);
1774-
}
1775-
const fresh = page.activities.filter((activity) => !seen.has(activity.id));
1776-
return [...fresh, ...prev];
1777-
});
1791+
// Dedup against everything already loaded (older pages + live window) so a
1792+
// boundary overlap can't leave duplicate ids. Loads are serialized by the
1793+
// in-flight ref, so `threadActivities` is a current snapshot here.
1794+
const seen = new Set(threadActivities.map((activity) => activity.id));
1795+
const fresh = page.activities.filter((activity) => !seen.has(activity.id));
1796+
if (fresh.length === 0) {
1797+
// The page delivered nothing new; stop rather than re-dispatch the same
1798+
// (unadvanced) cursor in a loop, and don't churn the array reference.
1799+
setOlderLoaded(true);
1800+
setOlderHasMore(false);
1801+
return;
1802+
}
1803+
setOlderActivities((prev) => [...fresh, ...prev]);
17781804
setOlderLoaded(true);
17791805
setOlderHasMore(page.hasMore);
17801806
})
17811807
.finally(() => {
1782-
if (inFlightOlderKeyRef.current === requestKey) {
1808+
if (olderActivitiesGenRef.current === gen) {
17831809
inFlightOlderKeyRef.current = null;
1784-
}
1785-
if (activeThreadActivityRequestKeyRef.current === requestKey) {
17861810
setLoadingOlderActivities(false);
17871811
}
17881812
});
@@ -1791,7 +1815,6 @@ function ChatViewContent(props: ChatViewProps) {
17911815
activeThreadActivityRequestKey,
17921816
hasMoreOlderActivities,
17931817
threadActivities,
1794-
liveThreadActivities,
17951818
loadThreadActivities,
17961819
]);
17971820

0 commit comments

Comments
 (0)