From 70d29dc70e097d7a25a9adbc0ed8b46200a29305 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Sat, 14 Feb 2026 14:25:41 +0000 Subject: [PATCH] feat: preserve messages after compaction (IndexedDB cache) + show agent name in header - Add IndexedDB message cache to retain pre-compaction history locally - Show compaction separator with amber styling when messages are compacted - Archived messages render at 60% opacity above the separator - Display agent name (from gateway identity) in header instead of 'PinchChat' when available - Fallback to 'PinchChat' when no agent name is configured - Add i18n keys for compaction separator (EN/FR) Closes #72, #69 --- src/App.tsx | 2 +- src/components/Chat.tsx | 17 +++++- src/components/Header.tsx | 5 +- src/hooks/useGateway.ts | 15 ++++- src/lib/i18n.ts | 2 + src/lib/messageCache.ts | 113 ++++++++++++++++++++++++++++++++++++++ src/types/index.ts | 4 ++ 7 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/lib/messageCache.ts diff --git a/src/App.tsx b/src/App.tsx index 94e24bf..00813b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -152,7 +152,7 @@ export default function App() {
{/* Primary pane */}
-
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} /> +
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} agentName={agentIdentity?.name} />
Loading…
}> diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index d43d7fc..cba8e27 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -302,6 +302,21 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session )} {visibleMessages.map(({ msg, showSep, isFirstInGroup }) => { const isActiveMatch = searchMatches.length > 0 && searchMatches[searchActiveIndex] === msg.id; + + // Render compaction separator + if (msg.isCompactionSeparator) { + return ( +
+
+ + + {t('chat.contextCompacted')} + +
+
+ ); + } + return (
{showSep && ( @@ -311,7 +326,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
)} -
+
toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined} />
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3e5c015..2764821 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -17,9 +17,10 @@ interface Props { onToggleSound?: () => void; messages?: ChatMessage[]; agentAvatarUrl?: string; + agentName?: string; } -export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl }: Props) { +export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl, agentName }: Props) { const t = useT(); const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey); @@ -41,7 +42,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, PinchChat { const img = e.target as HTMLImageElement; if (img.src !== window.location.origin + '/logo.png') { img.src = '/logo.png'; } else { img.style.display = 'none'; } }} />
- {t('header.title')} + {agentName || t('header.title')}
diff --git a/src/hooks/useGateway.ts b/src/hooks/useGateway.ts index 15b3ebb..3e8c1a9 100644 --- a/src/hooks/useGateway.ts +++ b/src/hooks/useGateway.ts @@ -3,6 +3,7 @@ import { GatewayClient, type JsonPayload } from '../lib/gateway'; import { genIdempotencyKey } from '../lib/utils'; import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials'; import { isSystemEvent } from '../lib/systemEvent'; +import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache'; import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types'; interface ChatPayloadMessage { @@ -249,7 +250,19 @@ export function useGateway() { } } } - setMessages(merged); + // Merge with cached messages to preserve pre-compaction history + const cached = await getCachedMessages(sessionKey); + const { messages: finalMessages, wasCompacted } = mergeWithCache(merged, cached); + + if (wasCompacted) { + // Store the full merged set so future loads keep the archive + setCachedMessages(sessionKey, finalMessages.filter(m => !m.isCompactionSeparator)); + } else { + // No compaction — update cache with latest gateway messages + setCachedMessages(sessionKey, merged); + } + + setMessages(finalMessages); } } catch { // Silently ignore history load failures diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 2a287f8..4db21b8 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -147,6 +147,7 @@ const en = { 'message.bookmark': 'Bookmark message', 'message.removeBookmark': 'Remove bookmark', 'chat.bookmarks': 'Bookmarks', + 'chat.contextCompacted': 'Context compacted — older messages cached locally', } as const; const fr: Record = { @@ -273,6 +274,7 @@ const fr: Record = { 'message.bookmark': 'Marquer le message', 'message.removeBookmark': 'Retirer le marque-page', 'chat.bookmarks': 'Marque-pages', + 'chat.contextCompacted': 'Contexte compacté — anciens messages en cache local', }; export type TranslationKey = keyof typeof en; diff --git a/src/lib/messageCache.ts b/src/lib/messageCache.ts new file mode 100644 index 0000000..9d7dbe6 --- /dev/null +++ b/src/lib/messageCache.ts @@ -0,0 +1,113 @@ +/** + * IndexedDB-based message cache for preserving chat history across compactions. + * When the gateway compacts a session, older messages disappear from the API. + * This cache retains them locally so users can still scroll back. + */ + +import type { ChatMessage } from '../types'; + +const DB_NAME = 'pinchchat-messages'; +const DB_VERSION = 1; +const STORE_NAME = 'messages'; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** + * Get cached messages for a session. + */ +export async function getCachedMessages(sessionKey: string): Promise { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(sessionKey); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + } catch { + return []; + } +} + +/** + * Store messages for a session (full replacement). + */ +export async function setCachedMessages(sessionKey: string, messages: ChatMessage[]): Promise { + try { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put(messages, sessionKey); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } catch { + // Silently ignore cache write failures + } +} + +/** + * Merge gateway messages with cached messages. + * If compaction occurred (cached has older messages not in gateway response), + * returns the full history with a compaction separator inserted. + * + * Returns { messages, wasCompacted }. + */ +export function mergeWithCache( + gatewayMessages: ChatMessage[], + cachedMessages: ChatMessage[], +): { messages: ChatMessage[]; wasCompacted: boolean } { + if (cachedMessages.length === 0) { + return { messages: gatewayMessages, wasCompacted: false }; + } + + // Find the earliest gateway message ID to detect overlap + const gatewayIds = new Set(gatewayMessages.map(m => m.id)); + + // Find cached messages that are NOT in the gateway response + // These are messages that were compacted away + const missingFromGateway = cachedMessages.filter(m => !gatewayIds.has(m.id)); + + if (missingFromGateway.length === 0) { + // No compaction — gateway has all messages (or more) + return { messages: gatewayMessages, wasCompacted: false }; + } + + // Compaction detected — merge old cached messages + separator + gateway messages + // Mark old messages so UI can style them differently + const archivedMessages = missingFromGateway.map(m => ({ + ...m, + isArchived: true, + })); + + // Insert a compaction separator + const separator: ChatMessage = { + id: 'compaction-separator-' + Date.now(), + role: 'assistant' as const, + content: '', + timestamp: gatewayMessages.length > 0 + ? gatewayMessages[0].timestamp - 1 + : Date.now(), + blocks: [], + isCompactionSeparator: true, + }; + + return { + messages: [...archivedMessages, separator, ...gatewayMessages], + wasCompacted: true, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 58f20c7..dee75c3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,10 @@ export interface ChatMessage { streamStartedAt?: number; /** Total generation time in milliseconds (set when streaming ends) */ generationTimeMs?: number; + /** True if this message was restored from local cache (pre-compaction) */ + isArchived?: boolean; + /** True if this is a visual separator showing where compaction occurred */ + isCompactionSeparator?: boolean; } export type MessageBlock =