fix (windowed chat): Fix a regression introduced by the windowed state merging logic.

This commit is contained in:
Darkatse 2026-04-06 17:25:16 -04:00
parent 40818607e8
commit 1cacb1fa17
5 changed files with 107 additions and 16 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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/);
});

View file

@ -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,
);
});