blob: af696dccb38cc48d593e329f05b15b92f18d02a6 [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;
Josh Bleecher Snyder0f004272025-07-21 22:40:03 +000034 // Reconnection timeout delays in milliseconds (runs from 1s to 60s)
35 private readonly reconnectDelaysMs: number[] = [
36 1000, 1500, 2250, 3375, 5062.5, 7593.75, 11390.625, 17085.9375,
37 25628.90625, 38443.359375, 57665.0390625, 60000, 60000
38 ];
Sean McCullough71941bd2025-04-18 13:31:48 -070039
banksean54777362025-06-19 16:38:30 +000040 // Initial load completion tracking
41 private expectedMessageCount: number | null = null;
42 private isInitialLoadComplete: boolean = false;
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044 // Event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -070045 private eventListeners: Map<
46 DataManagerEventType,
47 Array<(...args: any[]) => void>
48 > = new Map();
Earl Lee2e463fb2025-04-17 11:22:22 -070049
50 constructor() {
51 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070052 this.eventListeners.set("dataChanged", []);
53 this.eventListeners.set("connectionStatusChanged", []);
banksean54777362025-06-19 16:38:30 +000054 this.eventListeners.set("initialLoadComplete", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000055
56 // Check connection status periodically
57 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070058 }
59
60 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000061 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070062 */
63 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000064 // Connect to the SSE stream
65 this.connect();
66 }
67
68 /**
69 * Connect to the SSE stream
70 */
71 private connect(): void {
72 // If we're already connecting or connected, don't start another connection attempt
73 if (
74 this.eventSource &&
75 (this.connectionStatus === "connecting" ||
76 this.connectionStatus === "connected")
77 ) {
78 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070079 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000080
81 // Close any existing connection
82 this.closeEventSource();
83
banksean54777362025-06-19 16:38:30 +000084 // Reset initial load state for new connection
85 this.expectedMessageCount = null;
86 this.isInitialLoadComplete = false;
87
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000088 // Update connection status to connecting
89 this.updateConnectionStatus("connecting", "Connecting...");
90
91 // Determine the starting point for the stream based on what we already have
92 const fromIndex =
93 this.messages.length > 0
94 ? this.messages[this.messages.length - 1].idx + 1
95 : 0;
96
97 // Create a new EventSource connection
98 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
99
100 // Set up event handlers
101 this.eventSource.addEventListener("open", () => {
102 console.log("SSE stream opened");
103 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
104 this.updateConnectionStatus("connected");
105 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
106 });
107
108 this.eventSource.addEventListener("error", (event) => {
109 console.error("SSE stream error:", event);
110 this.closeEventSource();
111 this.updateConnectionStatus("disconnected", "Connection lost");
112 this.scheduleReconnect();
113 });
114
115 // Handle incoming messages
116 this.eventSource.addEventListener("message", (event) => {
117 const message = JSON.parse(event.data) as AgentMessage;
118 this.processNewMessage(message);
119 });
120
121 // Handle state updates
122 this.eventSource.addEventListener("state", (event) => {
123 const state = JSON.parse(event.data) as State;
124 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000125
126 // Store expected message count for initial load detection
127 if (this.expectedMessageCount === null) {
128 this.expectedMessageCount = state.message_count;
129 console.log(
130 `Initial load expects ${this.expectedMessageCount} messages`,
131 );
132
133 // Handle empty conversation case - immediately mark as complete
134 if (this.expectedMessageCount === 0) {
135 this.isInitialLoadComplete = true;
136 console.log(`Initial load complete: Empty conversation (0 messages)`);
137 this.emitEvent("initialLoadComplete", {
138 messageCount: 0,
139 expectedCount: 0,
140 });
141 }
142 }
143
144 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000145 this.emitEvent("dataChanged", { state, newMessages: [] });
146 });
147
148 // Handle heartbeats
149 this.eventSource.addEventListener("heartbeat", () => {
150 this.lastHeartbeatTime = Date.now();
151 // Make sure connection status is updated if it wasn't already
152 if (this.connectionStatus !== "connected") {
153 this.updateConnectionStatus("connected");
154 }
155 });
156 }
157
158 /**
159 * Close the current EventSource connection
160 */
161 private closeEventSource(): void {
162 if (this.eventSource) {
163 this.eventSource.close();
164 this.eventSource = null;
165 }
166 }
167
168 /**
169 * Schedule a reconnection attempt with exponential backoff
170 */
171 private scheduleReconnect(): void {
172 if (this.reconnectTimer !== null) {
173 window.clearTimeout(this.reconnectTimer);
174 this.reconnectTimer = null;
175 }
176
Josh Bleecher Snyder0f004272025-07-21 22:40:03 +0000177 const delayIndex = Math.min(this.reconnectAttempt, this.reconnectDelaysMs.length - 1);
178 const delay = this.reconnectDelaysMs[delayIndex];
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000179
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}