blob: 9eea954edff7102b5a9b5c2be7d4dc6b344dd483 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { TimelineMessage } from "./types";
2import { formatNumber } from "./utils";
3
4/**
5 * Event types for data manager
6 */
Sean McCullough71941bd2025-04-18 13:31:48 -07007export type DataManagerEventType = "dataChanged" | "connectionStatusChanged";
Earl Lee2e463fb2025-04-17 11:22:22 -07008
9/**
10 * Connection status types
11 */
Sean McCullough71941bd2025-04-18 13:31:48 -070012export type ConnectionStatus = "connected" | "disconnected" | "disabled";
Earl Lee2e463fb2025-04-17 11:22:22 -070013
14/**
15 * State interface
16 */
17export interface TimelineState {
18 hostname?: string;
19 working_dir?: string;
20 initial_commit?: string;
21 message_count?: number;
22 title?: string;
23 total_usage?: {
24 input_tokens: number;
25 output_tokens: number;
26 cache_read_input_tokens: number;
27 cache_creation_input_tokens: number;
28 total_cost_usd: number;
29 };
30}
31
32/**
33 * DataManager - Class to manage timeline data, fetching, and polling
34 */
35export class DataManager {
36 // State variables
37 private lastMessageCount: number = 0;
38 private nextFetchIndex: number = 0;
39 private currentFetchStartIndex: number = 0;
40 private currentPollController: AbortController | null = null;
41 private isFetchingMessages: boolean = false;
42 private isPollingEnabled: boolean = true;
43 private isFirstLoad: boolean = true;
44 private connectionStatus: ConnectionStatus = "disabled";
45 private messages: TimelineMessage[] = [];
46 private timelineState: TimelineState | null = null;
Sean McCullough71941bd2025-04-18 13:31:48 -070047
Earl Lee2e463fb2025-04-17 11:22:22 -070048 // Event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -070049 private eventListeners: Map<
50 DataManagerEventType,
51 Array<(...args: any[]) => void>
52 > = new Map();
Earl Lee2e463fb2025-04-17 11:22:22 -070053
54 constructor() {
55 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070056 this.eventListeners.set("dataChanged", []);
57 this.eventListeners.set("connectionStatusChanged", []);
Earl Lee2e463fb2025-04-17 11:22:22 -070058 }
59
60 /**
61 * Initialize the data manager and fetch initial data
62 */
63 public async initialize(): Promise<void> {
64 try {
65 // Initial data fetch
66 await this.fetchData();
67 // Start polling for updates only if initial fetch succeeds
68 this.startPolling();
69 } catch (error) {
70 console.error("Initial data fetch failed, will retry via polling", error);
71 // Still start polling to recover
72 this.startPolling();
73 }
74 }
75
76 /**
77 * Get all messages
78 */
79 public getMessages(): TimelineMessage[] {
80 return this.messages;
81 }
82
83 /**
84 * Get the current state
85 */
86 public getState(): TimelineState | null {
87 return this.timelineState;
88 }
89
90 /**
91 * Get the connection status
92 */
93 public getConnectionStatus(): ConnectionStatus {
94 return this.connectionStatus;
95 }
96
97 /**
98 * Get the isFirstLoad flag
99 */
100 public getIsFirstLoad(): boolean {
101 return this.isFirstLoad;
102 }
103
104 /**
105 * Get the currentFetchStartIndex
106 */
107 public getCurrentFetchStartIndex(): number {
108 return this.currentFetchStartIndex;
109 }
110
111 /**
112 * Add an event listener
113 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700114 public addEventListener(
115 event: DataManagerEventType,
116 callback: (...args: any[]) => void,
117 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 const listeners = this.eventListeners.get(event) || [];
119 listeners.push(callback);
120 this.eventListeners.set(event, listeners);
121 }
122
123 /**
124 * Remove an event listener
125 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700126 public removeEventListener(
127 event: DataManagerEventType,
128 callback: (...args: any[]) => void,
129 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700130 const listeners = this.eventListeners.get(event) || [];
131 const index = listeners.indexOf(callback);
132 if (index !== -1) {
133 listeners.splice(index, 1);
134 this.eventListeners.set(event, listeners);
135 }
136 }
137
138 /**
139 * Emit an event
140 */
141 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
142 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700143 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700144 }
145
146 /**
147 * Set polling enabled/disabled state
148 */
149 public setPollingEnabled(enabled: boolean): void {
150 this.isPollingEnabled = enabled;
Sean McCullough71941bd2025-04-18 13:31:48 -0700151
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 if (enabled) {
153 this.startPolling();
154 } else {
155 this.stopPolling();
156 }
157 }
158
159 /**
160 * Start polling for updates
161 */
162 public startPolling(): void {
163 this.stopPolling(); // Stop any existing polling
Sean McCullough71941bd2025-04-18 13:31:48 -0700164
Earl Lee2e463fb2025-04-17 11:22:22 -0700165 // Start long polling
166 this.longPoll();
167 }
168
169 /**
170 * Stop polling for updates
171 */
172 public stopPolling(): void {
173 // Abort any ongoing long poll request
174 if (this.currentPollController) {
175 this.currentPollController.abort();
176 this.currentPollController = null;
177 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700178
Earl Lee2e463fb2025-04-17 11:22:22 -0700179 // If polling is disabled by user, set connection status to disabled
180 if (!this.isPollingEnabled) {
181 this.updateConnectionStatus("disabled");
182 }
183 }
184
185 /**
186 * Update the connection status
187 */
188 private updateConnectionStatus(status: ConnectionStatus): void {
189 if (this.connectionStatus !== status) {
190 this.connectionStatus = status;
Sean McCullough71941bd2025-04-18 13:31:48 -0700191 this.emitEvent("connectionStatusChanged", status);
Earl Lee2e463fb2025-04-17 11:22:22 -0700192 }
193 }
194
195 /**
196 * Long poll for updates
197 */
198 private async longPoll(): Promise<void> {
199 // Abort any existing poll request
200 if (this.currentPollController) {
201 this.currentPollController.abort();
202 this.currentPollController = null;
203 }
204
205 // If polling is disabled, don't start a new poll
206 if (!this.isPollingEnabled) {
207 return;
208 }
209
210 let timeoutId: number | undefined;
211
212 try {
213 // Create a new abort controller for this request
214 this.currentPollController = new AbortController();
215 const signal = this.currentPollController.signal;
216
217 // Get the URL with the current message count
218 const pollUrl = `state?poll=true&seen=${this.lastMessageCount}`;
219
220 // Make the long poll request
221 // Use explicit timeout to handle stalled connections (120s)
222 const controller = new AbortController();
223 timeoutId = window.setTimeout(() => controller.abort(), 120000);
224
225 interface CustomFetchOptions extends RequestInit {
226 [Symbol.toStringTag]?: unknown;
227 }
228
229 const fetchOptions: CustomFetchOptions = {
230 signal: controller.signal,
231 // Use the original signal to allow manual cancellation too
232 get [Symbol.toStringTag]() {
233 if (signal.aborted) controller.abort();
234 return "";
235 },
236 };
237
238 try {
239 const response = await fetch(pollUrl, fetchOptions);
240 // Clear the timeout since we got a response
241 clearTimeout(timeoutId);
242
243 // Parse the JSON response
244 const _data = await response.json();
245
246 // If we got here, data has changed, so fetch the latest data
247 await this.fetchData();
248
249 // Start a new long poll (if polling is still enabled)
250 if (this.isPollingEnabled) {
251 this.longPoll();
252 }
253 } catch (error) {
254 // Handle fetch errors inside the inner try block
255 clearTimeout(timeoutId);
256 throw error; // Re-throw to be caught by the outer catch block
257 }
258 } catch (error: unknown) {
259 // Clean up timeout if we're handling an error
260 if (timeoutId) clearTimeout(timeoutId);
261
262 // Don't log or treat manual cancellations as errors
263 const isErrorWithName = (
264 err: unknown,
265 ): err is { name: string; message?: string } =>
266 typeof err === "object" && err !== null && "name" in err;
267
268 if (
269 isErrorWithName(error) &&
270 error.name === "AbortError" &&
271 this.currentPollController?.signal.aborted
272 ) {
273 console.log("Polling cancelled by user");
274 return;
275 }
276
277 // Handle different types of errors with specific messages
278 let errorMessage = "Not connected";
279
280 if (isErrorWithName(error)) {
281 if (error.name === "AbortError") {
282 // This was our timeout abort
283 errorMessage = "Connection timeout - not connected";
284 console.error("Long polling timeout");
285 } else if (error.name === "SyntaxError") {
286 // JSON parsing error
287 errorMessage = "Invalid response from server - not connected";
288 console.error("JSON parsing error:", error);
289 } else if (
290 error.name === "TypeError" &&
291 error.message?.includes("NetworkError")
292 ) {
293 // Network connectivity issues
294 errorMessage = "Network connection lost - not connected";
295 console.error("Network error during polling:", error);
296 } else {
297 // Generic error
298 console.error("Long polling error:", error);
299 }
300 }
301
302 // Disable polling on error
303 this.isPollingEnabled = false;
304
305 // Update connection status to disconnected
306 this.updateConnectionStatus("disconnected");
307
308 // Emit an event that we're disconnected with the error message
Sean McCullough71941bd2025-04-18 13:31:48 -0700309 this.emitEvent(
310 "connectionStatusChanged",
311 this.connectionStatus,
312 errorMessage,
313 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700314 }
315 }
316
317 /**
318 * Fetch timeline data
319 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700320 public async fetchData(): Promise<void> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700321 // If we're already fetching messages, don't start another fetch
322 if (this.isFetchingMessages) {
323 console.log("Already fetching messages, skipping request");
324 return;
325 }
326
327 this.isFetchingMessages = true;
328
329 try {
330 // Fetch state first
331 const stateResponse = await fetch("state");
332 const state = await stateResponse.json();
333 this.timelineState = state;
334
335 // Check if new messages are available
336 if (
337 state.message_count === this.lastMessageCount &&
338 this.lastMessageCount > 0
339 ) {
340 // No new messages, early return
341 this.isFetchingMessages = false;
Sean McCullough71941bd2025-04-18 13:31:48 -0700342 this.emitEvent("dataChanged", { state, newMessages: [] });
Earl Lee2e463fb2025-04-17 11:22:22 -0700343 return;
344 }
345
346 // Fetch messages with a start parameter
347 this.currentFetchStartIndex = this.nextFetchIndex;
348 const messagesResponse = await fetch(
349 `messages?start=${this.nextFetchIndex}`,
350 );
Sean McCullough71941bd2025-04-18 13:31:48 -0700351 const newMessages = (await messagesResponse.json()) || [];
Earl Lee2e463fb2025-04-17 11:22:22 -0700352
353 // Store messages in our array
354 if (this.nextFetchIndex === 0) {
355 // If this is the first fetch, replace the entire array
356 this.messages = [...newMessages];
357 } else {
358 // Otherwise append the new messages
359 this.messages = [...this.messages, ...newMessages];
360 }
361
362 // Update connection status to connected
363 this.updateConnectionStatus("connected");
364
365 // Update the last message index for next fetch
366 if (newMessages && newMessages.length > 0) {
367 this.nextFetchIndex += newMessages.length;
368 }
369
370 // Update the message count
371 this.lastMessageCount = state?.message_count ?? 0;
372
373 // Mark that we've completed first load
374 if (this.isFirstLoad) {
375 this.isFirstLoad = false;
376 }
377
378 // Emit an event that data has changed
Sean McCullough71941bd2025-04-18 13:31:48 -0700379 this.emitEvent("dataChanged", {
380 state,
381 newMessages,
382 isFirstFetch: this.nextFetchIndex === newMessages.length,
383 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700384 } catch (error) {
385 console.error("Error fetching data:", error);
386
387 // Update connection status to disconnected
388 this.updateConnectionStatus("disconnected");
389
390 // Emit an event that we're disconnected
Sean McCullough71941bd2025-04-18 13:31:48 -0700391 this.emitEvent(
392 "connectionStatusChanged",
393 this.connectionStatus,
394 "Not connected",
395 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700396 } finally {
397 this.isFetchingMessages = false;
398 }
399 }
400}