Skip to content

Commit fa0905e

Browse files
author
IM.codes
committed
Warm timeline cache from realtime events
1 parent d96b048 commit fa0905e

File tree

3 files changed

+81
-7
lines changed

3 files changed

+81
-7
lines changed

web/src/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import { shouldSubscribeTerminalRaw, type TerminalSubscribeViewMode } from './te
112112
import { onWatchCommand } from './watch-bridge.js';
113113
import { watchProjectionStore } from './watch-projection.js';
114114
import { isIdleSessionStateTimelineEvent, isRunningTimelineEvent } from './timeline-running.js';
115+
import { ingestTimelineEventForCache } from './hooks/useTimeline.js';
115116

116117
// On web: if opened by the native app for passkey auth, render the bridge page.
117118
const nativeCallback = typeof window !== 'undefined'
@@ -1173,6 +1174,7 @@ export function App() {
11731174
// Detect model from JSONL usage.update events (authoritative, overrides terminal scan)
11741175
if (msg.type === 'timeline.event') {
11751176
const event = msg.event;
1177+
ingestTimelineEventForCache(event, selectedServerId);
11761178
watchProjectionStore.handleTimelineEvent(event);
11771179
if (isRunningTimelineEvent(event) && !event.sessionId.startsWith('deck_sub_')) {
11781180
setSessions((prev) => prev.map((s) =>

web/src/hooks/useTimeline.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ function pruneTimelineCache(): void {
9494
}
9595
}
9696

97+
function scopeCacheKey(serverId: string | null | undefined, sessionId: string): string {
98+
return serverId ? `${serverId}:${sessionId}` : sessionId;
99+
}
100+
101+
function scopeEventsForDb(cacheKey: string, events: TimelineEvent[]): TimelineEvent[] {
102+
if (cacheKey === events[0]?.sessionId) return events;
103+
return events.map((event) => ({ ...event, sessionId: cacheKey }));
104+
}
105+
106+
function persistTimelineEvents(cacheKey: string, events: TimelineEvent[]): void {
107+
if (events.length === 0) return;
108+
sharedDb.putEvents(scopeEventsForDb(cacheKey, events)).catch(() => {});
109+
}
110+
97111
export function __resetTimelineCacheForTests(): void {
98112
eventsCache.clear();
99113
eventsCacheAccess.clear();
@@ -108,6 +122,14 @@ export function __setTimelineCacheForTests(cacheKey: string, events: TimelineEve
108122
setCachedEvents(cacheKey, events);
109123
}
110124

125+
export function ingestTimelineEventForCache(event: TimelineEvent, serverId?: string | null): void {
126+
const cacheKey = scopeCacheKey(serverId, event.sessionId);
127+
const existing = getCachedEvents(cacheKey) ?? [];
128+
const merged = mergeTimelineEvents(existing, [event], MAX_MEMORY_EVENTS);
129+
if (merged !== existing) setCachedEvents(cacheKey, merged);
130+
persistTimelineEvents(cacheKey, [event]);
131+
}
132+
111133
export interface UseTimelineResult {
112134
events: TimelineEvent[];
113135
loading: boolean;
@@ -130,7 +152,7 @@ export function useTimeline(
130152
): UseTimelineResult {
131153
// IDB + memory cache key: scope by serverId to prevent cross-server pollution
132154
// when different servers share the same session name (e.g. deck_cd_brain).
133-
const cacheKey = serverId && sessionId ? `${serverId}:${sessionId}` : sessionId;
155+
const cacheKey = sessionId ? scopeCacheKey(serverId, sessionId) : sessionId;
134156
const cacheKeyRef = useRef(cacheKey);
135157
cacheKeyRef.current = cacheKey;
136158
const [events, setEvents] = useState<TimelineEvent[]>([]);
@@ -320,11 +342,8 @@ export function useTimeline(
320342
// IDB helper: scope events by cacheKey so cross-server sessions don't collide
321343
const idbPutEvents = useCallback((evts: TimelineEvent[]) => {
322344
const key = cacheKeyRef.current;
323-
if (!key || key === evts[0]?.sessionId) {
324-
sharedDb?.putEvents(evts).catch(() => {});
325-
} else {
326-
sharedDb?.putEvents(evts.map(e => ({ ...e, sessionId: key }))).catch(() => {});
327-
}
345+
if (!key) return;
346+
persistTimelineEvents(key, evts);
328347
}, []);
329348

330349
// Listen for WS messages

web/test/use-timeline-cache.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { beforeEach, describe, expect, it } from 'vitest';
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
55
import { render, screen, cleanup, act, waitFor } from '@testing-library/preact';
66
import { h } from 'preact';
77
import type { ServerMessage, TimelineEvent, WsClient } from '../src/ws-client.js';
88
import {
99
__getTimelineCacheKeysForTests,
1010
__resetTimelineCacheForTests,
1111
__setTimelineCacheForTests,
12+
ingestTimelineEventForCache,
1213
useTimeline,
1314
} from '../src/hooks/useTimeline.js';
1415

@@ -138,6 +139,58 @@ describe('useTimeline global cache bounds', () => {
138139
});
139140
});
140141

142+
it('renders immediately from globally ingested timeline events before the first history request returns', async () => {
143+
const sessionName = `deck_sub_codex_sdk_${Date.now()}`;
144+
const serverId = `srv-${Date.now()}`;
145+
let handler: ((msg: ServerMessage) => void) | null = null;
146+
const sendTimelineHistoryRequest = vi.fn(() => 'history-live');
147+
148+
ingestTimelineEventForCache({
149+
eventId: `${sessionName}-live-1`,
150+
sessionId: sessionName,
151+
ts: 1,
152+
epoch: 7,
153+
seq: 1,
154+
source: 'daemon',
155+
confidence: 'high',
156+
type: 'assistant.text',
157+
payload: { text: 'live cached text' },
158+
}, serverId);
159+
160+
const ws: WsClient = {
161+
connected: true,
162+
onMessage: (next: (msg: ServerMessage) => void) => {
163+
handler = next;
164+
return () => { handler = null; };
165+
},
166+
sendTimelineHistoryRequest,
167+
} as unknown as WsClient;
168+
169+
function Probe() {
170+
const { events } = useTimeline(sessionName, ws, serverId);
171+
return h('div', { 'data-testid': 'probe' }, events[0]?.payload.text as string ?? '');
172+
}
173+
174+
render(h(Probe, {}));
175+
176+
await waitFor(() => {
177+
expect(screen.getByTestId('probe').textContent).toBe('live cached text');
178+
});
179+
expect(sendTimelineHistoryRequest).toHaveBeenCalledWith(sessionName, 300);
180+
181+
await act(async () => {
182+
handler?.({
183+
type: 'timeline.history',
184+
sessionName,
185+
requestId: 'history-live',
186+
epoch: 7,
187+
events: [],
188+
} as ServerMessage);
189+
});
190+
191+
expect(screen.getByTestId('probe').textContent).toBe('live cached text');
192+
});
193+
141194
it('keeps timeline history isolated across servers for the same session name', async () => {
142195
const sessionName = `deck_shared_${Date.now()}`;
143196
let handlerA: ((msg: ServerMessage) => void) | null = null;

0 commit comments

Comments
 (0)