From 0aa0a13a75b6ababf9be9a4417d2f7144feba58b Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 15 May 2026 08:47:42 +0200 Subject: [PATCH] fix: prevent chat reload duplication --- .changeset/fuzzy-rabbits-reload.md | 5 +++ src/client/src/chatHistoryCache.test.ts | 15 +++++++- src/client/src/chatHistoryCache.ts | 36 ++++++++++++++----- .../src/controllers/sessionController.ts | 24 +++++++------ 4 files changed, 59 insertions(+), 21 deletions(-) create mode 100644 .changeset/fuzzy-rabbits-reload.md diff --git a/.changeset/fuzzy-rabbits-reload.md b/.changeset/fuzzy-rabbits-reload.md new file mode 100644 index 0000000..1f407e7 --- /dev/null +++ b/.changeset/fuzzy-rabbits-reload.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix chat history reloads so previously displayed messages are not duplicated from the browser cache. diff --git a/src/client/src/chatHistoryCache.test.ts b/src/client/src/chatHistoryCache.test.ts index 5fd87ff..5aa6a26 100644 --- a/src/client/src/chatHistoryCache.test.ts +++ b/src/client/src/chatHistoryCache.test.ts @@ -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); + }); }); diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts index 7cf306c..b32cb1e 100644 --- a/src/client/src/chatHistoryCache.ts +++ b/src/client/src/chatHistoryCache.ts @@ -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); } diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 475b9b8..61c3bce 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -15,6 +15,7 @@ export class SessionController { private catchupStreamSessionId: string | undefined; private pendingTranscriptEvents: SessionUiEvent[] = []; private pendingTranscriptFrame: number | undefined; + private readonly rawHistoryPages = new Map(); 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);