Cache loaded chat history in browser

This commit is contained in:
Federico Jaramillo Martinez
2026-05-07 15:52:05 +02:00
parent 046d41f202
commit c7119c1197
2 changed files with 84 additions and 4 deletions
+71
View File
@@ -0,0 +1,71 @@
const CACHE_PREFIX = "pi-web:chat-history:";
const CACHE_TTL_MS = 30 * 60 * 1000;
export interface RawMessagePage {
messages: unknown[];
start: number;
total: number;
}
export interface CachedChatHistory extends RawMessagePage {
savedAt: number;
}
export function readChatHistoryCache(sessionId: string): RawMessagePage | undefined {
try {
const raw = sessionStorage.getItem(cacheKey(sessionId));
if (raw === null || raw === "") return undefined;
const parsed: unknown = JSON.parse(raw);
if (!isCachedHistory(parsed)) return undefined;
if (Date.now() - parsed.savedAt > CACHE_TTL_MS) {
sessionStorage.removeItem(cacheKey(sessionId));
return undefined;
}
return { messages: parsed.messages, start: parsed.start, total: parsed.total };
} catch {
return undefined;
}
}
export function writeChatHistoryCache(sessionId: string, page: RawMessagePage): void {
try {
sessionStorage.setItem(cacheKey(sessionId), JSON.stringify({ ...page, savedAt: Date.now() }));
} catch {
// Ignore quota/private-mode failures; history paging still works without cache.
}
}
export function mergeChatHistory(existing: RawMessagePage | undefined, incoming: RawMessagePage): RawMessagePage {
if (!existing || existing.total !== incoming.total) return incoming;
const start = Math.min(existing.start, incoming.start);
const end = Math.max(existing.start + existing.messages.length, incoming.start + incoming.messages.length);
const messages = new Array<unknown>(end - start);
copyInto(messages, start, existing);
copyInto(messages, start, incoming);
return { start, total: incoming.total, messages: messages.filter((message) => message !== undefined) };
}
function copyInto(target: unknown[], targetStart: number, page: RawMessagePage): void {
page.messages.forEach((message, index) => {
target[page.start - targetStart + index] = message;
});
}
function cacheKey(sessionId: string): string {
return `${CACHE_PREFIX}${sessionId}`;
}
function isCachedHistory(value: unknown): value is CachedChatHistory {
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";
}
@@ -2,6 +2,7 @@ import { api, type CommandResult, type SessionActivity, type SessionInfo, type S
const MESSAGE_PAGE_SIZE = 100; const MESSAGE_PAGE_SIZE = 100;
import { normalizeMessages, textMessage } from "../chatMessages"; import { normalizeMessages, textMessage } from "../chatMessages";
import { readChatHistoryCache, mergeChatHistory, writeChatHistoryCache, type RawMessagePage } from "../chatHistoryCache";
import { applyTranscriptEvent } from "../chatTranscript"; import { applyTranscriptEvent } from "../chatTranscript";
import { isShellInput } from "../inputModes"; import { isShellInput } from "../inputModes";
import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket"; import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket";
@@ -48,7 +49,8 @@ export class SessionController {
const buffered: SessionUiEvent[] = []; const buffered: SessionUiEvent[] = [];
this.socket.connect(session.id, (event) => buffered.push(event)); this.socket.connect(session.id, (event) => buffered.push(event));
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)]);
this.setState({ selectedSession: session, messages: normalizeMessages(page.messages), messagePageStart: page.start, messagePageTotal: page.total, isLoadingEarlierMessages: false, status }); const history = this.mergeAndCacheHistory(session.id, page);
this.setState({ selectedSession: session, messages: normalizeMessages(history.messages), messagePageStart: history.start, messagePageTotal: history.total, isLoadingEarlierMessages: false, status });
this.applyStatus(status); this.applyStatus(status);
for (const event of buffered) this.applyEvent(event); for (const event of buffered) this.applyEvent(event);
this.socket.setHandler((event) => { this.applyEvent(event); }); this.socket.setHandler((event) => { this.applyEvent(event); });
@@ -66,10 +68,11 @@ export class SessionController {
try { try {
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);
this.setState({ this.setState({
messages: [...normalizeMessages(page.messages), ...this.getState().messages], messages: normalizeMessages(history.messages),
messagePageStart: page.start, messagePageStart: history.start,
messagePageTotal: page.total, messagePageTotal: history.total,
}); });
} catch (error) { } catch (error) {
this.setState({ error: String(error) }); this.setState({ error: String(error) });
@@ -142,6 +145,12 @@ export class SessionController {
} }
} }
private mergeAndCacheHistory(sessionId: string, page: RawMessagePage): RawMessagePage {
const history = mergeChatHistory(readChatHistoryCache(sessionId), page);
writeChatHistoryCache(sessionId, history);
return history;
}
private applyCommandResult(result: CommandResult) { private applyCommandResult(result: CommandResult) {
if (result.type === "select") { if (result.type === "select") {
this.setState({ commandDialog: result }); this.setState({ commandDialog: result });