blob: 327ff89fd119f99da0c0a6328753e562fae4770a [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 Snyder15a0ffa2025-07-21 15:53:48 -070034 // Reconnection timeout delays in milliseconds (runs from 100ms to ~15s). Fibonacci-ish.
Josh Bleecher Snyder0f004272025-07-21 22:40:03 +000035 private readonly reconnectDelaysMs: number[] = [
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -070036 100, 100, 200, 300, 500, 800, 1300, 2100, 3400, 5500, 8900, 14400,
Josh Bleecher Snyder0f004272025-07-21 22:40:03 +000037 ];
Sean McCullough71941bd2025-04-18 13:31:48 -070038
banksean54777362025-06-19 16:38:30 +000039 // Initial load completion tracking
40 private expectedMessageCount: number | null = null;
41 private isInitialLoadComplete: boolean = false;
42
Earl Lee2e463fb2025-04-17 11:22:22 -070043 // Event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -070044 private eventListeners: Map<
45 DataManagerEventType,
46 Array<(...args: any[]) => void>
47 > = new Map();
Earl Lee2e463fb2025-04-17 11:22:22 -070048
49 constructor() {
50 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070051 this.eventListeners.set("dataChanged", []);
52 this.eventListeners.set("connectionStatusChanged", []);
banksean54777362025-06-19 16:38:30 +000053 this.eventListeners.set("initialLoadComplete", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000054
55 // Check connection status periodically
56 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070057 }
58
59 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000060 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070061 */
62 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000063 // Connect to the SSE stream
64 this.connect();
65 }
66
67 /**
68 * Connect to the SSE stream
69 */
70 private connect(): void {
71 // If we're already connecting or connected, don't start another connection attempt
72 if (
73 this.eventSource &&
74 (this.connectionStatus === "connecting" ||
75 this.connectionStatus === "connected")
76 ) {
77 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070078 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000079
80 // Close any existing connection
81 this.closeEventSource();
82
banksean54777362025-06-19 16:38:30 +000083 // Reset initial load state for new connection
84 this.expectedMessageCount = null;
85 this.isInitialLoadComplete = false;
86
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000087 // Update connection status to connecting
88 this.updateConnectionStatus("connecting", "Connecting...");
89
90 // Determine the starting point for the stream based on what we already have
91 const fromIndex =
92 this.messages.length > 0
93 ? this.messages[this.messages.length - 1].idx + 1
94 : 0;
95
96 // Create a new EventSource connection
97 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
98
99 // Set up event handlers
100 this.eventSource.addEventListener("open", () => {
101 console.log("SSE stream opened");
102 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
103 this.updateConnectionStatus("connected");
104 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
105 });
106
107 this.eventSource.addEventListener("error", (event) => {
108 console.error("SSE stream error:", event);
109 this.closeEventSource();
110 this.updateConnectionStatus("disconnected", "Connection lost");
111 this.scheduleReconnect();
112 });
113
114 // Handle incoming messages
115 this.eventSource.addEventListener("message", (event) => {
116 const message = JSON.parse(event.data) as AgentMessage;
117 this.processNewMessage(message);
118 });
119
120 // Handle state updates
121 this.eventSource.addEventListener("state", (event) => {
122 const state = JSON.parse(event.data) as State;
123 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000124
125 // Store expected message count for initial load detection
126 if (this.expectedMessageCount === null) {
127 this.expectedMessageCount = state.message_count;
128 console.log(
129 `Initial load expects ${this.expectedMessageCount} messages`,
130 );
131
132 // Handle empty conversation case - immediately mark as complete
133 if (this.expectedMessageCount === 0) {
134 this.isInitialLoadComplete = true;
135 console.log(`Initial load complete: Empty conversation (0 messages)`);
136 this.emitEvent("initialLoadComplete", {
137 messageCount: 0,
138 expectedCount: 0,
139 });
140 }
141 }
142
143 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000144 this.emitEvent("dataChanged", { state, newMessages: [] });
145 });
146
147 // Handle heartbeats
148 this.eventSource.addEventListener("heartbeat", () => {
149 this.lastHeartbeatTime = Date.now();
150 // Make sure connection status is updated if it wasn't already
151 if (this.connectionStatus !== "connected") {
152 this.updateConnectionStatus("connected");
153 }
154 });
155 }
156
157 /**
158 * Close the current EventSource connection
159 */
160 private closeEventSource(): void {
161 if (this.eventSource) {
162 this.eventSource.close();
163 this.eventSource = null;
164 }
165 }
166
167 /**
168 * Schedule a reconnection attempt with exponential backoff
169 */
170 private scheduleReconnect(): void {
171 if (this.reconnectTimer !== null) {
172 window.clearTimeout(this.reconnectTimer);
173 this.reconnectTimer = null;
174 }
175
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700176 const delayIndex = Math.min(
177 this.reconnectAttempt,
178 this.reconnectDelaysMs.length - 1,
179 );
180 let delay = this.reconnectDelaysMs[delayIndex];
181 // Add jitter: +/- 10% of the delay
182 delay *= 0.9 + Math.random() * 0.2;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000183
184 console.log(
185 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
186 );
187
188 // Increment reconnect attempt counter
189 this.reconnectAttempt++;
190
191 // Schedule the reconnect
192 this.reconnectTimer = window.setTimeout(() => {
193 this.reconnectTimer = null;
194 this.connect();
195 }, delay);
196 }
197
198 /**
199 * Check heartbeat status to determine if connection is still active
200 */
201 private checkConnectionStatus(): void {
202 if (this.connectionStatus !== "connected") {
203 return; // Only check if we think we're connected
204 }
205
206 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
207 if (timeSinceLastHeartbeat > 90000) {
208 // 90 seconds without heartbeat
209 console.warn(
210 "No heartbeat received in 90 seconds, connection appears to be lost",
211 );
212 this.closeEventSource();
213 this.updateConnectionStatus(
214 "disconnected",
215 "Connection timed out (no heartbeat)",
216 );
217 this.scheduleReconnect();
218 }
219 }
220
221 /**
banksean54777362025-06-19 16:38:30 +0000222 * Check if initial load is complete based on expected message count
223 */
224 private checkInitialLoadComplete(): void {
225 if (
226 this.expectedMessageCount !== null &&
227 this.expectedMessageCount > 0 &&
228 this.messages.length >= this.expectedMessageCount &&
229 !this.isInitialLoadComplete
230 ) {
231 this.isInitialLoadComplete = true;
232 console.log(
233 `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
234 );
235
236 this.emitEvent("initialLoadComplete", {
237 messageCount: this.messages.length,
238 expectedCount: this.expectedMessageCount,
239 });
240 }
241 }
242
243 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000244 * Process a new message from the SSE stream
245 */
246 private processNewMessage(message: AgentMessage): void {
247 // Find the message's position in the array
248 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
249
250 if (existingIndex >= 0) {
251 // This shouldn't happen - we should never receive duplicates
252 console.error(
253 `Received duplicate message with idx ${message.idx}`,
254 message,
255 );
256 return;
257 } else {
258 // Add the new message to our array
259 this.messages.push(message);
260 // Sort messages by idx to ensure they're in the correct order
261 this.messages.sort((a, b) => a.idx - b.idx);
262 }
263
264 // Mark that we've completed first load
265 if (this.isFirstLoad) {
266 this.isFirstLoad = false;
267 }
268
banksean54777362025-06-19 16:38:30 +0000269 // Check if initial load is now complete
270 this.checkInitialLoadComplete();
271
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000272 // Emit an event that data has changed
273 this.emitEvent("dataChanged", {
274 state: this.timelineState,
275 newMessages: [message],
banksean54777362025-06-19 16:38:30 +0000276 isFirstFetch: this.isInitialLoadComplete,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000277 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 }
279
280 /**
281 * Get all messages
282 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700283 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 return this.messages;
285 }
286
287 /**
288 * Get the current state
289 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000290 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700291 return this.timelineState;
292 }
293
294 /**
295 * Get the connection status
296 */
297 public getConnectionStatus(): ConnectionStatus {
298 return this.connectionStatus;
299 }
300
301 /**
302 * Get the isFirstLoad flag
303 */
304 public getIsFirstLoad(): boolean {
305 return this.isFirstLoad;
306 }
307
308 /**
banksean54777362025-06-19 16:38:30 +0000309 * Get the initial load completion status
310 */
311 public getIsInitialLoadComplete(): boolean {
312 return this.isInitialLoadComplete;
313 }
314
315 /**
316 * Get the expected message count for initial load
317 */
318 public getExpectedMessageCount(): number | null {
319 return this.expectedMessageCount;
320 }
321
322 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700323 * Add an event listener
324 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700325 public addEventListener(
326 event: DataManagerEventType,
327 callback: (...args: any[]) => void,
328 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700329 const listeners = this.eventListeners.get(event) || [];
330 listeners.push(callback);
331 this.eventListeners.set(event, listeners);
332 }
333
334 /**
335 * Remove an event listener
336 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700337 public removeEventListener(
338 event: DataManagerEventType,
339 callback: (...args: any[]) => void,
340 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700341 const listeners = this.eventListeners.get(event) || [];
342 const index = listeners.indexOf(callback);
343 if (index !== -1) {
344 listeners.splice(index, 1);
345 this.eventListeners.set(event, listeners);
346 }
347 }
348
349 /**
350 * Emit an event
351 */
352 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
353 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700354 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700355 }
356
357 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700358 * Update the connection status
359 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000360 private updateConnectionStatus(
361 status: ConnectionStatus,
362 message?: string,
363 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700364 if (this.connectionStatus !== status) {
365 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000366 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700367 }
368 }
369
370 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000371 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700372 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000373 public async send(message: string): Promise<boolean> {
374 // Attempt to connect if we're not already connected
375 if (
376 this.connectionStatus !== "connected" &&
377 this.connectionStatus !== "connecting"
378 ) {
379 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700380 }
381
Earl Lee2e463fb2025-04-17 11:22:22 -0700382 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000383 const response = await fetch("chat", {
384 method: "POST",
385 headers: {
386 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700387 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000388 body: JSON.stringify({ message }),
389 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700390
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000391 if (!response.ok) {
392 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700393 }
394
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000395 return true;
396 } catch (error) {
397 console.error("Error sending message:", error);
398 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700399 }
400 }
401
402 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000403 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700404 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000405 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700406 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000407 const response = await fetch("cancel", {
408 method: "POST",
409 headers: {
410 "Content-Type": "application/json",
411 },
412 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700413 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000414
415 if (!response.ok) {
416 throw new Error(`HTTP error! Status: ${response.status}`);
417 }
418
419 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700420 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000421 console.error("Error cancelling conversation:", error);
422 return false;
423 }
424 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700425
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000426 /**
427 * Cancel a specific tool call
428 */
429 public async cancelToolUse(toolCallId: string): Promise<boolean> {
430 try {
431 const response = await fetch("cancel", {
432 method: "POST",
433 headers: {
434 "Content-Type": "application/json",
435 },
436 body: JSON.stringify({
437 reason: "User cancelled tool use",
438 tool_call_id: toolCallId,
439 }),
440 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700441
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000442 if (!response.ok) {
443 throw new Error(`HTTP error! Status: ${response.status}`);
444 }
445
446 return true;
447 } catch (error) {
448 console.error("Error cancelling tool use:", error);
449 return false;
450 }
451 }
452
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000453 /**
454 * Download the conversation data
455 */
456 public downloadConversation(): void {
457 window.location.href = "download";
458 }
459
460 /**
461 * Get a suggested reprompt
462 */
463 public async getSuggestedReprompt(): Promise<string | null> {
464 try {
465 const response = await fetch("suggest-reprompt");
466 if (!response.ok) {
467 throw new Error(`HTTP error! Status: ${response.status}`);
468 }
469 const data = await response.json();
470 return data.prompt;
471 } catch (error) {
472 console.error("Error getting suggested reprompt:", error);
473 return null;
474 }
475 }
476
477 /**
478 * Get description for a commit
479 */
480 public async getCommitDescription(revision: string): Promise<string | null> {
481 try {
482 const response = await fetch(
483 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700484 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000485 if (!response.ok) {
486 throw new Error(`HTTP error! Status: ${response.status}`);
487 }
488 const data = await response.json();
489 return data.description;
490 } catch (error) {
491 console.error("Error getting commit description:", error);
492 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700493 }
494 }
495}