Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1322,4 +1322,56 @@ describe('makerChatStore active view tracking', () => {
expect(makerChatStore.getSnapshot(sessionId).messages).toHaveLength(0);
expect(makerChatStore.getSnapshot(sessionId).historyLoaded).toBe(false);
});

// Regression: when ensureInitialMessages backfill is invalidated by an epoch
// change (e.g. reloadMessages), isLoadingMore must be released so that
// loadOlderMessages is not permanently blocked.
it('releases isLoadingMore when initial backfill is invalidated by epoch change', async () => {
const sessionId = sid('initial-backfill-invalidation-lock');
const latestPage = Array.from({ length: 49 }, (_, i) =>
dbMessage(
sessionId,
`latest-${String(i).padStart(2, '0')}`,
`latest message ${i}`,
new Date(BASE_TIME.getTime() + (60 + i) * 1000).toISOString(),
),
);
const latestPlan = dbToolUseMessage(
sessionId,
'latest-plan',
'TaskUpdate',
{ taskId: 'abc', status: 'completed' },
new Date(BASE_TIME.getTime() + 120_000).toISOString(),
);
let resolveOlderPage!: (rows: Message[]) => void;
vi.mocked(messageService.list)
.mockResolvedValueOnce([...latestPage, latestPlan])
.mockReturnValueOnce(
new Promise<Message[]>((resolve) => {
resolveOlderPage = resolve;
}),
);

makerChatStore.ensureInitialMessages(sessionId);
await flushPromises(4);

// Backfill is in progress, lock held.
expect(makerChatStore.getSnapshot(sessionId).isLoadingMore).toBe(true);
expect(messageService.list).toHaveBeenCalledTimes(2);

// Epoch change invalidates the in-flight backfill.
makerChatStore.reloadMessages(sessionId);
await Promise.resolve();

// Resolve the pending backfill — it should detect invalidation and exit.
resolveOlderPage([
dbMessage(sessionId, 'older-visible', 'older visible message', BASE_TIME.toISOString()),
]);
await flushPromises();

// isLoadingMore must have been released despite the invalidation path.
expect(makerChatStore.getSnapshot(sessionId).isLoadingMore).toBe(false);
});


});
8 changes: 7 additions & 1 deletion apps/desktop/src/renderer/lib/makerChatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9931,6 +9931,12 @@ function retryInvalidatedInitialHistoryFetchIfNeeded(
epoch: number,
): void {
const ownsFetch = _historyFetchToken.get(sessionId) === token;
// Release the shared pagination lock only when this fetch still owns the
// token. A stale callback from a superseded fetch must not clear the lock
// that a newer replacement backfill is actively holding.
if (ownsFetch) {
setState(sessionId, (s) => (s.isLoadingMore ? { ...s, isLoadingMore: false } : s));
}
const epochChanged = (_messagesEpoch.get(sessionId) ?? 0) !== epoch;
const originUnchanged = remoteProjectsStore.getSessionDeviceId(sessionId) === origin;
releaseHistoryFetchIfCurrent(sessionId, token);
Expand Down Expand Up @@ -10490,7 +10496,7 @@ function ensureInitialMessages(sessionId: string): void {
// rewind 之类的粘滞抑制(见 releaseCacheHydrationAfterFailure)。屏上已 hydrate 的
// 缓存行**保持不动**:离线时它是用户唯一能看到的历史,清掉纯属倒退。
releaseCacheHydrationAfterFailure(sessionId);
setState(sessionId, (s) => ({ ...s, historyLoaded: false }));
setState(sessionId, (s) => ({ ...s, historyLoaded: false, isLoadingMore: false }));
});
}

Expand Down
Loading