blob: 7aa0923a81ecfd80f6861da0e66165d3c9bde9f8 [file] [log] [blame]
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001import { AgentMessage, State } from "./types";
Earl Lee2e463fb2025-04-17 11:22:22 -07002import { 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 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000012export type ConnectionStatus =
13 | "connected"
14 | "connecting"
15 | "disconnected"
16 | "disabled";
Earl Lee2e463fb2025-04-17 11:22:22 -070017
18/**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000019 * DataManager - Class to manage timeline data, fetching, and SSE streaming
Earl Lee2e463fb2025-04-17 11:22:22 -070020 */
21export class DataManager {
22 // State variables
Sean McCulloughd9f13372025-04-21 15:08:49 -070023 private messages: AgentMessage[] = [];
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000024 private timelineState: State | null = null;
25 private isFirstLoad: boolean = true;
26 private lastHeartbeatTime: number = 0;
27 private connectionStatus: ConnectionStatus = "disconnected";
28 private eventSource: EventSource | null = null;
29 private reconnectTimer: number | null = null;
30 private reconnectAttempt: number = 0;
31 private maxReconnectDelayMs: number = 60000; // Max delay of 60 seconds
32 private baseReconnectDelayMs: number = 1000; // Start with 1 second
Sean McCullough71941bd2025-04-18 13:31:48 -070033
Earl Lee2e463fb2025-04-17 11:22:22 -070034 // Event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -070035 private eventListeners: Map<
36 DataManagerEventType,
37 Array<(...args: any[]) => void>
38 > = new Map();
Earl Lee2e463fb2025-04-17 11:22:22 -070039
40 constructor() {
41 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070042 this.eventListeners.set("dataChanged", []);
43 this.eventListeners.set("connectionStatusChanged", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000044
45 // Check connection status periodically
46 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070047 }
48
49 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000050 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070051 */
52 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000053 // Connect to the SSE stream
54 this.connect();
55 }
56
57 /**
58 * Connect to the SSE stream
59 */
60 private connect(): void {
61 // If we're already connecting or connected, don't start another connection attempt
62 if (
63 this.eventSource &&
64 (this.connectionStatus === "connecting" ||
65 this.connectionStatus === "connected")
66 ) {
67 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070068 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000069
70 // Close any existing connection
71 this.closeEventSource();
72
73 // Update connection status to connecting
74 this.updateConnectionStatus("connecting", "Connecting...");
75
76 // Determine the starting point for the stream based on what we already have
77 const fromIndex =
78 this.messages.length > 0
79 ? this.messages[this.messages.length - 1].idx + 1
80 : 0;
81
82 // Create a new EventSource connection
83 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
84
85 // Set up event handlers
86 this.eventSource.addEventListener("open", () => {
87 console.log("SSE stream opened");
88 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
89 this.updateConnectionStatus("connected");
90 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
91 });
92
93 this.eventSource.addEventListener("error", (event) => {
94 console.error("SSE stream error:", event);
95 this.closeEventSource();
96 this.updateConnectionStatus("disconnected", "Connection lost");
97 this.scheduleReconnect();
98 });
99
100 // Handle incoming messages
101 this.eventSource.addEventListener("message", (event) => {
102 const message = JSON.parse(event.data) as AgentMessage;
103 this.processNewMessage(message);
104 });
105
106 // Handle state updates
107 this.eventSource.addEventListener("state", (event) => {
108 const state = JSON.parse(event.data) as State;
109 this.timelineState = state;
110 this.emitEvent("dataChanged", { state, newMessages: [] });
111 });
112
113 // Handle heartbeats
114 this.eventSource.addEventListener("heartbeat", () => {
115 this.lastHeartbeatTime = Date.now();
116 // Make sure connection status is updated if it wasn't already
117 if (this.connectionStatus !== "connected") {
118 this.updateConnectionStatus("connected");
119 }
120 });
121 }
122
123 /**
124 * Close the current EventSource connection
125 */
126 private closeEventSource(): void {
127 if (this.eventSource) {
128 this.eventSource.close();
129 this.eventSource = null;
130 }
131 }
132
133 /**
134 * Schedule a reconnection attempt with exponential backoff
135 */
136 private scheduleReconnect(): void {
137 if (this.reconnectTimer !== null) {
138 window.clearTimeout(this.reconnectTimer);
139 this.reconnectTimer = null;
140 }
141
142 // Calculate backoff delay with exponential increase and maximum limit
143 const delay = Math.min(
144 this.baseReconnectDelayMs * Math.pow(1.5, this.reconnectAttempt),
145 this.maxReconnectDelayMs,
146 );
147
148 console.log(
149 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
150 );
151
152 // Increment reconnect attempt counter
153 this.reconnectAttempt++;
154
155 // Schedule the reconnect
156 this.reconnectTimer = window.setTimeout(() => {
157 this.reconnectTimer = null;
158 this.connect();
159 }, delay);
160 }
161
162 /**
163 * Check heartbeat status to determine if connection is still active
164 */
165 private checkConnectionStatus(): void {
166 if (this.connectionStatus !== "connected") {
167 return; // Only check if we think we're connected
168 }
169
170 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
171 if (timeSinceLastHeartbeat > 90000) {
172 // 90 seconds without heartbeat
173 console.warn(
174 "No heartbeat received in 90 seconds, connection appears to be lost",
175 );
176 this.closeEventSource();
177 this.updateConnectionStatus(
178 "disconnected",
179 "Connection timed out (no heartbeat)",
180 );
181 this.scheduleReconnect();
182 }
183 }
184
185 /**
186 * Process a new message from the SSE stream
187 */
188 private processNewMessage(message: AgentMessage): void {
189 // Find the message's position in the array
190 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
191
192 if (existingIndex >= 0) {
193 // This shouldn't happen - we should never receive duplicates
194 console.error(
195 `Received duplicate message with idx ${message.idx}`,
196 message,
197 );
198 return;
199 } else {
200 // Add the new message to our array
201 this.messages.push(message);
202 // Sort messages by idx to ensure they're in the correct order
203 this.messages.sort((a, b) => a.idx - b.idx);
204 }
205
206 // Mark that we've completed first load
207 if (this.isFirstLoad) {
208 this.isFirstLoad = false;
209 }
210
211 // Emit an event that data has changed
212 this.emitEvent("dataChanged", {
213 state: this.timelineState,
214 newMessages: [message],
215 isFirstFetch: false,
216 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 }
218
219 /**
220 * Get all messages
221 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700222 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 return this.messages;
224 }
225
226 /**
227 * Get the current state
228 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000229 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700230 return this.timelineState;
231 }
232
233 /**
234 * Get the connection status
235 */
236 public getConnectionStatus(): ConnectionStatus {
237 return this.connectionStatus;
238 }
239
240 /**
241 * Get the isFirstLoad flag
242 */
243 public getIsFirstLoad(): boolean {
244 return this.isFirstLoad;
245 }
246
247 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 * Add an event listener
249 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700250 public addEventListener(
251 event: DataManagerEventType,
252 callback: (...args: any[]) => void,
253 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700254 const listeners = this.eventListeners.get(event) || [];
255 listeners.push(callback);
256 this.eventListeners.set(event, listeners);
257 }
258
259 /**
260 * Remove an event listener
261 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700262 public removeEventListener(
263 event: DataManagerEventType,
264 callback: (...args: any[]) => void,
265 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700266 const listeners = this.eventListeners.get(event) || [];
267 const index = listeners.indexOf(callback);
268 if (index !== -1) {
269 listeners.splice(index, 1);
270 this.eventListeners.set(event, listeners);
271 }
272 }
273
274 /**
275 * Emit an event
276 */
277 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
278 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700279 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 }
281
282 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 * Update the connection status
284 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000285 private updateConnectionStatus(
286 status: ConnectionStatus,
287 message?: string,
288 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700289 if (this.connectionStatus !== status) {
290 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000291 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700292 }
293 }
294
295 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000296 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700297 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000298 public async send(message: string): Promise<boolean> {
299 // Attempt to connect if we're not already connected
300 if (
301 this.connectionStatus !== "connected" &&
302 this.connectionStatus !== "connecting"
303 ) {
304 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700305 }
306
Earl Lee2e463fb2025-04-17 11:22:22 -0700307 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000308 const response = await fetch("chat", {
309 method: "POST",
310 headers: {
311 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700312 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000313 body: JSON.stringify({ message }),
314 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700315
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000316 if (!response.ok) {
317 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700318 }
319
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000320 return true;
321 } catch (error) {
322 console.error("Error sending message:", error);
323 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700324 }
325 }
326
327 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000328 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700329 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000330 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700331 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000332 const response = await fetch("cancel", {
333 method: "POST",
334 headers: {
335 "Content-Type": "application/json",
336 },
337 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700338 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000339
340 if (!response.ok) {
341 throw new Error(`HTTP error! Status: ${response.status}`);
342 }
343
344 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700345 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000346 console.error("Error cancelling conversation:", error);
347 return false;
348 }
349 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700350
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000351 /**
352 * Cancel a specific tool call
353 */
354 public async cancelToolUse(toolCallId: string): Promise<boolean> {
355 try {
356 const response = await fetch("cancel", {
357 method: "POST",
358 headers: {
359 "Content-Type": "application/json",
360 },
361 body: JSON.stringify({
362 reason: "User cancelled tool use",
363 tool_call_id: toolCallId,
364 }),
365 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700366
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000367 if (!response.ok) {
368 throw new Error(`HTTP error! Status: ${response.status}`);
369 }
370
371 return true;
372 } catch (error) {
373 console.error("Error cancelling tool use:", error);
374 return false;
375 }
376 }
377
378 /**
379 * Restart the conversation
380 */
381 public async restart(
382 revision: string,
383 initialPrompt: string,
384 ): Promise<boolean> {
385 try {
386 const response = await fetch("restart", {
387 method: "POST",
388 headers: {
389 "Content-Type": "application/json",
390 },
391 body: JSON.stringify({
392 revision,
393 initial_prompt: initialPrompt,
394 }),
395 });
396
397 if (!response.ok) {
398 throw new Error(`HTTP error! Status: ${response.status}`);
399 }
400
401 return true;
402 } catch (error) {
403 console.error("Error restarting conversation:", error);
404 return false;
405 }
406 }
407
408 /**
409 * Download the conversation data
410 */
411 public downloadConversation(): void {
412 window.location.href = "download";
413 }
414
415 /**
416 * Get a suggested reprompt
417 */
418 public async getSuggestedReprompt(): Promise<string | null> {
419 try {
420 const response = await fetch("suggest-reprompt");
421 if (!response.ok) {
422 throw new Error(`HTTP error! Status: ${response.status}`);
423 }
424 const data = await response.json();
425 return data.prompt;
426 } catch (error) {
427 console.error("Error getting suggested reprompt:", error);
428 return null;
429 }
430 }
431
432 /**
433 * Get description for a commit
434 */
435 public async getCommitDescription(revision: string): Promise<string | null> {
436 try {
437 const response = await fetch(
438 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700439 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000440 if (!response.ok) {
441 throw new Error(`HTTP error! Status: ${response.status}`);
442 }
443 const data = await response.json();
444 return data.description;
445 } catch (error) {
446 console.error("Error getting commit description:", error);
447 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700448 }
449 }
450}