diff --git a/src/script.js b/src/script.js index cd66d1c9..b9dad2e2 100644 --- a/src/script.js +++ b/src/script.js @@ -7773,7 +7773,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false } const activeWindowState = getWindowedChatState(); if (getWindowedChatKey(activeWindowState) === expectedWindowKey) { - const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor); + const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor, expectedCursorOffset); const nextWindowState = { ...activeWindowState, cursor: mergedCursor, diff --git a/src/scripts/group-chats.js b/src/scripts/group-chats.js index c4e66796..32237796 100644 --- a/src/scripts/group-chats.js +++ b/src/scripts/group-chats.js @@ -752,7 +752,7 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) { const activeWindowState = getWindowedChatState(); if (getWindowedChatKey(activeWindowState) === expectedWindowKey) { - const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor); + const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor, expectedCursorOffset); const nextWindowState = { ...activeWindowState, cursor: mergedCursor, diff --git a/src/scripts/tauri/chat/windowed-state.js b/src/scripts/tauri/chat/windowed-state.js index 5376aa2f..14a514de 100644 --- a/src/scripts/tauri/chat/windowed-state.js +++ b/src/scripts/tauri/chat/windowed-state.js @@ -38,7 +38,7 @@ export function getWindowedChatKey(windowState) { return `character:${String(windowState.characterName || '').trim()}|${String(windowState.avatarUrl || '').trim()}|${String(windowState.fileName || '').trim()}`; } -export function mergeWindowedChatCursorOffset(activeCursor, nextCursor) { +export function mergeWindowedChatCursorOffset(activeCursor, nextCursor, expectedBaseOffset) { if (!nextCursor) { return activeCursor ?? null; } @@ -47,9 +47,16 @@ export function mergeWindowedChatCursorOffset(activeCursor, nextCursor) { return nextCursor; } + const baseOffset = Number(expectedBaseOffset); + if (!Number.isFinite(baseOffset) || baseOffset < 0) { + throw new Error('Windowed chat cursor base offset is missing'); + } + + const delta = Number(nextCursor.offset) - baseOffset; + return { ...nextCursor, - offset: activeCursor.offset, + offset: Number(activeCursor.offset) + delta, }; } diff --git a/tests/windowed-payload-guards-contract.test.mjs b/tests/windowed-payload-guards-contract.test.mjs index cb3441e8..31463df5 100644 --- a/tests/windowed-payload-guards-contract.test.mjs +++ b/tests/windowed-payload-guards-contract.test.mjs @@ -14,6 +14,21 @@ test('windowed payload: showMoreMessages implements single-flight + CAS commit', assert.match(source, /getWindowedChatState\(\)\s*!==\s*windowState/); }); +test('windowed payload: showMoreMessages reindexes DOM and shifts windowed save counters after prepend', async () => { + const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8'); + + const showMoreStart = source.indexOf('export async function showMoreMessages'); + assert.ok(showMoreStart >= 0); + const slice = source.slice(showMoreStart, showMoreStart + 3000); + + assert.match(slice, /updateViewMessageIds\(\s*0\s*\)\s*;/); + assert.match( + slice, + /const\s+shiftedState\s*=\s*shiftWindowedMessageSaveState\(\s*windowState\s*,\s*messages\.length\s*,\s*['"]chat['"]\s*\)\s*;/, + ); + assert.match(slice, /\.\.\.\s*shiftedState\s*,/); +}); + test('windowed payload: clearChat(clearData:true) invalidates windowed state immediately', async () => { const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8'); @@ -43,12 +58,31 @@ test('windowed payload: group tail-load does not mutate window state for backgro assert.match(source, /if\s*\(\s*updateWindowState[\s\S]*?setWindowedChatState\s*\(/s); }); -test('windowed payload: windowed patch commit is guarded and preserves active cursor offset', async () => { +test('windowed payload: windowed patch commit is guarded and merges cursor offsets', async () => { const script = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8'); const groupChats = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8'); - assert.match(script, /\bgetWindowedChatKey\b/); - assert.match(script, /\bmergeWindowedChatCursorOffset\b/); - assert.match(groupChats, /\bgetWindowedChatKey\b/); - assert.match(groupChats, /\bmergeWindowedChatCursorOffset\b/); + const saveChatStart = script.indexOf('export async function saveChat'); + assert.ok(saveChatStart >= 0); + const saveChatSlice = script.slice(saveChatStart, saveChatStart + 3200); + + assert.match(saveChatSlice, /\bgetWindowedChatKey\b/); + assert.match(saveChatSlice, /const\s+expectedCursorOffset\s*=\s*windowState\.cursor\.offset\s*;/); + assert.match( + saveChatSlice, + /mergeWindowedChatCursorOffset\(\s*activeWindowState\?\.\s*cursor\s*,\s*cursor\s*,\s*expectedCursorOffset\s*\)/, + ); + assert.match(saveChatSlice, /activeWindowState\?\.\s*cursor\?\.\s*offset\s*===\s*expectedCursorOffset/); + + const saveGroupStart = groupChats.indexOf('async function saveGroupChat'); + assert.ok(saveGroupStart >= 0); + const saveGroupSlice = groupChats.slice(saveGroupStart, saveGroupStart + 2400); + + assert.match(saveGroupSlice, /\bgetWindowedChatKey\b/); + assert.match(saveGroupSlice, /const\s+expectedCursorOffset\s*=\s*windowState\.cursor\.offset\s*;/); + assert.match( + saveGroupSlice, + /mergeWindowedChatCursorOffset\(\s*activeWindowState\?\.\s*cursor\s*,\s*cursor\s*,\s*expectedCursorOffset\s*\)/, + ); + assert.match(saveGroupSlice, /activeWindowState\?\.\s*cursor\?\.\s*offset\s*===\s*expectedCursorOffset/); }); diff --git a/tests/windowed-payload-state.test.mjs b/tests/windowed-payload-state.test.mjs index a1a6e3c6..fbdc2615 100644 --- a/tests/windowed-payload-state.test.mjs +++ b/tests/windowed-payload-state.test.mjs @@ -44,27 +44,77 @@ test('windowed-state: getWindowedChatKey trims and separates kinds', async () => }), 'character:Alice|/User%20Avatars/a.png|chat-1'); }); -test('windowed-state: mergeWindowedChatCursorOffset preserves active offset', async () => { +test('windowed-state: mergeWindowedChatCursorOffset applies header delta to active offset', async () => { const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js')); const { mergeWindowedChatCursorOffset } = mod; - assert.equal(mergeWindowedChatCursorOffset(null, null), null); + assert.equal(mergeWindowedChatCursorOffset(null, null, 0), null); assert.deepEqual( - mergeWindowedChatCursorOffset(null, { offset: 10, size: 1, modifiedMillis: 2 }), + mergeWindowedChatCursorOffset(null, { offset: 10, size: 1, modifiedMillis: 2 }, 10), { offset: 10, size: 1, modifiedMillis: 2 }, ); assert.deepEqual( - mergeWindowedChatCursorOffset({ offset: 10, size: 1, modifiedMillis: 2 }, null), + mergeWindowedChatCursorOffset({ offset: 10, size: 1, modifiedMillis: 2 }, null, 10), { offset: 10, size: 1, modifiedMillis: 2 }, ); assert.deepEqual( mergeWindowedChatCursorOffset( - { offset: 10, size: 1, modifiedMillis: 2 }, - { offset: 999, size: 2, modifiedMillis: 3 }, + { offset: 20, size: 1, modifiedMillis: 2 }, + { offset: 10, size: 2, modifiedMillis: 3 }, + 10, ), - { offset: 10, size: 2, modifiedMillis: 3 }, + { offset: 20, size: 2, modifiedMillis: 3 }, + ); + + assert.deepEqual( + mergeWindowedChatCursorOffset( + { offset: 20, size: 1, modifiedMillis: 2 }, + { offset: 12, size: 2, modifiedMillis: 3 }, + 10, + ), + { offset: 22, size: 2, modifiedMillis: 3 }, + ); + + assert.deepEqual( + mergeWindowedChatCursorOffset( + { offset: 20, size: 1, modifiedMillis: 2 }, + { offset: 8, size: 2, modifiedMillis: 3 }, + 10, + ), + { offset: 18, size: 2, modifiedMillis: 3 }, + ); +}); + +test('windowed-state: mergeWindowedChatCursorOffset throws on missing or invalid base offset', async () => { + const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js')); + const { mergeWindowedChatCursorOffset } = mod; + + assert.throws( + () => mergeWindowedChatCursorOffset( + { offset: 10, size: 1, modifiedMillis: 2 }, + { offset: 12, size: 2, modifiedMillis: 3 }, + ), + /base offset/i, + ); + + assert.throws( + () => mergeWindowedChatCursorOffset( + { offset: 10, size: 1, modifiedMillis: 2 }, + { offset: 12, size: 2, modifiedMillis: 3 }, + NaN, + ), + /base offset/i, + ); + + assert.throws( + () => mergeWindowedChatCursorOffset( + { offset: 10, size: 1, modifiedMillis: 2 }, + { offset: 12, size: 2, modifiedMillis: 3 }, + -1, + ), + /base offset/i, ); });