diff --git a/src/client/src/chatHistoryCache.ts b/src/client/src/chatHistoryCache.ts new file mode 100644 index 0000000..6868fe3 --- /dev/null +++ b/src/client/src/chatHistoryCache.ts @@ -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(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"; +} diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 870d668..068cc64 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -2,6 +2,7 @@ import { api, type CommandResult, type SessionActivity, type SessionInfo, type S const MESSAGE_PAGE_SIZE = 100; import { normalizeMessages, textMessage } from "../chatMessages"; +import { readChatHistoryCache, mergeChatHistory, writeChatHistoryCache, type RawMessagePage } from "../chatHistoryCache"; import { applyTranscriptEvent } from "../chatTranscript"; import { isShellInput } from "../inputModes"; import { GlobalSessionSocket, SessionSocket, type SessionUiEvent } from "../sessionSocket"; @@ -48,7 +49,8 @@ export class SessionController { const buffered: SessionUiEvent[] = []; 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)]); - 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); for (const event of buffered) this.applyEvent(event); this.socket.setHandler((event) => { this.applyEvent(event); }); @@ -66,10 +68,11 @@ export class SessionController { try { 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); this.setState({ - messages: [...normalizeMessages(page.messages), ...this.getState().messages], - messagePageStart: page.start, - messagePageTotal: page.total, + messages: normalizeMessages(history.messages), + messagePageStart: history.start, + messagePageTotal: history.total, }); } catch (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) { if (result.type === "select") { this.setState({ commandDialog: result });