Skip to content

Commit 9ecd345

Browse files
committed
Improve chat resilience
1 parent 8945553 commit 9ecd345

8 files changed

Lines changed: 417 additions & 88 deletions

File tree

src/bridge/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
export { EcaRemoteApi } from './api';
1414
export { testConnection } from './connection';
15+
export { messageCache, MessageCache } from './message-cache';
1516
export { SSEClient } from './sse';
1617
export { WebBridge } from './transport';
18+
export type { CachedChat } from './message-cache';
1719
export type { SSEEvent, SSEClientOptions } from './sse';
1820
export type * from './types';

src/bridge/message-cache.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Message cache — singleton in-memory cache for chat messages.
3+
*
4+
* Lives outside WebBridge so it survives bridge destruction (e.g. tab
5+
* switching, reconnection). Keyed by server host + chatId to avoid
6+
* cross-server pollution in multi-connection scenarios.
7+
*
8+
* Design:
9+
* - Staleness TTL (default 5 min) — stale entries are treated as misses
10+
* - LRU eviction when max entry count is exceeded
11+
* - Incremental updates via appendEvent() to keep cache fresh from SSE
12+
*/
13+
14+
import type { RemoteChat, StoredMessage } from './types';
15+
16+
/** A cached chat entry with metadata for staleness checks. */
17+
export interface CachedChat {
18+
/** The full chat object (with messages) as returned by the REST API. */
19+
chat: RemoteChat;
20+
/** Timestamp (epoch ms) of when this entry was last written or updated. */
21+
timestamp: number;
22+
}
23+
24+
/** Configuration for the message cache. */
25+
interface MessageCacheConfig {
26+
/** Maximum number of cached chats (default: 20). */
27+
maxEntries: number;
28+
/** Time-to-live in milliseconds before an entry is considered stale (default: 5 min). */
29+
staleTTL: number;
30+
}
31+
32+
const DEFAULT_CONFIG: MessageCacheConfig = {
33+
maxEntries: 20,
34+
staleTTL: 5 * 60 * 1000, // 5 minutes
35+
};
36+
37+
/**
38+
* In-memory LRU cache for chat message history.
39+
*
40+
* Entries are keyed by `${host}::${chatId}` so multiple server
41+
* connections can coexist without interference.
42+
*/
43+
export class MessageCache {
44+
private cache = new Map<string, CachedChat>();
45+
private config: MessageCacheConfig;
46+
47+
constructor(config: Partial<MessageCacheConfig> = {}) {
48+
this.config = { ...DEFAULT_CONFIG, ...config };
49+
}
50+
51+
/** Build the composite cache key. */
52+
private key(host: string, chatId: string): string {
53+
return `${host}::${chatId}`;
54+
}
55+
56+
/**
57+
* Retrieve a cached chat if it exists and is not stale.
58+
* Returns `null` on miss or staleness.
59+
*/
60+
get(host: string, chatId: string): CachedChat | null {
61+
const k = this.key(host, chatId);
62+
const entry = this.cache.get(k);
63+
if (!entry) return null;
64+
65+
// Staleness check
66+
if (Date.now() - entry.timestamp > this.config.staleTTL) {
67+
this.cache.delete(k);
68+
return null;
69+
}
70+
71+
// LRU touch: delete + re-insert to move to end (most recent)
72+
this.cache.delete(k);
73+
this.cache.set(k, entry);
74+
return entry;
75+
}
76+
77+
/** Check if a fresh (non-stale) cache entry exists. */
78+
has(host: string, chatId: string): boolean {
79+
return this.get(host, chatId) !== null;
80+
}
81+
82+
/**
83+
* Store a full chat in the cache (typically after a REST fetch).
84+
* Evicts the oldest entry if the cache is full.
85+
*/
86+
set(host: string, chatId: string, chat: RemoteChat): void {
87+
const k = this.key(host, chatId);
88+
89+
// Delete first for LRU ordering (re-insert at end)
90+
this.cache.delete(k);
91+
92+
// Evict oldest (first entry in Map iteration order) if at capacity
93+
if (this.cache.size >= this.config.maxEntries) {
94+
const oldest = this.cache.keys().next().value;
95+
if (oldest !== undefined) {
96+
this.cache.delete(oldest);
97+
}
98+
}
99+
100+
this.cache.set(k, {
101+
chat: { ...chat, messages: chat.messages ? [...chat.messages] : [] },
102+
timestamp: Date.now(),
103+
});
104+
}
105+
106+
/**
107+
* Append a stored message to a cached chat (incremental SSE update).
108+
* If the chat is not cached, this is a no-op.
109+
*/
110+
appendMessage(host: string, chatId: string, message: StoredMessage): void {
111+
const k = this.key(host, chatId);
112+
const entry = this.cache.get(k);
113+
if (!entry) return;
114+
115+
entry.chat.messages = [...(entry.chat.messages ?? []), message];
116+
entry.timestamp = Date.now();
117+
}
118+
119+
/**
120+
* Update the status of a cached chat (e.g. running → idle).
121+
* If the chat is not cached, this is a no-op.
122+
*/
123+
updateStatus(host: string, chatId: string, status: 'idle' | 'running'): void {
124+
const k = this.key(host, chatId);
125+
const entry = this.cache.get(k);
126+
if (!entry) return;
127+
128+
entry.chat.status = status;
129+
entry.timestamp = Date.now();
130+
}
131+
132+
/** Invalidate (remove) a single chat entry. */
133+
invalidate(host: string, chatId: string): void {
134+
this.cache.delete(this.key(host, chatId));
135+
}
136+
137+
/** Invalidate all entries for a specific server host. */
138+
invalidateAll(host: string): void {
139+
const prefix = `${host}::`;
140+
for (const key of [...this.cache.keys()]) {
141+
if (key.startsWith(prefix)) {
142+
this.cache.delete(key);
143+
}
144+
}
145+
}
146+
147+
/** Clear the entire cache. */
148+
clear(): void {
149+
this.cache.clear();
150+
}
151+
152+
/** Number of entries currently in the cache. */
153+
get size(): number {
154+
return this.cache.size;
155+
}
156+
}
157+
158+
/**
159+
* Module-level singleton cache instance.
160+
*
161+
* Imported by WebBridge and other consumers. Because this lives at
162+
* module scope, it survives bridge destruction and React unmount/remount
163+
* cycles — enabling instant restore on tab switches and reconnections.
164+
*/
165+
export const messageCache = new MessageCache();

src/bridge/outbound-handler.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,16 @@ export async function handleOutbound(
181181

182182
/**
183183
* Handle a user prompt: creates a new chat if no chatId is provided.
184-
* Ensures the chat messages are loaded before sending (handles the case
185-
* where a prompt targets a chat whose history hasn't been lazy-loaded yet).
184+
*
185+
* The server has full chat context — there's no need to re-fetch or
186+
* re-load messages before sending. Doing so would risk dispatching
187+
* `chat/cleared` on the visible chat (clearing all messages) if the
188+
* chat wasn't in `loadedChatIds` (e.g. after a reconnect re-sync).
186189
*/
187190
async function handleUserPrompt(data: UserPromptData, ctx: OutboundContext): Promise<void> {
188191
const chatId = data.chatId || crypto.randomUUID();
189192
ctx.setCurrentChatId(chatId);
190193

191-
// Ensure messages are loaded so the webview has full context
192-
await ctx.loadChatMessages(chatId);
193-
194194
await ctx.api.sendPrompt(chatId, {
195195
message: data.prompt,
196196
model: data.model,

0 commit comments

Comments
 (0)