fix: prevent chat reload duplication

This commit is contained in:
Federico Jaramillo Martinez
2026-05-15 08:47:42 +02:00
parent b8256899c5
commit 0aa0a13a75
4 changed files with 59 additions and 21 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Fix chat history reloads so previously displayed messages are not duplicated from the browser cache.
+14 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { mergeChatHistory, type RawMessagePage } from "./chatHistoryCache";
function page(start: number, total: number, messages: string[]): RawMessagePage {
function page(start: number, total: number, messages: unknown[]): RawMessagePage {
return { start, total, messages };
}
@@ -37,4 +37,17 @@ describe("mergeChatHistory", () => {
expect(mergeChatHistory(page(0, 10, ["a", "b"]), incoming)).toEqual(incoming);
});
it("uses incoming history when cached history contains normalized chat lines", () => {
const incoming = page(0, 2, [{ role: "user", content: "fresh" }, { role: "assistant", content: "answer" }]);
const normalizedLine = { role: "assistant", parts: [{ type: "text", text: "duplicated display line" }] };
expect(mergeChatHistory(page(0, 2, [incoming.messages[0], normalizedLine]), incoming)).toEqual(incoming);
});
it("uses incoming history when cached history is longer than its raw range", () => {
const incoming = page(0, 2, ["fresh-a", "fresh-b"]);
expect(mergeChatHistory(page(0, 2, ["stale-a", "stale-b", "stale-c"]), incoming)).toEqual(incoming);
});
});
+27 -9
View File
@@ -36,7 +36,8 @@ export function writeChatHistoryCache(sessionId: string, page: RawMessagePage):
}
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
if (existing === undefined) return incoming;
if (existing === undefined || !isValidMessagePage(existing)) return incoming;
if (!isValidMessagePage(incoming)) return existing;
if (isCompleteReplacement(existing, incoming)) return incoming;
const start = Math.min(existing.start, incoming.start);
@@ -71,14 +72,31 @@ function cacheKey(sessionId: string): string {
}
function isCachedHistory(value: unknown): value is CachedChatHistory {
if (typeof value !== "object" || value === null) return false;
if (!("messages" in value) || !("start" in value) || !("total" in value) || !("savedAt" in value)) return false;
const { messages, start, total, savedAt } = value;
return Array.isArray(messages)
&& typeof start === "number"
&& typeof total === "number"
&& typeof savedAt === "number"
&& isValidMessagePage({ messages, start, total });
}
function isValidMessagePage(page: RawMessagePage): boolean {
return Number.isInteger(page.start)
&& Number.isInteger(page.total)
&& page.start >= 0
&& page.total >= page.start
&& page.messages.length <= page.total - page.start
&& !page.messages.some(isNormalizedChatLine);
}
function isNormalizedChatLine(value: unknown): boolean {
return typeof value === "object"
&& value !== null
&& "messages" in value
&& "start" in value
&& "total" in value
&& "savedAt" in value
&& Array.isArray(value.messages)
&& typeof value.start === "number"
&& typeof value.total === "number"
&& typeof value.savedAt === "number";
&& "role" in value
&& "parts" in value
&& !("content" in value)
&& typeof value.role === "string"
&& Array.isArray(value.parts);
}
+13 -11
View File
@@ -15,6 +15,7 @@ export class SessionController {
private catchupStreamSessionId: string | undefined;
private pendingTranscriptEvents: SessionUiEvent[] = [];
private pendingTranscriptFrame: number | undefined;
private readonly rawHistoryPages = new Map<string, RawMessagePage>();
constructor(
private readonly getState: GetState,
@@ -63,7 +64,7 @@ export class SessionController {
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
const cached = readChatHistoryCache(session.id);
const cached = this.rawHistoryPage(session.id);
this.setState({
selectedSession: session,
messages: normalizeMessages(cached?.messages ?? []),
@@ -78,7 +79,7 @@ export class SessionController {
if (session.archived === true) {
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.mergeAndCacheHistory(session.id, page, this.currentHistoryPage());
const history = this.mergeAndCacheHistory(session.id, page, cached);
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
if (options?.updateUrl !== false) this.updateUrl();
return;
@@ -91,7 +92,7 @@ export class SessionController {
);
const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]);
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.mergeAndCacheHistory(session.id, page, this.currentHistoryPage());
const history = this.mergeAndCacheHistory(session.id, page, cached);
const isReceivingPartialStream = status.isStreaming;
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined;
this.setState({ messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, isReceivingPartialStream, status, activity: this.getState().sessionActivities[session.id] });
@@ -110,7 +111,7 @@ export class SessionController {
if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
this.setState({ isLoadingEarlierMessages: true });
try {
const base = this.currentHistoryPage();
const base = this.rawHistoryPage(session.id);
const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
if (this.getState().selectedSession?.id !== session.id) return;
const history = this.mergeAndCacheHistory(session.id, page, base);
@@ -298,7 +299,7 @@ export class SessionController {
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
try {
this.flushPendingTranscriptEvents();
const base = this.currentHistoryPage();
const base = this.rawHistoryPage(sessionId);
const [page, status] = await Promise.all([api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), api.status(sessionId)]);
if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.mergeAndCacheHistory(sessionId, page, base);
@@ -324,16 +325,17 @@ export class SessionController {
});
}
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage, base = readChatHistoryCache(sessionId)): RawMessagePage {
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage, base = this.rawHistoryPage(sessionId)): RawMessagePage {
const history = mergeChatHistory(base, page);
this.rawHistoryPages.set(sessionId, history);
writeChatHistoryCache(sessionId, history);
return history;
}
private currentHistoryPage(): RawMessagePage | undefined {
const state = this.getState();
if (state.messages.length === 0 && state.messagePageTotal === 0) return undefined;
return { messages: state.messages, start: state.messagePageStart, total: state.messagePageTotal };
private rawHistoryPage(sessionId: string): RawMessagePage | undefined {
const cached = this.rawHistoryPages.get(sessionId) ?? readChatHistoryCache(sessionId);
if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
return cached;
}
private applyCommandResult(result: CommandResult) {
@@ -443,7 +445,7 @@ export class SessionController {
private async refreshMessages(sessionId: string) {
try {
const base = this.currentHistoryPage();
const base = this.rawHistoryPage(sessionId);
const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.mergeAndCacheHistory(sessionId, page, base);