blob: 4877ca87cfc7ed211fc960fdb9c1a1304da62762 [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"
bankseanc67d7bc2025-07-23 10:59:02 -070010 | "initialLoadComplete"
11 | "sessionEnded";
Earl Lee2e463fb2025-04-17 11:22:22 -070012
13/**
14 * Connection status types
15 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000016export type ConnectionStatus =
17 | "connected"
18 | "connecting"
19 | "disconnected"
20 | "disabled";
Earl Lee2e463fb2025-04-17 11:22:22 -070021
22/**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000023 * DataManager - Class to manage timeline data, fetching, and SSE streaming
Earl Lee2e463fb2025-04-17 11:22:22 -070024 */
25export class DataManager {
26 // State variables
Sean McCulloughd9f13372025-04-21 15:08:49 -070027 private messages: AgentMessage[] = [];
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000028 private timelineState: State | null = null;
29 private isFirstLoad: boolean = true;
30 private lastHeartbeatTime: number = 0;
31 private connectionStatus: ConnectionStatus = "disconnected";
32 private eventSource: EventSource | null = null;
33 private reconnectTimer: number | null = null;
34 private reconnectAttempt: number = 0;
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -070035 // Reconnection timeout delays in milliseconds (runs from 100ms to ~15s). Fibonacci-ish.
Josh Bleecher Snyder0f004272025-07-21 22:40:03 +000036 private readonly reconnectDelaysMs: number[] = [
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -070037 100, 100, 200, 300, 500, 800, 1300, 2100, 3400, 5500, 8900, 14400,
Josh Bleecher Snyder0f004272025-07-21 22:40:03 +000038 ];
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
bankseanc67d7bc2025-07-23 10:59:02 -070050 // Session state tracking
51 private isSessionEnded: boolean = false;
52 private userCanSendMessages: boolean = true; // User permission to send messages
53
Earl Lee2e463fb2025-04-17 11:22:22 -070054 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", []);
banksean54777362025-06-19 16:38:30 +000058 this.eventListeners.set("initialLoadComplete", []);
bankseanc67d7bc2025-07-23 10:59:02 -070059 this.eventListeners.set("sessionEnded", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000060
61 // Check connection status periodically
62 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070063 }
64
65 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000066 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070067 */
68 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000069 // Connect to the SSE stream
70 this.connect();
71 }
72
73 /**
74 * Connect to the SSE stream
75 */
76 private connect(): void {
77 // If we're already connecting or connected, don't start another connection attempt
78 if (
79 this.eventSource &&
80 (this.connectionStatus === "connecting" ||
81 this.connectionStatus === "connected")
82 ) {
83 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070084 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000085
86 // Close any existing connection
87 this.closeEventSource();
88
banksean54777362025-06-19 16:38:30 +000089 // Reset initial load state for new connection
90 this.expectedMessageCount = null;
91 this.isInitialLoadComplete = false;
92
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000093 // Update connection status to connecting
94 this.updateConnectionStatus("connecting", "Connecting...");
95
96 // Determine the starting point for the stream based on what we already have
97 const fromIndex =
98 this.messages.length > 0
99 ? this.messages[this.messages.length - 1].idx + 1
100 : 0;
101
102 // Create a new EventSource connection
103 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
104
105 // Set up event handlers
106 this.eventSource.addEventListener("open", () => {
107 console.log("SSE stream opened");
108 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
109 this.updateConnectionStatus("connected");
110 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
111 });
112
113 this.eventSource.addEventListener("error", (event) => {
114 console.error("SSE stream error:", event);
115 this.closeEventSource();
116 this.updateConnectionStatus("disconnected", "Connection lost");
117 this.scheduleReconnect();
118 });
119
120 // Handle incoming messages
121 this.eventSource.addEventListener("message", (event) => {
122 const message = JSON.parse(event.data) as AgentMessage;
123 this.processNewMessage(message);
124 });
125
126 // Handle state updates
127 this.eventSource.addEventListener("state", (event) => {
128 const state = JSON.parse(event.data) as State;
129 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000130
bankseanc67d7bc2025-07-23 10:59:02 -0700131 // Check session state and user permissions from server
132 const stateData = state;
133 if (stateData.session_ended === true) {
134 this.isSessionEnded = true;
135 this.userCanSendMessages = false;
136 console.log("Detected ended session from state event");
137 } else if (stateData.can_send_messages === false) {
138 // Session is active but user has read-only access
139 this.userCanSendMessages = false;
140 console.log("Detected read-only access to active session");
141 }
142
banksean54777362025-06-19 16:38:30 +0000143 // Store expected message count for initial load detection
144 if (this.expectedMessageCount === null) {
145 this.expectedMessageCount = state.message_count;
146 console.log(
147 `Initial load expects ${this.expectedMessageCount} messages`,
148 );
149
150 // Handle empty conversation case - immediately mark as complete
151 if (this.expectedMessageCount === 0) {
152 this.isInitialLoadComplete = true;
153 console.log(`Initial load complete: Empty conversation (0 messages)`);
154 this.emitEvent("initialLoadComplete", {
155 messageCount: 0,
156 expectedCount: 0,
157 });
158 }
159 }
160
bankseanc67d7bc2025-07-23 10:59:02 -0700161 // Update connection status when we receive state
162 if (this.connectionStatus !== "connected" && !this.isSessionEnded) {
163 this.updateConnectionStatus("connected");
164 }
165
banksean54777362025-06-19 16:38:30 +0000166 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000167 this.emitEvent("dataChanged", { state, newMessages: [] });
168 });
169
170 // Handle heartbeats
171 this.eventSource.addEventListener("heartbeat", () => {
172 this.lastHeartbeatTime = Date.now();
173 // Make sure connection status is updated if it wasn't already
174 if (this.connectionStatus !== "connected") {
175 this.updateConnectionStatus("connected");
176 }
177 });
bankseanc67d7bc2025-07-23 10:59:02 -0700178
179 // Handle session ended events for inactive sessions
180 this.eventSource.addEventListener("session_ended", (event) => {
181 const data = JSON.parse(event.data);
182 console.log("Session ended:", data.message);
183
184 this.isSessionEnded = true;
185 this.userCanSendMessages = false;
186 this.isInitialLoadComplete = true;
187
188 // Close the connection since no more data will come
189 this.closeEventSource();
190
191 // Clear any pending reconnection attempts
192 if (this.reconnectTimer !== null) {
193 window.clearTimeout(this.reconnectTimer);
194 this.reconnectTimer = null;
195 }
196 this.reconnectAttempt = 0;
197
198 // Update status to indicate session has ended
199 this.updateConnectionStatus("disabled", "Session ended");
200
201 // Notify listeners about the state change
202 this.emitEvent("sessionEnded", data);
203 this.emitEvent("dataChanged", {
204 state: this.timelineState,
205 newMessages: [],
206 });
207 this.emitEvent("initialLoadComplete", {
208 messageCount: this.messages.length,
209 expectedCount: this.messages.length,
210 });
211 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000212 }
213
214 /**
215 * Close the current EventSource connection
216 */
217 private closeEventSource(): void {
218 if (this.eventSource) {
219 this.eventSource.close();
220 this.eventSource = null;
221 }
222 }
223
224 /**
225 * Schedule a reconnection attempt with exponential backoff
226 */
227 private scheduleReconnect(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700228 // Don't schedule reconnections for ended sessions
229 if (this.isSessionEnded) {
230 console.log("Skipping reconnection attempt - session has ended");
231 return;
232 }
233
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000234 if (this.reconnectTimer !== null) {
235 window.clearTimeout(this.reconnectTimer);
236 this.reconnectTimer = null;
237 }
238
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700239 const delayIndex = Math.min(
240 this.reconnectAttempt,
241 this.reconnectDelaysMs.length - 1,
242 );
243 let delay = this.reconnectDelaysMs[delayIndex];
244 // Add jitter: +/- 10% of the delay
245 delay *= 0.9 + Math.random() * 0.2;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000246
247 console.log(
248 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
249 );
250
251 // Increment reconnect attempt counter
252 this.reconnectAttempt++;
253
254 // Schedule the reconnect
255 this.reconnectTimer = window.setTimeout(() => {
256 this.reconnectTimer = null;
257 this.connect();
258 }, delay);
259 }
260
261 /**
262 * Check heartbeat status to determine if connection is still active
263 */
264 private checkConnectionStatus(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700265 if (this.connectionStatus !== "connected" || this.isSessionEnded) {
266 return; // Only check if we think we're connected and session hasn't ended
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000267 }
268
269 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
270 if (timeSinceLastHeartbeat > 90000) {
271 // 90 seconds without heartbeat
272 console.warn(
273 "No heartbeat received in 90 seconds, connection appears to be lost",
274 );
275 this.closeEventSource();
276 this.updateConnectionStatus(
277 "disconnected",
278 "Connection timed out (no heartbeat)",
279 );
280 this.scheduleReconnect();
281 }
282 }
283
284 /**
banksean54777362025-06-19 16:38:30 +0000285 * Check if initial load is complete based on expected message count
286 */
287 private checkInitialLoadComplete(): void {
288 if (
289 this.expectedMessageCount !== null &&
290 this.expectedMessageCount > 0 &&
291 this.messages.length >= this.expectedMessageCount &&
292 !this.isInitialLoadComplete
293 ) {
294 this.isInitialLoadComplete = true;
295 console.log(
296 `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
297 );
298
299 this.emitEvent("initialLoadComplete", {
300 messageCount: this.messages.length,
301 expectedCount: this.expectedMessageCount,
302 });
303 }
304 }
305
306 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000307 * Process a new message from the SSE stream
308 */
309 private processNewMessage(message: AgentMessage): void {
310 // Find the message's position in the array
311 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
312
313 if (existingIndex >= 0) {
314 // This shouldn't happen - we should never receive duplicates
315 console.error(
316 `Received duplicate message with idx ${message.idx}`,
317 message,
318 );
319 return;
320 } else {
321 // Add the new message to our array
322 this.messages.push(message);
323 // Sort messages by idx to ensure they're in the correct order
324 this.messages.sort((a, b) => a.idx - b.idx);
325 }
326
327 // Mark that we've completed first load
328 if (this.isFirstLoad) {
329 this.isFirstLoad = false;
330 }
331
banksean54777362025-06-19 16:38:30 +0000332 // Check if initial load is now complete
333 this.checkInitialLoadComplete();
334
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000335 // Emit an event that data has changed
336 this.emitEvent("dataChanged", {
337 state: this.timelineState,
338 newMessages: [message],
banksean54777362025-06-19 16:38:30 +0000339 isFirstFetch: this.isInitialLoadComplete,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000340 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700341 }
342
343 /**
344 * Get all messages
345 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700346 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700347 return this.messages;
348 }
349
350 /**
351 * Get the current state
352 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000353 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700354 return this.timelineState;
355 }
356
357 /**
358 * Get the connection status
359 */
360 public getConnectionStatus(): ConnectionStatus {
361 return this.connectionStatus;
362 }
363
364 /**
365 * Get the isFirstLoad flag
366 */
367 public getIsFirstLoad(): boolean {
368 return this.isFirstLoad;
369 }
370
371 /**
banksean54777362025-06-19 16:38:30 +0000372 * Get the initial load completion status
373 */
374 public getIsInitialLoadComplete(): boolean {
375 return this.isInitialLoadComplete;
376 }
377
378 /**
379 * Get the expected message count for initial load
380 */
381 public getExpectedMessageCount(): number | null {
382 return this.expectedMessageCount;
383 }
384
385 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700386 * Add an event listener
387 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700388 public addEventListener(
389 event: DataManagerEventType,
390 callback: (...args: any[]) => void,
391 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700392 const listeners = this.eventListeners.get(event) || [];
393 listeners.push(callback);
394 this.eventListeners.set(event, listeners);
395 }
396
397 /**
398 * Remove an event listener
399 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700400 public removeEventListener(
401 event: DataManagerEventType,
402 callback: (...args: any[]) => void,
403 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700404 const listeners = this.eventListeners.get(event) || [];
405 const index = listeners.indexOf(callback);
406 if (index !== -1) {
407 listeners.splice(index, 1);
408 this.eventListeners.set(event, listeners);
409 }
410 }
411
412 /**
413 * Emit an event
414 */
415 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
416 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700417 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700418 }
419
420 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700421 * Update the connection status
422 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000423 private updateConnectionStatus(
424 status: ConnectionStatus,
425 message?: string,
426 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700427 if (this.connectionStatus !== status) {
428 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000429 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700430 }
431 }
432
433 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000434 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700435 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000436 public async send(message: string): Promise<boolean> {
437 // Attempt to connect if we're not already connected
438 if (
439 this.connectionStatus !== "connected" &&
440 this.connectionStatus !== "connecting"
441 ) {
442 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700443 }
444
Earl Lee2e463fb2025-04-17 11:22:22 -0700445 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000446 const response = await fetch("chat", {
447 method: "POST",
448 headers: {
449 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700450 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000451 body: JSON.stringify({ message }),
452 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700453
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000454 if (!response.ok) {
455 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700456 }
457
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000458 return true;
459 } catch (error) {
460 console.error("Error sending message:", error);
461 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700462 }
463 }
464
465 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000466 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700467 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000468 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700469 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000470 const response = await fetch("cancel", {
471 method: "POST",
472 headers: {
473 "Content-Type": "application/json",
474 },
475 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700476 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000477
478 if (!response.ok) {
479 throw new Error(`HTTP error! Status: ${response.status}`);
480 }
481
482 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700483 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000484 console.error("Error cancelling conversation:", error);
485 return false;
486 }
487 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700488
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000489 /**
490 * Cancel a specific tool call
491 */
492 public async cancelToolUse(toolCallId: string): Promise<boolean> {
493 try {
494 const response = await fetch("cancel", {
495 method: "POST",
496 headers: {
497 "Content-Type": "application/json",
498 },
499 body: JSON.stringify({
500 reason: "User cancelled tool use",
501 tool_call_id: toolCallId,
502 }),
503 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700504
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000505 if (!response.ok) {
506 throw new Error(`HTTP error! Status: ${response.status}`);
507 }
508
509 return true;
510 } catch (error) {
511 console.error("Error cancelling tool use:", error);
512 return false;
513 }
514 }
515
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000516 /**
517 * Download the conversation data
518 */
519 public downloadConversation(): void {
520 window.location.href = "download";
521 }
522
523 /**
524 * Get a suggested reprompt
525 */
526 public async getSuggestedReprompt(): Promise<string | null> {
527 try {
528 const response = await fetch("suggest-reprompt");
529 if (!response.ok) {
530 throw new Error(`HTTP error! Status: ${response.status}`);
531 }
532 const data = await response.json();
533 return data.prompt;
534 } catch (error) {
535 console.error("Error getting suggested reprompt:", error);
536 return null;
537 }
538 }
539
540 /**
541 * Get description for a commit
542 */
543 public async getCommitDescription(revision: string): Promise<string | null> {
544 try {
545 const response = await fetch(
546 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700547 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000548 if (!response.ok) {
549 throw new Error(`HTTP error! Status: ${response.status}`);
550 }
551 const data = await response.json();
552 return data.description;
553 } catch (error) {
554 console.error("Error getting commit description:", error);
555 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700556 }
557 }
bankseanc67d7bc2025-07-23 10:59:02 -0700558
559 /**
560 * Check if this session has ended (no more updates will come)
561 */
562 public get sessionEnded(): boolean {
563 return this.isSessionEnded;
564 }
565
566 /**
567 * Check if the current user can send messages (write access)
568 */
569 public get canSendMessages(): boolean {
570 return this.userCanSendMessages;
571 }
572
573 /**
574 * Check if this is effectively read-only (either ended or no write permission)
575 * @deprecated Use sessionEnded and canSendMessages instead for more precise control
576 */
577 public get readOnlyMode(): boolean {
578 return !this.userCanSendMessages;
579 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700580}