diff --git a/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts b/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts index 3bc54541bf..c3d0af7028 100644 --- a/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts @@ -974,7 +974,7 @@ describe('makerChatStore active view tracking', () => { await makerChatStore.loadAroundMessage(sessionId, 'hit', { radius: 60 }); // 阶段一:窗口里只有孤岛 → 必须播种,游标为 null 会让下一次翻页从最新重开、把跳转位置顶掉。 expect(makerChatStore.getSnapshot(sessionId).oldestMessageId).toBe('older-hit-context'); - expect(makerChatStore.getSnapshot(sessionId).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(sessionId).historyWindowIslands).toHaveLength(1); expect(makerChatStore.getLightSnapshot(sessionId).historyWindowHasIsland).toBe(true); resolveInitialList([ @@ -994,8 +994,8 @@ describe('makerChatStore active view tracking', () => { ]); // 阶段二:最新页落地 → 游标交还给它的下沿,往上翻才会穿过孤岛与尾段之间的缺失区间。 expect(snapshot.oldestMessageId).toBe('latest-page-oldest'); - // 洞还在,孤岛标记不清 —— 下一次跳转仍会尝试补齐。 - expect(snapshot.historyWindowHasIsland).toBe(true); + // 洞还在,孤岛区间不清 —— 下一次跳转仍会尝试补齐。 + expect(snapshot.historyWindowIslands).toHaveLength(1); }); it('keeps loadOlder history chronological after thinking timestamps are backdated', async () => { diff --git a/apps/desktop/src/renderer/__tests__/makerChatStoreJumpBackfill.test.ts b/apps/desktop/src/renderer/__tests__/makerChatStoreJumpBackfill.test.ts index 27fd6e9c94..0d766977b0 100644 --- a/apps/desktop/src/renderer/__tests__/makerChatStoreJumpBackfill.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerChatStoreJumpBackfill.test.ts @@ -71,6 +71,7 @@ vi.mock('@/lib/composerDraftStore', () => ({ })); import { makerChatStore } from '@/lib/makerChatStore'; +import { canFocusWithoutJumpLoad } from '@/lib/searchJumpTargeting'; import { aroundMessagesByClientIdFor, listMessagesFor } from '@/lib/makerTransport'; import { markSessionAutomaticHistoryLoadCompleted, @@ -764,7 +765,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { await makerChatStore.loadAroundMessageClientId(SID, 'island', { radius: 60 }); expect(makerChatStore.getSnapshot(SID).messages.map((m) => m.clientId)).toContain('island'); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(1); // 第 2 次跳转同一目标:目标已在窗口里,但那是孤岛 —— 必须重新尝试翻页补齐, // 而且「取回一页无关的行」不能算作已覆盖(review #676:判定必须看本页是否真的取到 @@ -777,23 +778,28 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { await makerChatStore.loadAroundMessageClientId(SID, 'island', { radius: 60 }); expect(vi.mocked(listMessagesFor).mock.calls.length).toBeGreaterThan(callsBefore); - // 关键:没真的取到目标 → 孤岛标记必须还在,下次跳转仍会重试。 - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + // 关键:没真的取到目标 → 孤岛区间必须还在,下次跳转仍会重试。 + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(1); // 第 3 次跳转:这次翻页真的取到目标 → 本次算覆盖(不再退回 around fallback)。 - // 但孤岛标记**不清**:到达本次目标只证明「尾部 → 本目标」连续,不证明更早的孤岛都被 - // 跨过(review #676 的两孤岛序列)。只由窗口整体重建清零。 + // 显式建模下,这座孤岛本身就是目标、又被翻页真的跨过 → 从模型移除;更早的孤岛 + // (两孤岛序列里先落下的那座)不受波及,由「T2」用例覆盖。 const callsBefore3 = vi.mocked(listMessagesFor).mock.calls.length; vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([target]); vi.mocked(listMessagesFor).mockResolvedValueOnce([target]); const found = await makerChatStore.loadAroundMessageClientId(SID, 'island', { radius: 60 }); expect(found?.clientId).toBe('island'); expect(vi.mocked(listMessagesFor).mock.calls.length).toBeGreaterThan(callsBefore3); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); - - // 窗口整体重建(reloadMessages 语义)才把标记清零。 + // 关键:被跨过的孤岛移出模型 → 窗口内搜索可以零成本 focus,不再每次白打一轮探测。 + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); + expect(makerChatStore.getSnapshot(SID).messages.map((m) => m.clientId)).toEqual([ + 'island', + 'noise', + 'tail', + ]); + // 窗口整体重建(reloadMessages 语义)保持空模型。 makerChatStore.reloadMessages(SID); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); }); it('U. 预算对照窗口总量,连续多次跳转不会各自重新起算', async () => { @@ -959,7 +965,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { it('Z. busy 让位时 around 一行没新增,不得凭空记上孤岛', async () => { // review #676(codex P1):busy 是在成员快速通道**之前**返回的(锁优先),所以"目标本来 - // 就在连续窗口里"也会走到 busy 的 fallback。那里原先无条件置 historyWindowHasIsland, + // 就在连续窗口里"也会走到 busy 的 fallback。那里原先无条件记孤岛, // 于是一个本来连续的窗口被永久标成不连续:此后每次窗口内搜索都绕过直接 focus、从 // oldestMessageId 往上补齐,而那个游标比已加载的目标更老、翻页永远碰不到它。 const seeded = fullPageNewestFirst(); @@ -969,7 +975,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([inWindow]); vi.mocked(listMessagesFor).mockResolvedValueOnce(seeded); await makerChatStore.loadAroundMessageClientId(SID, inWindow.clientId, { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBe(0); const windowSize = makerChatStore.getSnapshot(SID).messages.length; // 让普通向上分页占住锁。 @@ -990,7 +996,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { expect(makerChatStore.getSnapshot(SID).messages).toHaveLength(windowSize); // 关键:一行都没加进来 → 窗口连续性没有变化,标记不得被点亮。 - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBe(0); releasePage([]); await flushMicrotasks(); @@ -1109,7 +1115,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([island]); vi.mocked(listMessagesFor).mockResolvedValueOnce([]); await makerChatStore.loadAroundMessageClientId(SID, 'island-row', { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); // 第 3 步:再跳目标。around 返回的是"取快照那一刻"的旧内容,补齐停在飞行中。 vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([target]); @@ -1161,7 +1167,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([island]); vi.mocked(listMessagesFor).mockResolvedValueOnce([]); await makerChatStore.loadAroundMessageClientId(SID, 'island-row', { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); // 目标此刻**不在**窗口里。 expect( makerChatStore.getSnapshot(SID).messages.some((m) => m.clientId === 'arrived-target'), @@ -1221,7 +1227,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { vi.mocked(listMessagesFor).mockResolvedValueOnce(seeded); await makerChatStore.loadAroundMessageClientId(SID, inWindow.clientId, { radius: 60 }); const before = makerChatStore.getSnapshot(SID); - expect(before.historyWindowHasIsland).toBe(false); + expect(before.historyWindowIslands.length).toBe(0); const cursorBefore = before.oldestMessageId; const hasMoreBefore = before.hasMoreMessages; const callsBefore = vi.mocked(listMessagesFor).mock.calls.length; @@ -1298,7 +1304,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { vi.mocked(aroundMessagesByClientIdFor).mockResolvedValue([target]); vi.mocked(listMessagesFor).mockResolvedValue([]); await makerChatStore.loadAroundMessageClientId(SID, 'only-row', { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); // 有新行加进来 → 边界前移,hasMore 置 true 是对的。 expect(makerChatStore.getSnapshot(SID).hasMoreMessages).toBe(true); @@ -1311,11 +1317,13 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { expect(makerChatStore.getSnapshot(SID).hasMoreMessages).toBe(false); }); - it('Y. 超长裁剪不清孤岛标记(裁剪只保证"最新 200 行",不保证连续)', async () => { - // review #676(codex P1):slice(-TRIM_TARGET) 取的是最新 200 行,不等于"连续的最新 - // 一段"。若先前几次深跳留下多个孤岛、真正连续的尾段不足 200 行,裁剪结果里还夹着孤岛。 - // 清掉标记会让 canFocusWithoutJumpLoad 把命中孤岛当成已覆盖直接 focus,而从孤岛边界 - // 往上翻又取不到那段更新的缺失区间 → 洞永久固化。 + it('Y. 超长裁剪:整座被裁掉的孤岛随洞消失,保留的连续尾段恢复主段', async () => { + // review #676(codex P1)的原始口径是"裁剪不清孤岛标记",那只对粗粒度 boolean 成立 + // (boolean 无法判断孤岛是否还在窗口里)。显式孤岛模型按"孤岛是否真的还留在窗口里" + // 精确收口(pruneIslandsForTrimmedWindow):整座落在 slice(-TRIM_TARGET) 裁掉一侧的 + // 孤岛连行都没了,标记没有意义,随洞一起消失;仍夹在保留窗口里的孤岛必须保留 + // (清掉会让 canFocusWithoutJumpLoad 把命中孤岛当成已覆盖直接 focus,洞永久固化), + // 那条不变量由 Y3 覆盖 —— 260 行孤岛 + 50 行尾段,裁剪后仍夹着孤岛。 const target = serverMessage({ id: 'island-trim', clientId: 'island-trim', @@ -1335,7 +1343,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { ); }); await makerChatStore.loadAroundMessageClientId(SID, 'island-trim', { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); expect(makerChatStore.getSnapshot(SID).messages.length).toBeGreaterThan(300); markSessionAutomaticHistoryLoadCompleted(SID); @@ -1344,8 +1352,13 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { leave(); expect(makerChatStore.getSnapshot(SID).messages).toHaveLength(200); - // 关键:裁剪不清标记 —— 保留的 200 行未必连续。 - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + // 孤岛整座落在被裁掉的最老一侧 → 精确收口出模型。 + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); + // 保留的 200 行全部来自同一条连续翻页链,没有洞 → 窗口内搜索恢复零成本 focus。 + const retained = makerChatStore.getSnapshot(SID).messages; + expect(canFocusWithoutJumpLoad(makerChatStore.getSnapshot(SID), retained[0].clientId)).toBe( + true, + ); // 普通裁剪正是原问题的重挂载场景:消息窗口仍属同一代,自动补载预算必须保持耗尽。 expect(restoreSessionAutomaticHistoryLoadAttempts(SID, 5)).toBe(5); }); @@ -1406,7 +1419,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { await makerChatStore.loadAroundMessageClientId(SID, 'island-000', { radius: 60 }); expect(makerChatStore.getSnapshot(SID).messages.length).toBeGreaterThan(300); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); const contiguousCursor = makerChatStore.getSnapshot(SID).oldestMessageId; expect(contiguousCursor).toBe('tail-49'); markSessionAutomaticHistoryLoadCompleted(SID); @@ -1416,7 +1429,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { const trimmed = makerChatStore.getSnapshot(SID); expect(trimmed.messages).toHaveLength(200); - expect(trimmed.historyWindowHasIsland).toBe(true); + expect(trimmed.historyWindowIslands.length).toBeGreaterThan(0); expect(trimmed.historyLoaded).toBe(true); expect(trimmed.messages[0]?.clientId.startsWith('island-')).toBe(true); expect(trimmed.messages.some((message) => message.clientId === 'tail-49')).toBe(true); @@ -1438,9 +1451,9 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { SID, expect.objectContaining({ beforeTs: expect.any(Number) }), ); - expect(makerChatStore.getSnapshot(SID).messages.some((message) => message.clientId === 'gap-plan')).toBe( - true, - ); + expect( + makerChatStore.getSnapshot(SID).messages.some((message) => message.clientId === 'gap-plan'), + ).toBe(true); }); it('Y4. 孤岛与尾段间隔不足 HISTORY_GAP_SPLIT_MS 时,裁剪仍保留结构化游标', async () => { @@ -1462,7 +1475,7 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { vi.mocked(listMessagesFor).mockResolvedValueOnce(newestTail); await makerChatStore.loadAroundMessageClientId(SID, 'near-island-000', { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); const contiguousCursor = makerChatStore.getSnapshot(SID).oldestMessageId; expect(contiguousCursor).toBe('near-tail-49'); markSessionAutomaticHistoryLoadCompleted(SID); @@ -1492,13 +1505,132 @@ describe('跳转补齐 — 窗口连续,不留历史空洞', () => { serverMessage({ id: 'tail-c', clientId: 'tail-c', createdAt: '2026-07-25T12:00:00.000Z' }), ]); await makerChatStore.loadAroundMessageClientId(SID, 'island-clear', { radius: 60 }); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBeGreaterThan(0); makerChatStore.clearSession(SID); await flushMicrotasks(); // 窗口空了 → 按构造没有孤岛。 expect(makerChatStore.getSnapshot(SID).messages).toHaveLength(0); - expect(makerChatStore.getSnapshot(SID).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands.length).toBe(0); + }); + + it('T2. 两孤岛序列:补齐只收口被跨过的那座,更早的孤岛保留;再跳它仍走补齐', async () => { + // feat B 显式模型:covered 只证明「尾部 → 本次目标」连续,不证明更早的孤岛被跨过 + // (#676 review 给的两孤岛序列)。先造一座靠窗的孤岛 near,再造一座更老的 far; + // 成功跳到 near 时只有被翻页真的跨过的 near 收口出模型,far 保留;再跳 far 仍必须 + // 重新翻页补齐,不被主段快速通道短路。 + const seeded = fullPageNewestFirst(); + const inWindow = seeded[50]; + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([inWindow]); + vi.mocked(listMessagesFor).mockResolvedValueOnce(seeded); + await makerChatStore.loadAroundMessageClientId(SID, inWindow.clientId, { radius: 60 }); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); + + // 孤岛 near:靠窗一侧,07-20。 + const near = serverMessage({ + id: 'near-island-2', + clientId: 'near-island-2', + createdAt: '2026-07-20T00:00:00.000Z', + }); + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([near]); + vi.mocked(listMessagesFor).mockResolvedValueOnce([ + serverMessage({ + id: 'noise-near-2', + clientId: 'noise-near-2', + createdAt: '2026-07-24T00:00:00.000Z', + }), + ]); + await makerChatStore.loadAroundMessageClientId(SID, near.clientId, { radius: 60 }); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(1); + + // 孤岛 far:更老一侧,07-01,与 near 不相连。 + const far = serverMessage({ + id: 'far-island-2', + clientId: 'far-island-2', + createdAt: '2026-07-01T00:00:00.000Z', + }); + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([far]); + vi.mocked(listMessagesFor).mockResolvedValueOnce([]); + await makerChatStore.loadAroundMessageClientId(SID, far.clientId, { radius: 60 }); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(2); + expect( + makerChatStore.getSnapshot(SID).historyWindowIslands.map((i) => i.oldestClientId), + ).toEqual(['far-island-2', 'near-island-2']); + + // 成功跳到 near:翻页真的取到 near → 只有这座被跨过的孤岛收口,far 保留。 + const callsBeforeNear = vi.mocked(listMessagesFor).mock.calls.length; + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([near]); + vi.mocked(listMessagesFor).mockResolvedValueOnce([near]); + const foundNear = await makerChatStore.loadAroundMessageClientId(SID, near.clientId, { + radius: 60, + }); + expect(foundNear?.clientId).toBe(near.clientId); + expect(vi.mocked(listMessagesFor).mock.calls.length).toBeGreaterThan(callsBeforeNear); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(1); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands[0].oldestClientId).toBe( + far.clientId, + ); + + // 再跳 far:它仍是孤岛、不在主段内 → 必须重新翻页补齐,不被快速通道短路。 + const callsBeforeFar = vi.mocked(listMessagesFor).mock.calls.length; + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([far]); + vi.mocked(listMessagesFor).mockResolvedValueOnce([far]); + const foundFar = await makerChatStore.loadAroundMessageClientId(SID, far.clientId, { + radius: 60, + }); + expect(foundFar?.clientId).toBe(far.clientId); + expect(vi.mocked(listMessagesFor).mock.calls.length).toBeGreaterThan(callsBeforeFar); + // 补齐把 far 也真的跨过 → 模型清零,窗口内搜索恢复零成本 focus。 + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); + }); + + it('T3. 翻页把缺口填上、孤岛收口后,同一目标零成本直跳(不再白打探测)', async () => { + // feat B 目标:孤岛被向上翻页**真的跨过**(absorbIslandsCrossedByPaging)后从模型消失, + // canFocusWithoutJumpLoad 恢复 true —— 生产搜索 effect 直接 focus,不再每次白打一轮 + // around + list;store 侧快速通道也零分页请求。 + const seeded = fullPageNewestFirst(); + const inWindow = seeded[50]; + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([inWindow]); + vi.mocked(listMessagesFor).mockResolvedValueOnce(seeded); + await makerChatStore.loadAroundMessageClientId(SID, inWindow.clientId, { radius: 60 }); + + // 失败深跳留下孤岛(翻页取不到目标 → 退回 around 窗口)。 + const target = serverMessage({ + id: 'gap-fill-target', + clientId: 'gap-fill-target', + createdAt: '2026-07-01T00:00:00.000Z', + }); + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([target]); + vi.mocked(listMessagesFor).mockResolvedValueOnce([ + serverMessage({ + id: 'gap-noise', + clientId: 'gap-noise', + createdAt: '2026-07-24T00:00:00.000Z', + }), + ]); + await makerChatStore.loadAroundMessageClientId(SID, target.clientId, { radius: 60 }); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(1); + // 孤岛期间:生产入口拒绝零成本 focus,每次窗口内搜索都会走补齐。 + expect(canFocusWithoutJumpLoad(makerChatStore.getSnapshot(SID), target.clientId)).toBe(false); + + // 普通向上翻页把缺口填上:本页最老行就是目标 → 孤岛被真的跨过,收口出模型。 + vi.mocked(listMessagesFor).mockResolvedValueOnce([target]); + const didAdvance = await makerChatStore.loadOlderMessages(SID); + expect(didAdvance).toBe(true); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); + + // 窗口内搜索:生产入口此刻判定可直接 focus(零网络)。 + expect(canFocusWithoutJumpLoad(makerChatStore.getSnapshot(SID), target.clientId)).toBe(true); + + // store 侧重跳同一条:快速通道 covered,不再发任何分页请求。 + const listCallsAfterFill = vi.mocked(listMessagesFor).mock.calls.length; + vi.mocked(aroundMessagesByClientIdFor).mockResolvedValueOnce([target]); + const found = await makerChatStore.loadAroundMessageClientId(SID, target.clientId, { + radius: 60, + }); + expect(found?.clientId).toBe(target.clientId); + expect(vi.mocked(listMessagesFor).mock.calls.length).toBe(listCallsAfterFill); + expect(makerChatStore.getSnapshot(SID).historyWindowIslands).toHaveLength(0); }); }); diff --git a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts index 9092e2e8a5..49a02f8727 100644 --- a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts @@ -84,6 +84,7 @@ const state = (overrides: Partial = {}): SessionChatState => ( continuationInFlightProjectionCapability: 'unknown', isLoadingMore: false, hasMoreMessages: false, + historyWindowIslands: [], isFirstMessage: false, streamingClientId: null, streamingText: '', diff --git a/apps/desktop/src/renderer/__tests__/reconcileRemoteMessages.test.ts b/apps/desktop/src/renderer/__tests__/reconcileRemoteMessages.test.ts index 445acd06f2..6559a4010d 100644 --- a/apps/desktop/src/renderer/__tests__/reconcileRemoteMessages.test.ts +++ b/apps/desktop/src/renderer/__tests__/reconcileRemoteMessages.test.ts @@ -1189,7 +1189,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { expect(ids).toContain('long-thinking'); expect(ids).toContain('client-auth-b'); // 关键:按落库时间线它在权威范围之外 → 按孤岛处理。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0); }); it('远程会话:同毫秒但没有 rowid 的 live push 保守按脱离处理', async () => { @@ -1223,7 +1223,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { 'client-same-ms-no-rowid', ); // 关键:排不出先后 → 按孤岛处理,而不是当成连续。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0); }); it('远程会话:同毫秒、rowid 更小的范围内晚到行不被误判成脱离', async () => { @@ -1256,9 +1256,11 @@ describe('makerChatStore.reconcileRemoteMessages', () => { ]); await flushMany(REMOTE_RECONCILE_FLUSH_TICKS); - expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toContain('client-inside'); + expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toContain( + 'client-inside', + ); // 关键:范围内 → 不记孤岛。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBe(0); }); it('远程会话:加性提交不能替一次无关的 rewind 背书,rewind 掉的尾部不得被补回', async () => { @@ -1338,7 +1340,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { expect(ids).toContain('client-same-ms-later'); expect(ids).toContain('client-auth-1'); // 关键:同毫秒但 rowid 更大 → 落在权威范围之外 → 按孤岛处理。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0); }); it('远程会话:权威重建保留了比权威窗口更新的晚到行时也记孤岛(推送有损)', async () => { @@ -1370,7 +1372,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { expect(ids).toContain('client-last-of-burst'); expect(ids).toContain('client-auth-1'); // 关键:范围外的晚到行按孤岛处理,下一次跳转会尝试补连续。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0); }); it('远程会话:purge 清掉对账次序簿,但旧代际的对账仍被代际守卫拦下', async () => { @@ -1402,7 +1404,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { const s = sid(); makerChatStore.initGlobalListeners(); await openRemoteWithHistory(s, [dbMessage(s, 'seed', 'seed row', '2026-06-15T00:00:00.000Z')]); - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBe(0); const pendingList = deferred(); remoteListResolver = () => pendingList.promise; @@ -1426,7 +1428,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { expect(ids).toContain('client-far-older'); expect(ids).toContain('client-auth-1'); // 关键:保留了脱离新窗口的行 → 标记必须点亮,后续跳转才会尝试补连续。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0); }); it('远程会话:分页期间转入 streaming 时,不 bump 代际也不抢别人的分页锁', async () => { @@ -1492,7 +1494,7 @@ describe('makerChatStore.reconcileRemoteMessages', () => { // 先制造孤岛状态。 remoteAround = [dbMessage(s, 'island', 'island row', '2026-06-01T00:00:00.000Z')]; await makerChatStore.loadAroundMessageClientId(s, 'client-island', { radius: 60 }); - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(true); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBeGreaterThan(0); // 无重叠对账 → 权威重建,期间没有任何 remote push 进来。 remoteListResolver = () => [ @@ -1501,9 +1503,11 @@ describe('makerChatStore.reconcileRemoteMessages', () => { makerChatStore.reconcileRemoteMessages(s); await flushMany(REMOTE_RECONCILE_FLUSH_TICKS); - expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toEqual(['client-auth-1']); + expect(makerChatStore.getSnapshot(s).messages.map((m) => m.clientId)).toEqual([ + 'client-auth-1', + ]); // 关键:窗口是完整重建出来的,标记必须清零。 - expect(makerChatStore.getSnapshot(s).historyWindowHasIsland).toBe(false); + expect(makerChatStore.getSnapshot(s).historyWindowIslands.length).toBe(0); }); it('远程会话:权威重建作废在飞行中的跳转补齐,并释放分页锁', async () => { diff --git a/apps/desktop/src/renderer/__tests__/searchJumpTargeting.test.ts b/apps/desktop/src/renderer/__tests__/searchJumpTargeting.test.ts index e8302b82f9..b8932aeb1d 100644 --- a/apps/desktop/src/renderer/__tests__/searchJumpTargeting.test.ts +++ b/apps/desktop/src/renderer/__tests__/searchJumpTargeting.test.ts @@ -3,58 +3,138 @@ * --------------------------------------------------------------------------- * 回归:搜索 / 引用跳转的落点判定必须区分"目标在窗口里"与"窗口连续覆盖到目标"。 * - * 背景(#676 review):这个判定原先内联在 CCAgentSessionView 的 searchJump effect 里 —— + * 背景(#676):这个判定原先内联在 CCAgentSessionView 的 searchJump effect 里 —— * 调用方在 messages 里看到目标就直接 focus 并 return,store 侧新加的孤岛感知补齐根本没有 * 机会运行。于是"补齐失败留下孤岛 → 重跳同一目标自愈"这条链在生产路径上是断的,而 store * 级回归绕过了这个入口、看不出问题。判定抽成纯函数后由本文件直接覆盖。 + * + * feat B:窗口模型从 boolean(historyWindowHasIsland)升级为显式孤岛区间 + * (historyWindowIslands,每座孤岛 {oldestClientId, newestClientId})。"能否零成本 + * focus"只看目标是否落在**主连续段**(最后一个孤岛最新边界行之后的所有行)里: + * 孤岛行与主段之间隔着没加载的历史,必须先交给 store 补齐。 */ import { describe, it, expect } from 'vitest'; -import { canFocusWithoutJumpLoad } from '@/lib/searchJumpTargeting'; +import { + canFocusWithoutJumpLoad, + mainContiguousRunStartIndex, + isInsideMainContiguousRun, + type LoadedWindowIsland, +} from '@/lib/searchJumpTargeting'; -const windowWith = (ids: string[], hasIsland?: boolean) => ({ +const windowWith = (ids: string[], islands?: LoadedWindowIsland[]) => ({ messages: ids.map((clientId) => ({ clientId })), - ...(hasIsland === undefined ? {} : { historyWindowHasIsland: hasIsland }), + ...(islands === undefined ? {} : { historyWindowIslands: islands }), }); -describe('搜索跳转落点判定', () => { - it('窗口连续且目标在窗口里 → 直接 focus,不必再走 store', () => { +describe('搜索跳转落点判定 — 主连续段', () => { + it('无孤岛且目标在窗口里 → 直接 focus,不必再走 store', () => { expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c']), 'b')).toBe(true); - // historyWindowHasIsland 缺省(undefined)等于"无孤岛"。 - expect(canFocusWithoutJumpLoad(windowWith(['a', 'b'], false), 'b')).toBe(true); + // 显式空孤岛列表与缺省同义。 + expect(canFocusWithoutJumpLoad(windowWith(['a', 'b'], []), 'b')).toBe(true); }); it('目标不在窗口里 → 必须走 store 加载', () => { expect(canFocusWithoutJumpLoad(windowWith(['a', 'b']), 'zzz')).toBe(false); }); - it('窗口有孤岛时即便目标在窗口里也要走 store,让补齐自愈', () => { + it('目标在孤岛上 → 即便在窗口里也要走 store,让补齐自愈', () => { // 关键回归:目标"在 messages 里"可能只是先前失败的深跳留下的孤立片段。 - expect(canFocusWithoutJumpLoad(windowWith(['island-target'], true), 'island-target')).toBe( - false, - ); + expect( + canFocusWithoutJumpLoad( + windowWith( + ['island-target'], + [{ oldestClientId: 'island-target', newestClientId: 'island-target' }], + ), + 'island-target', + ), + ).toBe(false); + }); + + it('目标在主段内(最后一座孤岛 newest 边界之后)→ 直接 focus', () => { + const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'b' }]; + // 主段 = b 之后的所有行。 + expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'c')).toBe(true); + expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'd')).toBe(true); + // 孤岛上的行仍不直接 focus。 + expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'a')).toBe(false); + expect(canFocusWithoutJumpLoad(windowWith(['a', 'b', 'c', 'd'], islands), 'b')).toBe(false); + }); + + it('多孤岛时,目标落在两座孤岛之间 → 走 store;落在主段 → 直接 focus', () => { + const islands: LoadedWindowIsland[] = [ + { oldestClientId: 'a', newestClientId: 'b' }, + { oldestClientId: 'd', newestClientId: 'e' }, + ]; + const window = windowWith(['a', 'b', 'c', 'd', 'e', 'f', 'g'], islands); + // c 在 b 与 d 之间:是"b → 最新"这段历史里的洞,不得直接 focus。 + expect(canFocusWithoutJumpLoad(window, 'c')).toBe(false); + expect(canFocusWithoutJumpLoad(window, 'd')).toBe(false); + expect(canFocusWithoutJumpLoad(window, 'e')).toBe(false); + // 主段 = e 之后。 + expect(canFocusWithoutJumpLoad(window, 'f')).toBe(true); + expect(canFocusWithoutJumpLoad(window, 'g')).toBe(true); + }); + + it('孤岛边界行不在窗口里(模型被破坏)→ 保守整窗不连续,一律走 store', () => { + // newest 边界行缺失:主段起点按"找不到边界"保守推到窗口末尾。 + expect( + canFocusWithoutJumpLoad( + windowWith(['x', 'y'], [{ oldestClientId: 'ghost', newestClientId: 'y' }]), + 'y', + ), + ).toBe(false); + // oldest 边界行缺失同样保守。 + expect( + canFocusWithoutJumpLoad( + windowWith(['x', 'y'], [{ oldestClientId: 'x', newestClientId: 'ghost' }]), + 'x', + ), + ).toBe(false); + }); +}); + +describe('mainContiguousRunStartIndex', () => { + it('无孤岛时整窗都是主段', () => { + expect(mainContiguousRunStartIndex([{ clientId: 'a' }, { clientId: 'b' }], [])).toBe(0); + }); + + it('主段起点 = 最后一个孤岛 newest 边界行之后', () => { + const messages = [{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }]; + const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'b' }]; + expect(mainContiguousRunStartIndex(messages, islands)).toBe(2); + }); + + it('边界行缺失 → 主段为空(返回 messages.length)', () => { + const messages = [{ clientId: 'a' }, { clientId: 'b' }]; + const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'nope' }]; + expect(mainContiguousRunStartIndex(messages, islands)).toBe(messages.length); }); }); -describe('canFocusWithoutJumpLoad · 孤岛一律交回 store', () => { - it('有孤岛时即便已翻到历史起点也不直接 focus(around 仍可能捞回缺的邻居)', () => { - // review #676(codex P1):跳转不只走分页,它还发 around-client-id。远程权威重建可以同时 - // 留下"孤岛 + hasMore=false"(翻到历史起点却保留了一条被有损推送落下的脱离行),那时 - // around 恰好能把它周围缺的邻居捞回来。用 hasMore 短路会把这条修复通道永久关掉。 - const state = { - messages: [{ clientId: 'a' }, { clientId: 'b' }], - historyWindowHasIsland: true, - hasMoreMessages: false, - }; - expect(canFocusWithoutJumpLoad(state, 'b')).toBe(false); - }); - - it('有孤岛且还能继续翻页时同样交回 store 补齐', () => { - const state = { - messages: [{ clientId: 'a' }], - historyWindowHasIsland: true, - hasMoreMessages: true, - }; - expect(canFocusWithoutJumpLoad(state, 'a')).toBe(false); +describe('isInsideMainContiguousRun(与 canFocusWithoutJumpLoad 共用同一把尺子)', () => { + it('目标在窗口外或下标早于主段起点 → false', () => { + const islands: LoadedWindowIsland[] = [{ oldestClientId: 'a', newestClientId: 'b' }]; + expect( + isInsideMainContiguousRun( + [{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }], + islands, + 'c', + ), + ).toBe(true); + expect( + isInsideMainContiguousRun( + [{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }], + islands, + 'b', + ), + ).toBe(false); + expect( + isInsideMainContiguousRun( + [{ clientId: 'a' }, { clientId: 'b' }, { clientId: 'c' }], + islands, + 'zzz', + ), + ).toBe(false); }); }); diff --git a/apps/desktop/src/renderer/hooks/useCCAgentChat.ts b/apps/desktop/src/renderer/hooks/useCCAgentChat.ts index 9a66115be6..a218615259 100644 --- a/apps/desktop/src/renderer/hooks/useCCAgentChat.ts +++ b/apps/desktop/src/renderer/hooks/useCCAgentChat.ts @@ -26,6 +26,7 @@ import { import { makerChatStore, + EMPTY_LIGHT_STATE, EMPTY_SESSION_STATE, EMPTY_TASK_UPDATES, type AgentStatus, @@ -357,8 +358,8 @@ function useHeavyChatSnapshot( function useLiveChatLightState(sessionId: string | undefined): SessionChatLightState { return useSyncExternalStore( (cb) => (sessionId ? makerChatStore.subscribeLight(sessionId, cb) : NOOP_UNSUBSCRIBE), - () => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_SESSION_STATE), - () => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_SESSION_STATE), + () => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_LIGHT_STATE), + () => (sessionId ? makerChatStore.getLightSnapshot(sessionId) : EMPTY_LIGHT_STATE), ); } diff --git a/apps/desktop/src/renderer/lib/historyGap.ts b/apps/desktop/src/renderer/lib/historyGap.ts index c88f1e3d82..d12da91998 100644 --- a/apps/desktop/src/renderer/lib/historyGap.ts +++ b/apps/desktop/src/renderer/lib/historyGap.ts @@ -3,7 +3,7 @@ * * 桌面消费方:`components/chat/MessageStream` —— tool_segment 按它切段、工作组按它切组。 * makerChatStore 用同一把尺子识别裁剪后窗口里「孤岛 + 最新连续尾段」的缺口,好把 - * 向上翻页的游标留在尾段下沿(见 oldestMessageOfNewestContiguousRun);跳转补齐预算 + * 向上翻页的游标留在尾段下沿(见 makerChatStore 的 oldestMessageOfMainContiguousRun);跳转补齐预算 * 本身仍按行数取保守上界(JUMP_BACKFILL_MAX_ITEMS),不按本常量估算。 * * 为什么保留这层 re-export 而不让 MessageStream 直接引 shared:阈值原本是桌面常量, diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 0458b86182..8d9aa5402e 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -134,7 +134,11 @@ import { markSessionAutomaticHistoryLoadCompleted, resetSessionAutomaticHistoryLoadCompletion, } from '@/lib/sessionScrollStore'; -import { HISTORY_GAP_SPLIT_MS } from '@/lib/historyGap'; +import { + isInsideMainContiguousRun, + mainContiguousRunStartIndex, + type LoadedWindowIsland, +} from '@/lib/searchJumpTargeting'; import { extractIpcError } from '@/utils/ipcError'; import { tryBeginAgentSendDispatch } from '@/lib/agentSwitchCoordinator'; import { getUserPrompt } from '@/lib/userPromptStore'; @@ -2444,25 +2448,31 @@ export interface SessionChatState { isLoadingMore: boolean; hasMoreMessages: boolean; /** - * 窗口里是否掺进过"孤岛" —— 跳转补齐失败时 merge 的 around 窗口,它与已加载的尾部窗口 - * 之间隔着没加载的历史。 + * 窗口里掺进过的孤岛区间(显式建模的"已加载窗口")—— 跳转补齐失败时 merge 的 around + * 窗口,每座孤岛与已加载的尾部窗口(主连续段)之间都隔着没加载的历史。 * - * 为什么需要它:补齐的快速通道原来只判 `messages.some(clientId === target)`,那是**成员** - * 判定而不是**连续覆盖**判定。孤岛一旦落进窗口,再跳同一个目标就会命中 some()、直接返回 - * covered 而不补齐,于是"中间缺失"再也修不好(#676 review)。有孤岛时快速通道失效, - * 每次跳转都重新从最新翻页,让这个状态可自愈。 + * 为什么不是 boolean:单个标记无法回答"目标落在哪一段"。补齐快速通道与搜索落点判定都 + * 需要"目标是否在主连续段里"(最新 → 目标的整段历史已确认连续)—— 那要求把已加载区间 + * 显式建模(见 searchJumpTargeting 的 TODO 与 MessageStream 锚定窗口双向有界的 TODO, + * 同一条后续改动)。孤岛按时间升序(最老在前),主段就是最后一个孤岛最新边界行之后的 + * 全部行;孤岛被向上翻页跨过(接回主段)时从本数组移除,于是"孤岛 + 已翻到历史起点"的 + * 会话在窗口真正完整后不再每次搜索都白打一轮 around + list。 * - * 只由**把窗口清空、从最新重新拉起**的路径清回 false:reloadMessages(rewind / origin + * 只由**把窗口清空、从最新重新拉起**的路径清回空:reloadMessages(rewind / origin * 漂移重载)、clearSessionAfterGuard(/clear)、_demoteIdleSessions(空闲降级)、 - * _purgeSession(整条移除,重建后回到默认 false)。 + * _purgeSession(整条移除,重建后回到默认空)。 * * 反过来,这几处**刻意不清**(都在 #676 review 里逐条确认过): * - `covered`:到达本次目标只证明"尾部 → 本目标"连续,不证明更早的孤岛都被跨过; * - `_trimMessagesIfNeeded`:`slice(-TRIM_TARGET)` 只保证"最新 200 行",不保证连续; * - 首拉落地:它只是把最新一页 merge 进来,孤岛与尾段之间的洞还在(游标交还给最新页 * 下沿,好让往上翻能穿过去)。 + * + * 边界行 clientId 恒在 messages 里:孤岛行全部来自 merge,不会被改名;裁剪、删除等 + * 会动窗口形状的路径必须同步收口本模型(见 pruneIslandsForTrimmedWindow / + * conservativeIslandsFor 与各处调用)。 */ - historyWindowHasIsland?: boolean; + historyWindowIslands: readonly LoadedWindowIsland[]; isFirstMessage: boolean; streamingClientId: string | null; streamingText: string; @@ -2704,7 +2714,6 @@ export type SessionChatLightState = Pick< | 'continuationInFlightProjectionCapability' | 'isLoadingMore' | 'hasMoreMessages' - | 'historyWindowHasIsland' | 'isFirstMessage' | 'historyLoaded' | 'pendingPermission' @@ -2731,7 +2740,13 @@ export type SessionChatLightState = Pick< | 'pendingTaskWake' | 'pendingTaskWakeStarted' | 'turnStoppedByUser' ->; +> & { + /** 窗口是否掺进过孤岛(由 historyWindowIslands 派生,给 UI / 判定用)。 */ + historyWindowHasIsland: boolean; +}; + +/** 没有孤岛的空模型(共享只读实例,避免无谓分配)。 */ +const EMPTY_WINDOW_ISLANDS: readonly LoadedWindowIsland[] = []; function createInitialState(): SessionChatState { return { @@ -2767,7 +2782,7 @@ function createInitialState(): SessionChatState { continuationInFlightProjectionCapability: 'unknown', isLoadingMore: false, hasMoreMessages: true, - historyWindowHasIsland: false, + historyWindowIslands: EMPTY_WINDOW_ISLANDS, isFirstMessage: true, streamingClientId: null, streamingText: '', @@ -2845,7 +2860,7 @@ export const EMPTY_SESSION_STATE: SessionChatState = Object.freeze({ continuationInFlightProjectionCapability: 'unknown', isLoadingMore: false, hasMoreMessages: false, - historyWindowHasIsland: false, + historyWindowIslands: EMPTY_WINDOW_ISLANDS, isFirstMessage: true, streamingClientId: null, streamingText: '', @@ -2888,6 +2903,12 @@ export const EMPTY_SESSION_STATE: SessionChatState = Object.freeze({ lastAgentMeta: null, }) as SessionChatState; +/** Stable empty light snapshot (sessionless callers; derived boolean off). */ +export const EMPTY_LIGHT_STATE: SessionChatLightState = Object.freeze({ + ...EMPTY_SESSION_STATE, + historyWindowHasIsland: false, +}); + // --------------------------------------------------------------------------- // Store internals // --------------------------------------------------------------------------- @@ -3476,28 +3497,240 @@ function _isSessionBusy(sessionId: string, s: SessionChatState): boolean { } /** - * 已加载窗口里最新连续段的最老一行。 + * 在 messages 里按 clientId 定位一行;找不到返回 -1。 + * + * 孤岛边界行恒在窗口里(模型不变量),找不到只可能是模型被破坏 —— 调用方一律按 + * 保守方向处理(返回原集合或整窗不连续),绝不让快速通道把孤岛行当成主段。 + */ +function messageIndexByClientId( + messages: readonly { clientId: string }[], + clientId: string, +): number { + return messages.findIndex((message) => message.clientId === clientId); +} + +/** + * 主连续段(最新尾段)最老一行的位置,用于向上翻页的 beforeTs 游标。 + * + * `messages` 是 oldest-first。窗口可能是「更老的孤岛 + 缺口 + 最新连续尾段」, + * 此时 `messages[0]` 是孤岛边缘,不是向上翻页该用的游标。主段由显式孤岛模型推导 + * (最后一个孤岛最新边界行之后的全部行),不再用时间阈值近似 —— 见 searchJumpTargeting + * 的 mainContiguousRunStartIndex 与 canFocusWithoutJumpLoad 的说明。 + */ +function oldestMessageOfMainContiguousRun( + messages: ChatMessage[], + islands: readonly LoadedWindowIsland[], +): ChatMessage | null { + return messages[mainContiguousRunStartIndex(messages, islands)] ?? null; +} + +/** + * 分页把窗口从游标向更老一侧推进后,按"孤岛是否真的被翻页跨过"逐座收口孤岛集合。 * - * `messages` 是 oldest-first。窗口可能是「更老的孤岛 + 缺口 + 最新连续尾段」, - * 此时 `messages[0]` 是孤岛边缘,不是向上翻页该用的游标。切段尺子与渲染层相同 - * (`HISTORY_GAP_SPLIT_MS`);没有超过该阈值的空洞时,整窗都算最新连续段。 + * 翻页从主段游标一路取更老的行,凡是整座落在 [新边界, 游标] 区间里的孤岛都被接回主段 + * (它的行本来就在库里,list 会原样带回、由 mergeMessages 去重)→ 从模型移除;只被跨过 + * 最新一侧的孤岛存活部分的最老边界不变、最新边界换成新边界前一行(B-1 是存活部分的 + * 最新一行,必然在窗口里);完全没被碰到的保持原样。 + * + * 这就是「孤岛 + 已翻到历史起点」的会话恢复零成本跳转的通道:翻页把洞填上之后, + * 窗口内搜索不再需要每次白打一轮 around + list。 */ -function oldestMessageOfNewestContiguousRun(messages: ChatMessage[]): ChatMessage | null { - if (messages.length === 0) return null; - let runStart = 0; - for (let i = 1; i < messages.length; i++) { - const prev = messageTime(messages[i - 1].createdAt); - const next = messageTime(messages[i].createdAt); - if (!Number.isFinite(prev) || !Number.isFinite(next)) continue; - if (next - prev > HISTORY_GAP_SPLIT_MS) runStart = i; +function absorbIslandsCrossedByPaging( + islands: readonly LoadedWindowIsland[], + messages: ChatMessage[], + newOldestBoundaryId: string | null, +): readonly LoadedWindowIsland[] { + if (islands.length === 0 || !newOldestBoundaryId) return islands; + // 游标可能是 DB row id(调用方传 oldestId / cursorId 等分页游标)也可能是 + // clientId,统一按"clientId 或 server id 命中"匹配,与 retainedWindowKeepsGapCursor + // 同一口径 —— 只按 clientId 找会让生产里的边界永远落空、孤岛收口失效。 + const boundaryIdx = messages.findIndex((message) => + messageMatchesHistoryCursor(message, newOldestBoundaryId), + ); + if (boundaryIdx < 0) return islands; + const next: LoadedWindowIsland[] = []; + for (const island of islands) { + const oldestIdx = messageIndexByClientId(messages, island.oldestClientId); + const newestIdx = messageIndexByClientId(messages, island.newestClientId); + if (oldestIdx >= boundaryIdx) continue; // 整座孤岛被翻页覆盖 → 接回主段 + if (newestIdx >= boundaryIdx) { + // 只跨过最新一侧:存活部分 = [oldest, B-1],B-1 就在窗口里(孤岛连续块的一部分)。 + next.push({ + oldestClientId: island.oldestClientId, + newestClientId: messages[boundaryIdx - 1].clientId, + }); + continue; + } + next.push(island); } - return messages[runStart] ?? null; + return next; } -function messageMatchesHistoryCursor( - message: ChatMessage, - cursorId: string, -): boolean { +/** + * around 行 merge 进窗口后,按"around 块与窗口里既有行 / 既有孤岛的重叠关系"重算孤岛集合。 + * + * around 块是库里的一段连续行。它与窗口里某段已加载区间**重叠**(哪怕重叠一行)就说明它 + * 和那段连成一片:重叠落在主段 → 主段向更老/更新延伸、不产生新洞;重叠落在既有孤岛 → + * 孤岛延伸(两座孤岛经 around 块连通后合并成一座)。完全不重叠才是新孤岛,边界取 around + * 块在窗口里的最老/最新行。 + * + * 块与主段重叠时的保守口径:返回原集合 —— 即使块顺带接住了更深处的一座孤岛,多留一座 + * 标记只是多做补齐尝试,方向安全(#676 的既有口径,见 commitAroundWindow 的 covered 注)。 + * + * "块是否连上主段"必须在**合并前**的窗口(prevMessages)上判定:合并后主段起点会被新插入 + * 的行顶到块自己身上,「无孤岛窗口 + 首座失败跳转孤岛」会退化成主段吸收,孤岛永远记不上 + * (T / Y3 回归)。判据是"块里至少有一行本来就躺在 prevMessages 的主段里" —— 块是库里 + * 一段连续行,它跨过主段边界时,边界两侧都必须已经在窗口里才算连上。 + */ +function updateIslandsAfterAroundMerge( + islands: readonly LoadedWindowIsland[], + prevMessages: ChatMessage[], + messages: ChatMessage[], + aroundRows: readonly Message[], +): readonly LoadedWindowIsland[] { + if (aroundRows.length === 0) return islands; + // around 块在窗口里的下标范围:只统计真的进了窗口的行(hidden thinking 行会被 + // mapServerMessages 过滤掉,不进窗口,但不影响块的连通范围判定)。 + let blockStart = -1; + let blockEnd = -1; + // 主段起点在合并前的窗口上算(见函数注释):无孤岛时整窗都算主段,合并后算会把 + // 刚插进来的孤岛行也圈进主段。 + const prevMainStart = mainContiguousRunStartIndex(prevMessages, islands); + let touchesPrevMainRun = false; + for (const row of aroundRows) { + const idx = messageIndexByClientId(messages, row.clientId); + if (idx < 0) continue; + if (blockStart < 0 || idx < blockStart) blockStart = idx; + if (idx > blockEnd) blockEnd = idx; + if (messageIndexByClientId(prevMessages, row.clientId) >= prevMainStart) { + touchesPrevMainRun = true; + } + } + if (blockStart < 0) return islands; + // 块里任何一行本来就躺在主段里 → 块经它连上主段,主段向更老/更新延伸,孤岛集合不动。 + if (touchesPrevMainRun) return islands; + + // 块完全落在主段更老一侧:与既有孤岛合并(经块连通的孤岛合并成一座)或新建一座。 + let mergedOldestClientId: string | null = null; + let mergedNewestClientId: string | null = null; + let mergedAny = false; + const next: LoadedWindowIsland[] = []; + for (const island of islands) { + const oldestIdx = messageIndexByClientId(messages, island.oldestClientId); + const newestIdx = messageIndexByClientId(messages, island.newestClientId); + const overlaps = + oldestIdx >= 0 && newestIdx >= 0 && oldestIdx <= blockEnd && newestIdx >= blockStart; + if (!overlaps) { + next.push(island); + continue; + } + mergedAny = true; + if (mergedOldestClientId === null || oldestIdx < blockStart) { + mergedOldestClientId = island.oldestClientId; + } + if (mergedNewestClientId === null || newestIdx > blockEnd) { + mergedNewestClientId = island.newestClientId; + } + } + next.push({ + oldestClientId: mergedOldestClientId ?? messages[blockStart].clientId, + newestClientId: mergedNewestClientId ?? messages[blockEnd].clientId, + }); + // 块可能插在两座孤岛之间 → 按窗口位置重排(孤岛很少,排序开销可忽略)。 + next.sort( + (a, b) => + messageIndexByClientId(messages, a.oldestClientId) - + messageIndexByClientId(messages, b.oldestClientId), + ); + return next; +} + +/** + * 裁剪后按保留窗口收口孤岛集合。 + * + * `slice(-TRIM_TARGET)` 从最老一侧裁。整座被裁掉的孤岛随洞一起消失;被拦腰裁断的那座 + * 存活部分的最老边界换成本窗口最老行 —— 孤岛按时间升序、裁剪只会先切最老的孤岛,所以 + * 窗口最老行就是那座被裁孤岛存活部分的最老一行,精确而非近似。 + */ +function pruneIslandsForTrimmedWindow( + islands: readonly LoadedWindowIsland[], + retained: ChatMessage[], +): readonly LoadedWindowIsland[] { + if (islands.length === 0) return islands; + const retainedIds = new Set(retained.map((message) => message.clientId)); + const fallbackOldest = retained[0]?.clientId ?? null; + const next: LoadedWindowIsland[] = []; + for (const island of islands) { + if (!retainedIds.has(island.newestClientId)) continue; + next.push({ + oldestClientId: + retainedIds.has(island.oldestClientId) || fallbackOldest === null + ? island.oldestClientId + : fallbackOldest, + newestClientId: island.newestClientId, + }); + } + return next; +} + +/** + * 保守的"整窗不连续"建模:主段置空(孤岛边界行取窗口最新行),任何目标都不会命中 + * 主段快速通道,一律走补齐尝试 —— 与旧 boolean=true 同语义。 + */ +function conservativeIslandsFor(messages: ChatMessage[]): readonly LoadedWindowIsland[] { + const newest = messages[messages.length - 1]; + if (!newest) return EMPTY_WINDOW_ISLANDS; + return [{ oldestClientId: newest.clientId, newestClientId: newest.clientId }]; +} + +/** + * 删除行后重算孤岛集合:删除落在某座孤岛的区间内会把它拦腰截断,剩余部分的连通性 + * 不再可判 → 保守按整窗不连续建模。删在主段里与旧 boolean 同口径(不另记孤岛)。 + */ +function conservativeIfIslandRowRemoved( + islands: readonly LoadedWindowIsland[], + prevMessages: ChatMessage[], + removedIds: ReadonlySet, + nextMessages: ChatMessage[], +): readonly LoadedWindowIsland[] { + if (islands.length === 0) return islands; + for (const island of islands) { + const oldestIdx = messageIndexByClientId(prevMessages, island.oldestClientId); + const newestIdx = messageIndexByClientId(prevMessages, island.newestClientId); + if (oldestIdx < 0 || newestIdx < 0) return conservativeIslandsFor(nextMessages); + for (const message of prevMessages) { + if (!removedIds.has(message.clientId)) continue; + const idx = messageIndexByClientId(prevMessages, message.clientId); + if (idx >= oldestIdx && idx <= newestIdx) return conservativeIslandsFor(nextMessages); + } + } + return islands; +} + +/** + * 从最新一侧截断(edit-last)后重算孤岛集合:整座落在截断区里的孤岛随洞消失;被拦腰 + * 截断的那座存活部分的最老边界仍在、最新边界未知 → 保守按整窗不连续建模。 + */ +function pruneIslandsAfterNewestTruncation( + islands: readonly LoadedWindowIsland[], + prevMessages: ChatMessage[], + truncateFromIdx: number, +): readonly LoadedWindowIsland[] { + if (islands.length === 0) return islands; + const next: LoadedWindowIsland[] = []; + for (const island of islands) { + const oldestIdx = messageIndexByClientId(prevMessages, island.oldestClientId); + const newestIdx = messageIndexByClientId(prevMessages, island.newestClientId); + if (oldestIdx >= truncateFromIdx) continue; + if (newestIdx >= truncateFromIdx) { + return conservativeIslandsFor(prevMessages.slice(0, truncateFromIdx)); + } + next.push(island); + } + return next; +} + +function messageMatchesHistoryCursor(message: ChatMessage, cursorId: string): boolean { return message.clientId === cursorId || message.id === cursorId; } @@ -3537,7 +3770,7 @@ function _trimMessagesIfNeeded(sessionId: string): void { // 的精确游标。清成 null 后空闲恢复会拿 messages[0](孤岛)当 beforeTs,向更老处 // 翻页,填不了孤岛与尾段之间的缺口,未解析计划会一直缺席到整窗重载。 const keepGapCursor = - s.historyWindowHasIsland === true && + s.historyWindowIslands.length > 0 && retainedWindowKeepsGapCursor(retained, s.oldestMessageId); return { ...s, @@ -3545,13 +3778,13 @@ function _trimMessagesIfNeeded(sessionId: string): void { hasMoreMessages: true, oldestMessageId: keepGapCursor ? s.oldestMessageId : null, isLoadingMore: false, - // 孤岛标记**保持原值**:`slice(-TRIM_TARGET)` 只保证"取最新的 200 行",不保证这 200 行 + // 孤岛集合按保留窗口收口:`slice(-TRIM_TARGET)` 只保证"取最新的 200 行",不保证这 200 行 // 连续 —— 若先前几次深跳留下多个孤岛、而真正连续的尾段不足 200 行,裁剪结果里就还夹着 - // 孤岛。清掉标记会让 canFocusWithoutJumpLoad 把命中孤岛当成已覆盖直接 focus,而从孤岛 - // 边界往上翻又取不到那段更新的缺失区间 → 洞永久固化,直到整会话重载(#676 review)。 - // - // 代价是出现过孤岛的会话在裁剪后仍会多做补齐尝试;方向上是安全的那一侧。 - // 真正清零只发生在"整窗从最新重建"的路径(reloadMessages / clear / demote / purge)。 + // 孤岛。整座被裁掉的孤岛随洞一起消失;被拦腰裁断的那座只保留存活部分(最老边界换成本 + // 窗口最老行,见 pruneIslandsForTrimmedWindow)。漏收口会让 canFocusWithoutJumpLoad + // 把命中孤岛当成已覆盖直接 focus,而从孤岛边界往上翻又取不到那段更新的缺失区间 → + // 洞永久固化,直到整会话重载(#676 review)。 + historyWindowIslands: pruneIslandsForTrimmedWindow(s.historyWindowIslands, retained), }; }); } @@ -3791,7 +4024,7 @@ function _demoteIdleSessions(): void { oldestMessageId: null, hasMoreMessages: true, isLoadingMore: false, - historyWindowHasIsland: false, + historyWindowIslands: EMPTY_WINDOW_ISLANDS, })); } } @@ -8773,7 +9006,8 @@ function selectLightState(state: SessionChatState): SessionChatLightState { continuationInFlightProjectionCapability: state.continuationInFlightProjectionCapability, isLoadingMore: state.isLoadingMore, hasMoreMessages: state.hasMoreMessages, - historyWindowHasIsland: state.historyWindowHasIsland, + // 派生布尔给 UI / 判定用,正本始终是 historyWindowIslands 区间模型。 + historyWindowHasIsland: state.historyWindowIslands.length > 0, isFirstMessage: state.isFirstMessage, historyLoaded: state.historyLoaded, pendingPermission: state.pendingPermission, @@ -10555,7 +10789,7 @@ function ensureInitialMessages(sessionId: string): void { ), isFirstMessage: false, oldestMessageId: - s.historyWindowHasIsland === true + s.historyWindowIslands.length > 0 ? initialOldestId : (oldestServerMessageIdForWindow( existing, @@ -10645,9 +10879,7 @@ function ensureInitialMessages(sessionId: string): void { const oldestId = oldestRow.id; settleCacheHydration(sessionId); if (!isCurrentHistoryLoad()) return; - setState(sessionId, (s) => ({ - ...s, - historyLoaded: true, + setState(sessionId, (s) => { // Merge: keep any messages already appended by streaming events // (unlikely here since we gate history load on first mount, but // preserves slice invariants). @@ -10655,36 +10887,46 @@ function ensureInitialMessages(sessionId: string): void { // 冷缓存 hydrate 的行先**整批剔除**再 merge:mergeMessages 只增不删,权威页里 // 已经不存在的缓存行(被控端 /clear、rewind、删消息)否则会永久留在窗口里。 // 仍在权威页里的那些会由 mapped 原样带回来,不会闪。 - messages: mergeMessages( + const messages = mergeMessages( mapped, s.messages.some((m) => m.cacheHydrated === true) ? s.messages.filter((m) => m.cacheHydrated !== true) : s.messages, {}, 'newest-first', - ), - isFirstMessage: false, - // 窗口里掺着跳转孤岛时,**本页的下沿**接管游标,不再取"两者中更老的那个"。 - // - // 序列(#676 review codex P1):会话刚打开就直接深跳,首拉还没回来 → 补齐无从下手、 - // 退回 around 孤岛并用孤岛下沿播种游标(那时窗口里只有孤岛,只能这么播)。随后首拉 - // 的最新页落地,若仍保留更老的孤岛游标,缺失区间恰好比它**更新**:普通向上翻页与 - // 孤岛感知重试都只会请求比孤岛更老的行,那段洞永远拉不回来,除非整会话重载。 - // 换成最新页下沿后,往上翻会一页页穿过那段洞,补齐也能真正命中目标并自愈。 - // - // 孤岛标记**不清**:洞还在,直到翻页真的把它填上。 - oldestMessageId: - s.historyWindowHasIsland === true - ? oldestId - : (oldestServerMessageIdForWindow( - merged, - s.messages, - s.oldestMessageId, - 'newest-first', - ) ?? oldestId), - hasMoreMessages: hasMore, - isLoadingMore: false, - })); + ); + return { + ...s, + historyLoaded: true, + messages, + isFirstMessage: false, + // 窗口里掺着跳转孤岛时,**本页的下沿**接管游标,不再取"两者中更老的那个"。 + // + // 序列(#676 review codex P1):会话刚打开就直接深跳,首拉还没回来 → 补齐无从下手、 + // 退回 around 孤岛并用孤岛下沿播种游标(那时窗口里只有孤岛,只能这么播)。随后首拉 + // 的最新页落地,若仍保留更老的孤岛游标,缺失区间恰好比它**更新**:普通向上翻页与 + // 孤岛感知重试都只会请求比孤岛更老的行,那段洞永远拉不回来,除非整会话重载。 + // 换成最新页下沿后,往上翻会一页页穿过那段洞,补齐也能真正命中目标并自愈。 + // + // 孤岛区间**不清**:洞还在,直到翻页真的把它填上(absorbIslandsCrossedByPaging)。 + oldestMessageId: + s.historyWindowIslands.length > 0 + ? oldestId + : (oldestServerMessageIdForWindow( + merged, + s.messages, + s.oldestMessageId, + 'newest-first', + ) ?? oldestId), + hasMoreMessages: hasMore, + isLoadingMore: false, + historyWindowIslands: absorbIslandsCrossedByPaging( + s.historyWindowIslands, + messages, + oldestId, + ), + }; + }); if (import.meta.env.DEV) { const ingestDurMs = performance.now() - ingestStartMs; if (ingestDurMs >= 30) { @@ -10742,7 +10984,7 @@ function scheduleIdlePlanDiscoveryIfNeeded(sessionId: string): void { const state = sessions.get(sessionId); if (!state?.historyLoaded || state.isLoadingMore || !state.hasMoreMessages) return; const planState = getLatestMessageTodoState(state.messages, { - taskHistoryMayBeIncomplete: state.hasMoreMessages || state.historyWindowHasIsland, + taskHistoryMayBeIncomplete: state.hasMoreMessages || state.historyWindowIslands.length > 0, }); if (planState.hasPlanEvent && planState.isResolved) return; if (_idlePlanDiscoveryHandles.has(sessionId)) return; @@ -10762,7 +11004,7 @@ function loadOneOlderPageForPlanDiscovery(sessionId: string): Promise { return Promise.resolve(false); } const planState = getLatestMessageTodoState(state.messages, { - taskHistoryMayBeIncomplete: state.hasMoreMessages || state.historyWindowHasIsland, + taskHistoryMayBeIncomplete: state.hasMoreMessages || state.historyWindowIslands.length > 0, }); if (planState.hasPlanEvent && planState.isResolved) return Promise.resolve(false); if (planState.hasPlanEvent && !planState.isResolved) { @@ -10785,7 +11027,7 @@ async function continuePlanResolutionAfterIdleDiscovery(sessionId: string): Prom const state = sessions.get(sessionId); if (!state?.historyLoaded || state.isLoadingMore || !state.hasMoreMessages) return; const planState = getLatestMessageTodoState(state.messages, { - taskHistoryMayBeIncomplete: state.hasMoreMessages || state.historyWindowHasIsland, + taskHistoryMayBeIncomplete: state.hasMoreMessages || state.historyWindowIslands.length > 0, }); if (!planState.hasPlanEvent || planState.isResolved) return; const advanced = await loadOlderMessages(sessionId, false, 1); @@ -10852,7 +11094,7 @@ function reloadMessages(sessionId: string, opts?: { allowCacheHydrate?: boolean oldestMessageId: null, isStreaming: false, // 窗口从最新重新拉起 → 不再有孤岛。 - historyWindowHasIsland: false, + historyWindowIslands: EMPTY_WINDOW_ISLANDS, // 分页锁归本次重置释放:窗口和游标都清了,in-flight 的翻页 / 跳转补齐也已被上面的 // bumpMessagesEpoch 作废,锁再留着只会让行首守卫卡住下一次翻页。由这里清而不是 // 让被作废的请求代清 —— 它们无法分辨锁是自己那一代的还是重置后新代际的(#676 review)。 @@ -10919,6 +11161,14 @@ function removeMessagesByClientIds( messages, taskUpdates, isFirstMessage: !messages.some((message) => message.role === 'user'), + // 删除落在孤岛区间内会把孤岛从中间截断,剩余部分的连通性不再可判 → 保守按 + // 整窗不连续建模(主段为空)。删在主段里则与旧 boolean 同口径:不另记孤岛。 + historyWindowIslands: conservativeIfIslandRowRemoved( + s.historyWindowIslands, + s.messages, + deletedClientIds, + messages, + ), ...lockReset, }; }); @@ -10962,6 +11212,13 @@ function dropMessagesFromClientId(sessionId: string, clientId: string): void { return { ...s, messages: s.messages.slice(0, idx), + // 从最新一侧截断:整座落在截断区里的孤岛随洞一起消失;被拦腰截断的那座保守按 + // 整窗不连续建模(存活部分的最老边界未知,见 pruneIslandsAfterNewestTruncation)。 + historyWindowIslands: pruneIslandsAfterNewestTruncation( + s.historyWindowIslands, + s.messages, + idx, + ), taskUpdates: new Map(), isStreaming: false, // 同 reloadMessages / clearSessionAfterGuard:本地截断也是 epoch-reset 路径, @@ -11200,7 +11457,7 @@ function runRemoteReconcile(sessionId: string, opts?: { force?: boolean }): Prom // 赢、把对账推到下一轮触发)会让被控端权威内容继续缺着,而走到这个分支本身就说明本地 // 窗口已经严重过期。所以让权威重建赢。 // - // historyWindowHasIsland 按"是否保留了晚到的本地行"决定清不清,见下方 lateArrivals。 + // historyWindowIslands 按"是否保留了晚到的本地行"决定清不清,见下方 lateArrivals。 const isContiguous = reachedKnownWindow; // 代际 bump 与分页锁释放都**只在重建确定提交时**做。不能放在 setState 外面提前 bump: // 更新器里的 isStreaming 守卫可能否掉重建。也不能等 setState 通知完成后才 bump: @@ -11294,7 +11551,12 @@ function runRemoteReconcile(sessionId: string, opts?: { force?: boolean }): Prom // 锁归本次重置释放(与 reloadMessages / clear / trim / demote 同规矩):被作废的 // 请求不会代清,漏清会让行首守卫把该会话的翻页永久卡住。 isLoadingMore: false, - historyWindowHasIsland: hasDetachedArrival, + // 晚到行全部脱离权威窗口 → 窗口整体不可信:保守按"主段为空"建模(任何目标都 + // 继续走补齐尝试,与旧 boolean=true 同语义)。没有脱离行 → 权威窗口本身是 + // 连续拉回的一段,整窗即主段,窗口内搜索直接 focus。 + historyWindowIslands: hasDetachedArrival + ? conservativeIslandsFor(messages) + : EMPTY_WINDOW_ISLANDS, }; }); } finally { @@ -11367,10 +11629,13 @@ function loadOlderMessages( firstPageOpts = { limit: 50, before: state.oldestMessageId }; } else if (state.messages.length > 0) { // 无 ID 时默认用 messages[0]。有孤岛时那是孤岛最老行,beforeTs 会翻到缺口 - // 更早的一侧;改用最新连续段下沿,才能填孤岛与尾段之间的洞。 + // 更早的一侧;改用主连续段(最后一个孤岛最新边界行之后)的下沿,才能填孤岛与 + // 尾段之间的洞 —— 与 canFocusWithoutJumpLoad / 补齐快速通道同一把结构尺子, + // 不再用时间阈值近似(见 oldestMessageOfMainContiguousRun)。 const oldest = - state.historyWindowHasIsland === true - ? (oldestMessageOfNewestContiguousRun(state.messages) ?? state.messages[0]) + state.historyWindowIslands.length > 0 + ? (oldestMessageOfMainContiguousRun(state.messages, state.historyWindowIslands) ?? + state.messages[0]) : state.messages[0]; if (!oldest.createdAt) return Promise.resolve(false); const ts = new Date(oldest.createdAt).getTime(); @@ -11461,6 +11726,13 @@ function loadOlderMessages( oldestMessageId: nextOldestMessageId, hasMoreMessages: hasMore, isLoadingMore: false, + // 翻页从主段游标向更老一侧推进:被跨过的孤岛(行落进本批拉取范围)接回主段, + // 从显式模型里消失 —— 这是"孤岛 + 已翻到历史起点"的会话恢复零成本跳转的通道。 + historyWindowIslands: absorbIslandsCrossedByPaging( + s.historyWindowIslands, + messages, + nextOldestMessageId, + ), }; }); if (didAdvanceWindow && automatic) { @@ -11595,15 +11867,19 @@ async function backfillHistoryUntil( // 所以锁被占用时一律 busy —— 返回 busy 而不是 unavailable,是因为锁是别人的, // fallback 路径不得代为释放。 if (getOrCreateState(sessionId).isLoadingMore) return { outcome: 'busy', ownsPagingLock: false }; - // 快速通道只在窗口没有孤岛时可信:some(clientId) 是成员判定,不是连续覆盖判定。窗口里 - // 掺过孤岛(补齐失败时 merge 的 around 窗口)时,目标可能正是那座孤岛上的行 —— 直接返回 - // covered 就等于承认"中间缺失"永久修不好。有孤岛时一律走下面的翻页补齐,让它自愈。 + // 快速通道只在目标落在**主连续段**里时可信:some(clientId) 是成员判定,不是连续覆盖 + // 判定。窗口里掺过孤岛(补齐失败时 merge 的 around 窗口)时,目标可能正是那座孤岛上的行 + // —— 直接返回 covered 就等于承认"中间缺失"永久修不好。目标在主段内(最新 → 目标的整段 + // 历史已确认连续)才允许零成本短路;落在孤岛上的一律走下面的翻页补齐,让它自愈。 const stateBeforeFetch = getOrCreateState(sessionId); if ( - stateBeforeFetch.historyWindowHasIsland !== true && - stateBeforeFetch.messages.some((message) => message.clientId === targetClientId) + isInsideMainContiguousRun( + stateBeforeFetch.messages, + stateBeforeFetch.historyWindowIslands, + targetClientId, + ) ) { - // 窗口本身连续(无孤岛)时,成员判定就等于覆盖判定,可以零成本短路。 + // 目标在主连续段内:成员判定就等于覆盖判定,可以零成本短路。 // 注意 ownsPagingLock=false:这条路一个请求都没发、也没置锁,所以提交时不得清锁、 // 不得写游标 —— 否则两个 around 响应同时落地时,这一次会把另一次刚拿到的锁清掉、 // 并覆写它的游标(#676 review codex P1)。 @@ -11638,17 +11914,26 @@ async function backfillHistoryUntil( const publishCollected = (): void => { if (collected.length === 0) return; const mapped = mapServerMessages(collected); - setState(sessionId, (s) => ({ - ...s, + setState(sessionId, (s) => { // addOnly:这些页可能是几秒前取到的,期间 live push 可能已经更新过其中某行; // 用旧快照 hydrate 会把 live 更新盖回去(见 mergeMessages 的 addOnly 说明)。 - messages: mergeMessages(mapped, s.messages, { addOnly: true }, 'newest-first'), - historyLoaded: true, - isFirstMessage: false, - oldestMessageId: cursorId ?? s.oldestMessageId, - hasMoreMessages: hasMoreAtEnd, - isLoadingMore: true, - })); + const messages = mergeMessages(mapped, s.messages, { addOnly: true }, 'newest-first'); + return { + ...s, + messages, + historyLoaded: true, + isFirstMessage: false, + oldestMessageId: cursorId ?? s.oldestMessageId, + hasMoreMessages: hasMoreAtEnd, + isLoadingMore: true, + // 同 loadOlderMessages:被翻页跨过的孤岛接回主段、移出模型。 + historyWindowIslands: absorbIslandsCrossedByPaging( + s.historyWindowIslands, + messages, + cursorId, + ), + }; + }); }; try { @@ -11809,19 +12094,25 @@ function commitAroundWindow( 'oldest-first', ); if (!ownsPagingLock) { - // mergeMessages 只增不减 → "长度没变"等价于"没引入可能不连续的行"。 - const addedRows = messages.length !== s.messages.length; // busy 意味着补齐**根本没跑**(让位给正在飞行的分页),这次 merge 进来的 around 行与 - // 尾部窗口之间可能隔着没加载的历史 → 记上孤岛,否则下次跳同一目标会命中成员快速通道、 - // 永远修不回连续。 + // 尾部窗口之间可能隔着没加载的历史 → 交给 updateIslandsAfterAroundMerge 按"around 块 + // 与窗口里既有行是否重叠"决定记孤岛、并入既有孤岛还是并入主段:一行没加进(addedRows + // 等价于 messages 没变)时窗口形状没动,孤岛集合原样返回 —— 绝不凭空记一座 + // (#676 review codex P1 的 busy 零新增用例)。 // - // covered 且不持锁 = 成员快速通道:它成立的前提就是"窗口无孤岛且目标在窗口里", - // 所以 radius 内的邻居与目标同处连续区间,不产生孤岛,标记不动。 - const marksIsland = outcome === 'busy' && addedRows; - if (marksIsland) return { ...s, messages, historyWindowHasIsland: true }; - // 真正的 no-op(merge 没换引用、也不用记孤岛)直接返回原 state:setState 只要 - // next !== prev 就通知订阅者,白发一次会让整棵消息树重渲染(#676 review copilot)。 - return messages === s.messages ? s : { ...s, messages }; + // covered 且不持锁 = 成员快速通道:它成立的前提就是"目标在主连续段里", + // 所以 radius 内的邻居与目标同处连续区间,不产生孤岛,孤岛集合不动。 + if (outcome === 'covered') { + return messages === s.messages ? s : { ...s, messages }; + } + const nextIslands = updateIslandsAfterAroundMerge( + s.historyWindowIslands, + s.messages, + messages, + rows, + ); + if (messages === s.messages && nextIslands === s.historyWindowIslands) return s; + return { ...s, messages, historyWindowIslands: nextIslands }; } // 已补齐:hasMore 归 backfill 维护,不能被 around 窗口的边界回退。 // 但游标要取两侧更早的那个:radius 决定的 around 窗口可能含比"命中那一页的 @@ -11839,10 +12130,11 @@ function commitAroundWindow( // (#676 review copilot)。 historyLoaded: true, isFirstMessage: false, - // 注意这里**不清**孤岛标记。补齐到达目标只证明"尾部 → 本次目标"连续,不证明更早的 - // 孤岛都被跨过:先前一次失败的深跳留下孤岛 A,之后跳一个更近的目标 B 并补齐成功, - // B↔A 之间的洞和 A 自己的行都还在。清掉唯一的 boolean 会让之后跳回 A 走成员快速 - // 通道、永不修复(#676 review 给的两孤岛序列)。 + // 注意这里**不动**孤岛集合:absorbIslandsCrossedByPaging 已按"本次翻页真的跨过" + // 逐座收口(目标所在的孤岛被接回主段时自然消失)。补齐到达目标只证明 + // "尾部 → 本次目标"连续,不证明更早的孤岛都被跨过:先前一次失败的深跳留下孤岛 A, + // 之后跳一个更近的目标 B 并补齐成功,B↔A 之间的洞和 A 自己的行都还在。清掉 + // 模型会让之后跳回 A 走成员快速通道、永不修复(#676 review 给的两孤岛序列)。 oldestMessageId: oldestServerMessageIdForWindow( rows, s.messages, @@ -11858,6 +12150,15 @@ function commitAroundWindow( messages, historyLoaded: true, isFirstMessage: false, + // 退回 around 窗口 = 窗口里多了一座孤岛(或并入既有孤岛;around 块与主段重叠时 + // 则是主段向更老延伸,不产生新孤岛 —— 见 updateIslandsAfterAroundMerge)。 + // 一行都没加进来时窗口形状没变,孤岛集合保持原值。 + historyWindowIslands: updateIslandsAfterAroundMerge( + s.historyWindowIslands, + s.messages, + messages, + rows, + ), // 游标:已有连续窗口时**留在它的边缘**,不跟着孤岛前移(#676 review)。 // // 退回 around 窗口时,缺失的区间比那座孤岛更新。若把 oldestMessageId 推到孤岛上, @@ -11884,9 +12185,6 @@ function commitAroundWindow( hasMoreMessages: addedRows ? true : s.hasMoreMessages, // 锁由本次提交释放。 isLoadingMore: false, - // 退回 around 窗口 = 窗口里多了一座孤岛(它与尾部窗口之间隔着没加载的历史)。 - // 同理:没加进任何行就没有新孤岛,保持原值。 - historyWindowHasIsland: addedRows ? true : s.historyWindowHasIsland, }; }); } @@ -13967,12 +14265,12 @@ async function clearSessionAfterGuardImpl(sessionId: string, clearedAt: string): streamingClientId: null, streamingText: '', isStreaming: false, - // 窗口清空 → 按构造没有孤岛,标记必须一起清零(与 reloadMessages / trim / demote - // 同规矩)。漏清的后果不是数据错而是永久降级:covered 刻意保留孤岛标记(到达本次 + // 窗口清空 → 按构造没有孤岛,模型必须一起清零(与 reloadMessages / trim / demote + // 同规矩)。漏清的后果不是数据错而是永久降级:covered 刻意保留孤岛区间(到达本次 // 目标不证明更早的洞都补上了),于是 /clear 之后这个会话永远被判为"不连续", // canFocusWithoutJumpLoad 拒绝每一次窗口内命中,每次搜索跳转都白跑一轮补齐 // (#676 review)。 - historyWindowHasIsland: false, + historyWindowIslands: EMPTY_WINDOW_ISLANDS, // 分页锁归本次重置释放(与 reloadMessages / dropMessagesFromClientId 同规矩): // 上面刚 bump epoch 作废了 in-flight 的翻页 / 跳转补齐,而被作废的请求不会(也不该) // 代清这把锁 —— 它们分辨不出锁属于哪一代。漏清会让行首守卫把该会话的翻页永久 diff --git a/apps/desktop/src/renderer/lib/searchJumpTargeting.ts b/apps/desktop/src/renderer/lib/searchJumpTargeting.ts index a9a018b686..2f4196c55e 100644 --- a/apps/desktop/src/renderer/lib/searchJumpTargeting.ts +++ b/apps/desktop/src/renderer/lib/searchJumpTargeting.ts @@ -7,20 +7,76 @@ * store 的孤岛感知补齐根本没机会跑(#676 review)。 */ +/** + * 一座"孤岛":跳转补齐失败时 merge 的 around 窗口。它与主连续段(最新尾段)之间隔着 + * 没加载的历史,所以即使目标行在窗口里,也不能当"窗口已连续覆盖到它"直接 focus —— + * 否则"中间缺失"永远修不回来。 + * + * 这是对"已加载窗口"的显式建模:单个 boolean 无法回答"目标落在哪一段"这个问题, + * 于是"孤岛 + 已翻到历史起点"的会话每次窗口内搜索都被迫多发一轮 around + list 探测 + * (searchJumpTargeting 旧版与 MessageStream 锚定窗口双向有界 TODO 同一条后续改动)。 + * 边界行 clientId 都保证在 messages 里(孤岛行全部来自 merge,不会被改名)。 + */ +export type LoadedWindowIsland = { + /** 孤岛最老一侧边界行的 clientId。 */ + oldestClientId: string; + /** 孤岛最新一侧边界行的 clientId。 */ + newestClientId: string; +}; + /** 判定所需的最小窗口状态,便于单测直接构造。 */ export type SearchJumpWindowState = { messages: readonly { clientId: string }[]; - /** 窗口里是否掺进过跳转孤岛(补齐失败时 merge 的 around 窗口)。 */ - historyWindowHasIsland?: boolean; + /** + * 窗口里掺进过的孤岛区间,按时间升序(最老在前)。 + * 缺省(undefined)等于"没有孤岛、整窗连续"。 + */ + historyWindowIslands?: readonly LoadedWindowIsland[]; }; +/** + * 主连续段(最新尾段)在 messages 里的起点下标。 + * + * 窗口是 chronological 升序排列的;孤岛全部落在主段更老的一侧,所以主段就是 + * 最后一个孤岛最新边界行之后的所有行。没有孤岛时整窗都算主段。 + * + * 边界行不在窗口里(理论上是模型被破坏,任何单点修复都不如保守)时返回 + * messages.length —— 主段为空,任何目标都走 store 补齐。 + */ +export function mainContiguousRunStartIndex( + messages: readonly { clientId: string }[], + islands: readonly LoadedWindowIsland[], +): number { + if (islands.length === 0) return 0; + const newestIsland = islands[islands.length - 1]; + const seamIndex = messages.findIndex( + (message) => message.clientId === newestIsland.newestClientId, + ); + return seamIndex < 0 ? messages.length : seamIndex + 1; +} + +/** + * 目标是否落在主连续段里 —— 即"窗口最新行 → 目标"这段历史已确认连续、中间没有缺口。 + * + * 这是 canFocusWithoutJumpLoad 与 store 侧补齐快速通道共用的同一把尺子,保证 + * 生产入口与自愈路径对"能否零成本 focus"的判定永远一致。 + */ +export function isInsideMainContiguousRun( + messages: readonly { clientId: string }[], + islands: readonly LoadedWindowIsland[], + targetClientId: string, +): boolean { + const targetIndex = messages.findIndex((message) => message.clientId === targetClientId); + if (targetIndex < 0) return false; + return targetIndex >= mainContiguousRunStartIndex(messages, islands); +} + /** * 能否直接 focus 已在窗口里的目标、跳过 store 的跳转加载? * - * 只有两个条件同时成立才可以: - * 1. 目标确实在当前窗口里; - * 2. 窗口没有孤岛 —— 否则"在窗口里"可能只是先前失败的深跳留下的孤立片段,它与已加载的 - * 尾部之间隔着没加载的历史。这时必须交给 store 重新补齐,否则中间缺失永远修不回来。 + * 只有"目标落在主连续段里"才成立:那等于"最新 → 目标"整段已确认连续,聚焦它 + * 不需要任何网络。目标虽然在 messages 里、却在某座孤岛上时,它与已加载的尾部之间 + * 隔着没加载的历史,必须交给 store 重新补齐,否则中间缺失永远修不回来。 * * 曾经加过一个例外:"有孤岛但 hasMoreMessages === false 时直接 focus",理由是分页只能往更老 * 翻、那边已经空了,补齐不可能改善覆盖。**这个理由是错的**,已撤掉:跳转不只走分页,它还发 @@ -30,12 +86,17 @@ export type SearchJumpWindowState = { * * 代价是"孤岛 + 已翻到历史起点"的会话每次窗口内搜索都会多打一次 around + 一次 list。要真正 * 判定"窗口已完整覆盖、无需再试",得把已加载区间显式建模(见 MessageStream 里锚定窗口双向 - * 有界的 TODO,同一条后续改动),不是一个 boolean 能承载的。 + * 有界的 TODO,同一条后续改动),不是一个 boolean 能承载的 —— 本文件与 makerChatStore 的 + * historyWindowIslands 就是这份显式建模:孤岛被向上翻页跨过(接回主段)后自动从模型里消失, + * 主段内的目标不再需要任何探测。 */ export function canFocusWithoutJumpLoad( state: SearchJumpWindowState, targetClientId: string, ): boolean { - if (state.historyWindowHasIsland === true) return false; - return state.messages.some((message) => message.clientId === targetClientId); + return isInsideMainContiguousRun( + state.messages, + state.historyWindowIslands ?? [], + targetClientId, + ); }