blob: c94fde415b95b1cfe7e4d16730b78602e4eae38a [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00002import { AgentMessage, State } from "./types";
Earl Lee2e463fb2025-04-17 11:22:22 -07003
4/**
5 * Event types for data manager
6 */
banksean54777362025-06-19 16:38:30 +00007export type DataManagerEventType =
8 | "dataChanged"
9 | "connectionStatusChanged"
10 | "initialLoadComplete";
Earl Lee2e463fb2025-04-17 11:22:22 -070011
12/**
13 * Connection status types
14 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000015export type ConnectionStatus =
16 | "connected"
17 | "connecting"
18 | "disconnected"
19 | "disabled";
Earl Lee2e463fb2025-04-17 11:22:22 -070020
21/**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000022 * DataManager - Class to manage timeline data, fetching, and SSE streaming
Earl Lee2e463fb2025-04-17 11:22:22 -070023 */
24export class DataManager {
25 // State variables
Sean McCulloughd9f13372025-04-21 15:08:49 -070026 private messages: AgentMessage[] = [];
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000027 private timelineState: State | null = null;
28 private isFirstLoad: boolean = true;
29 private lastHeartbeatTime: number = 0;
30 private connectionStatus: ConnectionStatus = "disconnected";
31 private eventSource: EventSource | null = null;
32 private reconnectTimer: number | null = null;
33 private reconnectAttempt: number = 0;
34 private maxReconnectDelayMs: number = 60000; // Max delay of 60 seconds
35 private baseReconnectDelayMs: number = 1000; // Start with 1 second
Sean McCullough71941bd2025-04-18 13:31:48 -070036
banksean54777362025-06-19 16:38:30 +000037 // Initial load completion tracking
38 private expectedMessageCount: number | null = null;
39 private isInitialLoadComplete: boolean = false;
40
Earl Lee2e463fb2025-04-17 11:22:22 -070041 // Event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -070042 private eventListeners: Map<
43 DataManagerEventType,
44 Array<(...args: any[]) => void>
45 > = new Map();
Earl Lee2e463fb2025-04-17 11:22:22 -070046
47 constructor() {
48 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070049 this.eventListeners.set("dataChanged", []);
50 this.eventListeners.set("connectionStatusChanged", []);
banksean54777362025-06-19 16:38:30 +000051 this.eventListeners.set("initialLoadComplete", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000052
53 // Check connection status periodically
54 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070055 }
56
57 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000058 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070059 */
60 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000061 // Connect to the SSE stream
62 this.connect();
63 }
64
65 /**
66 * Connect to the SSE stream
67 */
68 private connect(): void {
69 // If we're already connecting or connected, don't start another connection attempt
70 if (
71 this.eventSource &&
72 (this.connectionStatus === "connecting" ||
73 this.connectionStatus === "connected")
74 ) {
75 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070076 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000077
78 // Close any existing connection
79 this.closeEventSource();
80
banksean54777362025-06-19 16:38:30 +000081 // Reset initial load state for new connection
82 this.expectedMessageCount = null;
83 this.isInitialLoadComplete = false;
84
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000085 // Update connection status to connecting
86 this.updateConnectionStatus("connecting", "Connecting...");
87
88 // Determine the starting point for the stream based on what we already have
89 const fromIndex =
90 this.messages.length > 0
91 ? this.messages[this.messages.length - 1].idx + 1
92 : 0;
93
94 // Create a new EventSource connection
95 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
96
97 // Set up event handlers
98 this.eventSource.addEventListener("open", () => {
99 console.log("SSE stream opened");
100 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
101 this.updateConnectionStatus("connected");
102 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
103 });
104
105 this.eventSource.addEventListener("error", (event) => {
106 console.error("SSE stream error:", event);
107 this.closeEventSource();
108 this.updateConnectionStatus("disconnected", "Connection lost");
109 this.scheduleReconnect();
110 });
111
112 // Handle incoming messages
113 this.eventSource.addEventListener("message", (event) => {
114 const message = JSON.parse(event.data) as AgentMessage;
115 this.processNewMessage(message);
116 });
117
118 // Handle state updates
119 this.eventSource.addEventListener("state", (event) => {
120 const state = JSON.parse(event.data) as State;
121 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000122
123 // Store expected message count for initial load detection
124 if (this.expectedMessageCount === null) {
125 this.expectedMessageCount = state.message_count;
126 console.log(
127 `Initial load expects ${this.expectedMessageCount} messages`,
128 );
129
130 // Handle empty conversation case - immediately mark as complete
131 if (this.expectedMessageCount === 0) {
132 this.isInitialLoadComplete = true;
133 console.log(`Initial load complete: Empty conversation (0 messages)`);
134 this.emitEvent("initialLoadComplete", {
135 messageCount: 0,
136 expectedCount: 0,
137 });
138 }
139 }
140
141 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000142 this.emitEvent("dataChanged", { state, newMessages: [] });
143 });
144
145 // Handle heartbeats
146 this.eventSource.addEventListener("heartbeat", () => {
147 this.lastHeartbeatTime = Date.now();
148 // Make sure connection status is updated if it wasn't already
149 if (this.connectionStatus !== "connected") {
150 this.updateConnectionStatus("connected");
151 }
152 });
153 }
154
155 /**
156 * Close the current EventSource connection
157 */
158 private closeEventSource(): void {
159 if (this.eventSource) {
160 this.eventSource.close();
161 this.eventSource = null;
162 }
163 }
164
165 /**
166 * Schedule a reconnection attempt with exponential backoff
167 */
168 private scheduleReconnect(): void {
169 if (this.reconnectTimer !== null) {
170 window.clearTimeout(this.reconnectTimer);
171 this.reconnectTimer = null;
172 }
173
174 // Calculate backoff delay with exponential increase and maximum limit
175 const delay = Math.min(
176 this.baseReconnectDelayMs * Math.pow(1.5, this.reconnectAttempt),
177 this.maxReconnectDelayMs,
178 );
179
180 console.log(
181 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
182 );
183
184 // Increment reconnect attempt counter
185 this.reconnectAttempt++;
186
187 // Schedule the reconnect
188 this.reconnectTimer = window.setTimeout(() => {
189 this.reconnectTimer = null;
190 this.connect();
191 }, delay);
192 }
193
194 /**
195 * Check heartbeat status to determine if connection is still active
196 */
197 private checkConnectionStatus(): void {
198 if (this.connectionStatus !== "connected") {
199 return; // Only check if we think we're connected
200 }
201
202 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
203 if (timeSinceLastHeartbeat > 90000) {
204 // 90 seconds without heartbeat
205 console.warn(
206 "No heartbeat received in 90 seconds, connection appears to be lost",
207 );
208 this.closeEventSource();
209 this.updateConnectionStatus(
210 "disconnected",
211 "Connection timed out (no heartbeat)",
212 );
213 this.scheduleReconnect();
214 }
215 }
216
217 /**
banksean54777362025-06-19 16:38:30 +0000218 * Check if initial load is complete based on expected message count
219 */
220 private checkInitialLoadComplete(): void {
221 if (
222 this.expectedMessageCount !== null &&
223 this.expectedMessageCount > 0 &&
224 this.messages.length >= this.expectedMessageCount &&
225 !this.isInitialLoadComplete
226 ) {
227 this.isInitialLoadComplete = true;
228 console.log(
229 `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
230 );
231
232 this.emitEvent("initialLoadComplete", {
233 messageCount: this.messages.length,
234 expectedCount: this.expectedMessageCount,
235 });
236 }
237 }
238
239 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000240 * Process a new message from the SSE stream
241 */
242 private processNewMessage(message: AgentMessage): void {
243 // Find the message's position in the array
244 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
245
246 if (existingIndex >= 0) {
247 // This shouldn't happen - we should never receive duplicates
248 console.error(
249 `Received duplicate message with idx ${message.idx}`,
250 message,
251 );
252 return;
253 } else {
254 // Add the new message to our array
255 this.messages.push(message);
256 // Sort messages by idx to ensure they're in the correct order
257 this.messages.sort((a, b) => a.idx - b.idx);
258 }
259
260 // Mark that we've completed first load
261 if (this.isFirstLoad) {
262 this.isFirstLoad = false;
263 }
264
banksean54777362025-06-19 16:38:30 +0000265 // Check if initial load is now complete
266 this.checkInitialLoadComplete();
267
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000268 // Emit an event that data has changed
269 this.emitEvent("dataChanged", {
270 state: this.timelineState,
271 newMessages: [message],
banksean54777362025-06-19 16:38:30 +0000272 isFirstFetch: this.isInitialLoadComplete,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000273 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 }
275
276 /**
277 * Get all messages
278 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700279 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 return this.messages;
281 }
282
283 /**
284 * Get the current state
285 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000286 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 return this.timelineState;
288 }
289
290 /**
291 * Get the connection status
292 */
293 public getConnectionStatus(): ConnectionStatus {
294 return this.connectionStatus;
295 }
296
297 /**
298 * Get the isFirstLoad flag
299 */
300 public getIsFirstLoad(): boolean {
301 return this.isFirstLoad;
302 }
303
304 /**
banksean54777362025-06-19 16:38:30 +0000305 * Get the initial load completion status
306 */
307 public getIsInitialLoadComplete(): boolean {
308 return this.isInitialLoadComplete;
309 }
310
311 /**
312 * Get the expected message count for initial load
313 */
314 public getExpectedMessageCount(): number | null {
315 return this.expectedMessageCount;
316 }
317
318 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 * Add an event listener
320 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700321 public addEventListener(
322 event: DataManagerEventType,
323 callback: (...args: any[]) => void,
324 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700325 const listeners = this.eventListeners.get(event) || [];
326 listeners.push(callback);
327 this.eventListeners.set(event, listeners);
328 }
329
330 /**
331 * Remove an event listener
332 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700333 public removeEventListener(
334 event: DataManagerEventType,
335 callback: (...args: any[]) => void,
336 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700337 const listeners = this.eventListeners.get(event) || [];
338 const index = listeners.indexOf(callback);
339 if (index !== -1) {
340 listeners.splice(index, 1);
341 this.eventListeners.set(event, listeners);
342 }
343 }
344
345 /**
346 * Emit an event
347 */
348 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
349 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700350 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700351 }
352
353 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700354 * Update the connection status
355 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000356 private updateConnectionStatus(
357 status: ConnectionStatus,
358 message?: string,
359 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700360 if (this.connectionStatus !== status) {
361 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000362 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700363 }
364 }
365
366 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000367 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700368 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000369 public async send(message: string): Promise<boolean> {
370 // Attempt to connect if we're not already connected
371 if (
372 this.connectionStatus !== "connected" &&
373 this.connectionStatus !== "connecting"
374 ) {
375 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700376 }
377
Earl Lee2e463fb2025-04-17 11:22:22 -0700378 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000379 const response = await fetch("chat", {
380 method: "POST",
381 headers: {
382 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700383 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000384 body: JSON.stringify({ message }),
385 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700386
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000387 if (!response.ok) {
388 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700389 }
390
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000391 return true;
392 } catch (error) {
393 console.error("Error sending message:", error);
394 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700395 }
396 }
397
398 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000399 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700400 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000401 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700402 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000403 const response = await fetch("cancel", {
404 method: "POST",
405 headers: {
406 "Content-Type": "application/json",
407 },
408 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700409 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000410
411 if (!response.ok) {
412 throw new Error(`HTTP error! Status: ${response.status}`);
413 }
414
415 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700416 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000417 console.error("Error cancelling conversation:", error);
418 return false;
419 }
420 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700421
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000422 /**
423 * Cancel a specific tool call
424 */
425 public async cancelToolUse(toolCallId: string): Promise<boolean> {
426 try {
427 const response = await fetch("cancel", {
428 method: "POST",
429 headers: {
430 "Content-Type": "application/json",
431 },
432 body: JSON.stringify({
433 reason: "User cancelled tool use",
434 tool_call_id: toolCallId,
435 }),
436 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700437
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000438 if (!response.ok) {
439 throw new Error(`HTTP error! Status: ${response.status}`);
440 }
441
442 return true;
443 } catch (error) {
444 console.error("Error cancelling tool use:", error);
445 return false;
446 }
447 }
448
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000449 /**
450 * Download the conversation data
451 */
452 public downloadConversation(): void {
453 window.location.href = "download";
454 }
455
456 /**
457 * Get a suggested reprompt
458 */
459 public async getSuggestedReprompt(): Promise<string | null> {
460 try {
461 const response = await fetch("suggest-reprompt");
462 if (!response.ok) {
463 throw new Error(`HTTP error! Status: ${response.status}`);
464 }
465 const data = await response.json();
466 return data.prompt;
467 } catch (error) {
468 console.error("Error getting suggested reprompt:", error);
469 return null;
470 }
471 }
472
473 /**
474 * Get description for a commit
475 */
476 public async getCommitDescription(revision: string): Promise<string | null> {
477 try {
478 const response = await fetch(
479 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700480 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000481 if (!response.ok) {
482 throw new Error(`HTTP error! Status: ${response.status}`);
483 }
484 const data = await response.json();
485 return data.description;
486 } catch (error) {
487 console.error("Error getting commit description:", error);
488 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700489 }
490 }
491}