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 { describe, expect, it } from "vitest";
import { mergeChatHistory, type RawMessagePage } from "./chatHistoryCache"; 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 }; return { start, total, messages };
} }
@@ -37,4 +37,17 @@ describe("mergeChatHistory", () => {
expect(mergeChatHistory(page(0, 10, ["a", "b"]), incoming)).toEqual(incoming); 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 { 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; if (isCompleteReplacement(existing, incoming)) return incoming;
const start = Math.min(existing.start, incoming.start); const start = Math.min(existing.start, incoming.start);
@@ -71,14 +72,31 @@ function cacheKey(sessionId: string): string {
} }
function isCachedHistory(value: unknown): value is CachedChatHistory { 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" return typeof value === "object"
&& value !== null && value !== null
&& "messages" in value && "role" in value
&& "start" in value && "parts" in value
&& "total" in value && !("content" in value)
&& "savedAt" in value && typeof value.role === "string"
&& Array.isArray(value.messages) && Array.isArray(value.parts);
&& typeof value.start === "number"
&& typeof value.total === "number"
&& typeof value.savedAt === "number";
} }
+13 -11
View File
@@ -15,6 +15,7 @@ export class SessionController {
private catchupStreamSessionId: string | undefined; private catchupStreamSessionId: string | undefined;
private pendingTranscriptEvents: SessionUiEvent[] = []; private pendingTranscriptEvents: SessionUiEvent[] = [];
private pendingTranscriptFrame: number | undefined; private pendingTranscriptFrame: number | undefined;
private readonly rawHistoryPages = new Map<string, RawMessagePage>();
constructor( constructor(
private readonly getState: GetState, private readonly getState: GetState,
@@ -63,7 +64,7 @@ export class SessionController {
this.socket.close(); this.socket.close();
this.catchupStreamSessionId = undefined; this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents(); this.clearPendingTranscriptEvents();
const cached = readChatHistoryCache(session.id); const cached = this.rawHistoryPage(session.id);
this.setState({ this.setState({
selectedSession: session, selectedSession: session,
messages: normalizeMessages(cached?.messages ?? []), messages: normalizeMessages(cached?.messages ?? []),
@@ -78,7 +79,7 @@ export class SessionController {
if (session.archived === true) { if (session.archived === true) {
const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }); const page = await api.messages(session.id, { limit: MESSAGE_PAGE_SIZE });
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return; 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 }); 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(); if (options?.updateUrl !== false) this.updateUrl();
return; 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)]); 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; 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; const isReceivingPartialStream = status.isStreaming;
this.catchupStreamSessionId = isReceivingPartialStream ? session.id : undefined; 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] }); 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; if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return;
this.setState({ isLoadingEarlierMessages: true }); this.setState({ isLoadingEarlierMessages: true });
try { 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 }); const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE });
if (this.getState().selectedSession?.id !== session.id) return; if (this.getState().selectedSession?.id !== session.id) return;
const history = this.mergeAndCacheHistory(session.id, page, base); 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; if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
try { try {
this.flushPendingTranscriptEvents(); 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)]); const [page, status] = await Promise.all([api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }), api.status(sessionId)]);
if (this.getState().selectedSession?.id !== sessionId) return; if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.mergeAndCacheHistory(sessionId, page, base); 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); const history = mergeChatHistory(base, page);
this.rawHistoryPages.set(sessionId, history);
writeChatHistoryCache(sessionId, history); writeChatHistoryCache(sessionId, history);
return history; return history;
} }
private currentHistoryPage(): RawMessagePage | undefined { private rawHistoryPage(sessionId: string): RawMessagePage | undefined {
const state = this.getState(); const cached = this.rawHistoryPages.get(sessionId) ?? readChatHistoryCache(sessionId);
if (state.messages.length === 0 && state.messagePageTotal === 0) return undefined; if (cached !== undefined) this.rawHistoryPages.set(sessionId, cached);
return { messages: state.messages, start: state.messagePageStart, total: state.messagePageTotal }; return cached;
} }
private applyCommandResult(result: CommandResult) { private applyCommandResult(result: CommandResult) {
@@ -443,7 +445,7 @@ export class SessionController {
private async refreshMessages(sessionId: string) { private async refreshMessages(sessionId: string) {
try { try {
const base = this.currentHistoryPage(); const base = this.rawHistoryPage(sessionId);
const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE }); const page = await api.messages(sessionId, { limit: MESSAGE_PAGE_SIZE });
if (this.getState().selectedSession?.id !== sessionId) return; if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.mergeAndCacheHistory(sessionId, page, base); const history = this.mergeAndCacheHistory(sessionId, page, base);