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,
{ 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 =