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
This commit is contained in:
@@ -152,7 +152,7 @@ export default function App() {
|
||||
<div ref={splitContainerRef} className="flex-1 flex min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
||||
{/* Primary pane */}
|
||||
<main className="flex flex-col min-w-0" style={splitSession ? { width: `${splitRatio}%` } : { flex: 1 }} aria-label={t('app.mainChat')}>
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} />
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} agentName={agentIdentity?.name} />
|
||||
<ConnectionBanner status={status} />
|
||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-pc-text-muted"><div className="animate-pulse text-sm">Loading…</div></div>}>
|
||||
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} agentAvatarUrl={agentIdentity?.avatar} />
|
||||
|
||||
@@ -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 (
|
||||
<div key={msg.id} className="flex items-center gap-3 py-4 px-4 select-none">
|
||||
<div className="flex-1 h-px bg-amber-500/30" />
|
||||
<span className="text-[11px] font-medium text-amber-400/70 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||
{t('chat.contextCompacted')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-amber-500/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} data-msg-id={msg.id}>
|
||||
{showSep && (
|
||||
@@ -311,7 +326,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
<div className="flex-1 h-px bg-[var(--pc-hover-strong)]" />
|
||||
</div>
|
||||
)}
|
||||
<div className={isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''}>
|
||||
<div className={`${isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''} ${msg.isArchived ? 'opacity-60' : ''}`}>
|
||||
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} agentAvatarUrl={agentAvatarUrl} isFirstInGroup={isFirstInGroup} isBookmarked={isBookmarked(msg.id)} onToggleBookmark={sessionKey ? () => toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
<img src={agentAvatarUrl || '/logo.png'} alt="PinchChat" className="h-9 w-9 rounded-2xl object-cover" onError={(e) => { const img = e.target as HTMLImageElement; if (img.src !== window.location.origin + '/logo.png') { img.src = '/logo.png'; } else { img.style.display = 'none'; } }} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-pc-text text-sm tracking-wide">{t('header.title')}</span>
|
||||
<span className="font-semibold text-pc-text text-sm tracking-wide">{agentName || t('header.title')}</span>
|
||||
<Sparkles className="h-3.5 w-3.5 text-pc-accent-light/60" />
|
||||
</div>
|
||||
<span className="text-xs text-pc-text-muted truncate flex items-center gap-1.5">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<keyof typeof en, string> = {
|
||||
@@ -273,6 +274,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'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;
|
||||
|
||||
113
src/lib/messageCache.ts
Normal file
113
src/lib/messageCache.ts
Normal file
@@ -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<IDBDatabase> {
|
||||
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<ChatMessage[]> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user