blob: 9d2444402d8d13c0c523d46e0a4f4e2c14b96d17 [file] [log] [blame]
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001import { AgentMessage, State } from "./types";
Earl Lee2e463fb2025-04-17 11:22:22 -07002
3/**
4 * Event types for data manager
5 */
banksean54777362025-06-19 16:38:30 +00006export type DataManagerEventType =
7 | "dataChanged"
8 | "connectionStatusChanged"
bankseanc67d7bc2025-07-23 10:59:02 -07009 | "initialLoadComplete"
banksean568bebf2025-07-24 02:44:54 +000010 | "sessionEnded"
11 | "sessionDataReady";
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", []);
banksean568bebf2025-07-24 02:44:54 +000060 this.eventListeners.set("sessionDataReady", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000061
62 // Check connection status periodically
63 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070064 }
65
66 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000067 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070068 */
69 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000070 // Connect to the SSE stream
71 this.connect();
72 }
73
74 /**
75 * Connect to the SSE stream
76 */
77 private connect(): void {
banksean568bebf2025-07-24 02:44:54 +000078 // Don't attempt to connect if the session has ended
banksean7fe76972025-07-23 19:19:01 -070079 if (this.isSessionEnded) {
80 console.log("Skipping connection attempt - session has ended");
81 return;
82 }
83
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000084 // If we're already connecting or connected, don't start another connection attempt
85 if (
86 this.eventSource &&
87 (this.connectionStatus === "connecting" ||
88 this.connectionStatus === "connected")
89 ) {
90 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070091 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000092
93 // Close any existing connection
94 this.closeEventSource();
95
banksean54777362025-06-19 16:38:30 +000096 // Reset initial load state for new connection
97 this.expectedMessageCount = null;
98 this.isInitialLoadComplete = false;
99
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000100 // Update connection status to connecting
101 this.updateConnectionStatus("connecting", "Connecting...");
102
103 // Determine the starting point for the stream based on what we already have
104 const fromIndex =
105 this.messages.length > 0
106 ? this.messages[this.messages.length - 1].idx + 1
107 : 0;
108
109 // Create a new EventSource connection
110 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
111
112 // Set up event handlers
113 this.eventSource.addEventListener("open", () => {
114 console.log("SSE stream opened");
115 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
116 this.updateConnectionStatus("connected");
117 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
118 });
119
120 this.eventSource.addEventListener("error", (event) => {
121 console.error("SSE stream error:", event);
122 this.closeEventSource();
123 this.updateConnectionStatus("disconnected", "Connection lost");
124 this.scheduleReconnect();
125 });
126
127 // Handle incoming messages
128 this.eventSource.addEventListener("message", (event) => {
129 const message = JSON.parse(event.data) as AgentMessage;
130 this.processNewMessage(message);
131 });
132
133 // Handle state updates
134 this.eventSource.addEventListener("state", (event) => {
135 const state = JSON.parse(event.data) as State;
136 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000137
bankseanc67d7bc2025-07-23 10:59:02 -0700138 // Check session state and user permissions from server
139 const stateData = state;
140 if (stateData.session_ended === true) {
141 this.isSessionEnded = true;
142 this.userCanSendMessages = false;
143 console.log("Detected ended session from state event");
144 } else if (stateData.can_send_messages === false) {
145 // Session is active but user has read-only access
146 this.userCanSendMessages = false;
147 console.log("Detected read-only access to active session");
148 }
149
banksean54777362025-06-19 16:38:30 +0000150 // Store expected message count for initial load detection
151 if (this.expectedMessageCount === null) {
152 this.expectedMessageCount = state.message_count;
153 console.log(
154 `Initial load expects ${this.expectedMessageCount} messages`,
155 );
156
157 // Handle empty conversation case - immediately mark as complete
158 if (this.expectedMessageCount === 0) {
159 this.isInitialLoadComplete = true;
160 console.log(`Initial load complete: Empty conversation (0 messages)`);
161 this.emitEvent("initialLoadComplete", {
162 messageCount: 0,
163 expectedCount: 0,
164 });
165 }
166 }
167
bankseanc67d7bc2025-07-23 10:59:02 -0700168 // Update connection status when we receive state
169 if (this.connectionStatus !== "connected" && !this.isSessionEnded) {
170 this.updateConnectionStatus("connected");
171 }
172
banksean54777362025-06-19 16:38:30 +0000173 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000174 this.emitEvent("dataChanged", { state, newMessages: [] });
175 });
176
177 // Handle heartbeats
178 this.eventSource.addEventListener("heartbeat", () => {
179 this.lastHeartbeatTime = Date.now();
180 // Make sure connection status is updated if it wasn't already
181 if (this.connectionStatus !== "connected") {
182 this.updateConnectionStatus("connected");
183 }
184 });
bankseanc67d7bc2025-07-23 10:59:02 -0700185
186 // Handle session ended events for inactive sessions
187 this.eventSource.addEventListener("session_ended", (event) => {
188 const data = JSON.parse(event.data);
189 console.log("Session ended:", data.message);
190
191 this.isSessionEnded = true;
192 this.userCanSendMessages = false;
193 this.isInitialLoadComplete = true;
194
195 // Close the connection since no more data will come
196 this.closeEventSource();
197
198 // Clear any pending reconnection attempts
199 if (this.reconnectTimer !== null) {
200 window.clearTimeout(this.reconnectTimer);
201 this.reconnectTimer = null;
202 }
203 this.reconnectAttempt = 0;
204
205 // Update status to indicate session has ended
206 this.updateConnectionStatus("disabled", "Session ended");
207
208 // Notify listeners about the state change
209 this.emitEvent("sessionEnded", data);
210 this.emitEvent("dataChanged", {
211 state: this.timelineState,
212 newMessages: [],
213 });
banksean568bebf2025-07-24 02:44:54 +0000214 // Emit sessionDataReady for components that need to know the ended session data is ready
215 // (like newsessions), but don't emit initialLoadComplete as that's for live session loads
216 this.emitEvent("sessionDataReady", {
bankseanc67d7bc2025-07-23 10:59:02 -0700217 messageCount: this.messages.length,
218 expectedCount: this.messages.length,
banksean568bebf2025-07-24 02:44:54 +0000219 isEndedSession: true,
bankseanc67d7bc2025-07-23 10:59:02 -0700220 });
221 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000222 }
223
224 /**
225 * Close the current EventSource connection
226 */
227 private closeEventSource(): void {
228 if (this.eventSource) {
229 this.eventSource.close();
230 this.eventSource = null;
231 }
232 }
233
234 /**
235 * Schedule a reconnection attempt with exponential backoff
236 */
237 private scheduleReconnect(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700238 // Don't schedule reconnections for ended sessions
239 if (this.isSessionEnded) {
240 console.log("Skipping reconnection attempt - session has ended");
241 return;
242 }
243
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000244 if (this.reconnectTimer !== null) {
245 window.clearTimeout(this.reconnectTimer);
246 this.reconnectTimer = null;
247 }
248
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700249 const delayIndex = Math.min(
250 this.reconnectAttempt,
251 this.reconnectDelaysMs.length - 1,
252 );
253 let delay = this.reconnectDelaysMs[delayIndex];
254 // Add jitter: +/- 10% of the delay
255 delay *= 0.9 + Math.random() * 0.2;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000256
257 console.log(
258 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
259 );
260
261 // Increment reconnect attempt counter
262 this.reconnectAttempt++;
263
264 // Schedule the reconnect
265 this.reconnectTimer = window.setTimeout(() => {
266 this.reconnectTimer = null;
267 this.connect();
268 }, delay);
269 }
270
271 /**
272 * Check heartbeat status to determine if connection is still active
273 */
274 private checkConnectionStatus(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700275 if (this.connectionStatus !== "connected" || this.isSessionEnded) {
276 return; // Only check if we think we're connected and session hasn't ended
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000277 }
278
279 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
280 if (timeSinceLastHeartbeat > 90000) {
281 // 90 seconds without heartbeat
282 console.warn(
283 "No heartbeat received in 90 seconds, connection appears to be lost",
284 );
285 this.closeEventSource();
286 this.updateConnectionStatus(
287 "disconnected",
288 "Connection timed out (no heartbeat)",
289 );
290 this.scheduleReconnect();
291 }
292 }
293
294 /**
banksean54777362025-06-19 16:38:30 +0000295 * Check if initial load is complete based on expected message count
296 */
297 private checkInitialLoadComplete(): void {
298 if (
299 this.expectedMessageCount !== null &&
300 this.expectedMessageCount > 0 &&
301 this.messages.length >= this.expectedMessageCount &&
302 !this.isInitialLoadComplete
303 ) {
304 this.isInitialLoadComplete = true;
305 console.log(
306 `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
307 );
308
309 this.emitEvent("initialLoadComplete", {
310 messageCount: this.messages.length,
311 expectedCount: this.expectedMessageCount,
312 });
313 }
314 }
315
316 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000317 * Process a new message from the SSE stream
318 */
319 private processNewMessage(message: AgentMessage): void {
320 // Find the message's position in the array
321 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
322
323 if (existingIndex >= 0) {
324 // This shouldn't happen - we should never receive duplicates
325 console.error(
326 `Received duplicate message with idx ${message.idx}`,
327 message,
328 );
329 return;
330 } else {
331 // Add the new message to our array
332 this.messages.push(message);
333 // Sort messages by idx to ensure they're in the correct order
334 this.messages.sort((a, b) => a.idx - b.idx);
335 }
336
337 // Mark that we've completed first load
338 if (this.isFirstLoad) {
339 this.isFirstLoad = false;
340 }
341
banksean54777362025-06-19 16:38:30 +0000342 // Check if initial load is now complete
343 this.checkInitialLoadComplete();
344
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000345 // Emit an event that data has changed
346 this.emitEvent("dataChanged", {
347 state: this.timelineState,
348 newMessages: [message],
banksean54777362025-06-19 16:38:30 +0000349 isFirstFetch: this.isInitialLoadComplete,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000350 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700351 }
352
353 /**
354 * Get all messages
355 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700356 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700357 return this.messages;
358 }
359
360 /**
361 * Get the current state
362 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000363 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700364 return this.timelineState;
365 }
366
367 /**
368 * Get the connection status
369 */
370 public getConnectionStatus(): ConnectionStatus {
371 return this.connectionStatus;
372 }
373
374 /**
375 * Get the isFirstLoad flag
376 */
377 public getIsFirstLoad(): boolean {
378 return this.isFirstLoad;
379 }
380
381 /**
banksean54777362025-06-19 16:38:30 +0000382 * Get the initial load completion status
383 */
384 public getIsInitialLoadComplete(): boolean {
385 return this.isInitialLoadComplete;
386 }
387
388 /**
389 * Get the expected message count for initial load
390 */
391 public getExpectedMessageCount(): number | null {
392 return this.expectedMessageCount;
393 }
394
395 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700396 * Add an event listener
397 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700398 public addEventListener(
399 event: DataManagerEventType,
400 callback: (...args: any[]) => void,
401 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700402 const listeners = this.eventListeners.get(event) || [];
403 listeners.push(callback);
404 this.eventListeners.set(event, listeners);
405 }
406
407 /**
408 * Remove an event listener
409 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700410 public removeEventListener(
411 event: DataManagerEventType,
412 callback: (...args: any[]) => void,
413 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700414 const listeners = this.eventListeners.get(event) || [];
415 const index = listeners.indexOf(callback);
416 if (index !== -1) {
417 listeners.splice(index, 1);
418 this.eventListeners.set(event, listeners);
419 }
420 }
421
422 /**
423 * Emit an event
424 */
425 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
426 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700427 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700428 }
429
430 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 * Update the connection status
432 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000433 private updateConnectionStatus(
434 status: ConnectionStatus,
435 message?: string,
436 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700437 if (this.connectionStatus !== status) {
438 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000439 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700440 }
441 }
442
443 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000444 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700445 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000446 public async send(message: string): Promise<boolean> {
447 // Attempt to connect if we're not already connected
448 if (
449 this.connectionStatus !== "connected" &&
450 this.connectionStatus !== "connecting"
451 ) {
452 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700453 }
454
Earl Lee2e463fb2025-04-17 11:22:22 -0700455 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000456 const response = await fetch("chat", {
457 method: "POST",
458 headers: {
459 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700460 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000461 body: JSON.stringify({ message }),
462 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700463
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000464 if (!response.ok) {
465 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700466 }
467
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000468 return true;
469 } catch (error) {
470 console.error("Error sending message:", error);
471 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700472 }
473 }
474
475 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000476 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700477 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000478 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700479 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000480 const response = await fetch("cancel", {
481 method: "POST",
482 headers: {
483 "Content-Type": "application/json",
484 },
485 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700486 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000487
488 if (!response.ok) {
489 throw new Error(`HTTP error! Status: ${response.status}`);
490 }
491
492 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700493 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000494 console.error("Error cancelling conversation:", error);
495 return false;
496 }
497 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700498
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000499 /**
500 * Cancel a specific tool call
501 */
502 public async cancelToolUse(toolCallId: string): Promise<boolean> {
503 try {
504 const response = await fetch("cancel", {
505 method: "POST",
506 headers: {
507 "Content-Type": "application/json",
508 },
509 body: JSON.stringify({
510 reason: "User cancelled tool use",
511 tool_call_id: toolCallId,
512 }),
513 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700514
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000515 if (!response.ok) {
516 throw new Error(`HTTP error! Status: ${response.status}`);
517 }
518
519 return true;
520 } catch (error) {
521 console.error("Error cancelling tool use:", error);
522 return false;
523 }
524 }
525
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000526 /**
527 * Download the conversation data
528 */
529 public downloadConversation(): void {
530 window.location.href = "download";
531 }
532
533 /**
534 * Get a suggested reprompt
535 */
536 public async getSuggestedReprompt(): Promise<string | null> {
537 try {
538 const response = await fetch("suggest-reprompt");
539 if (!response.ok) {
540 throw new Error(`HTTP error! Status: ${response.status}`);
541 }
542 const data = await response.json();
543 return data.prompt;
544 } catch (error) {
545 console.error("Error getting suggested reprompt:", error);
546 return null;
547 }
548 }
549
550 /**
551 * Get description for a commit
552 */
553 public async getCommitDescription(revision: string): Promise<string | null> {
554 try {
555 const response = await fetch(
556 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700557 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000558 if (!response.ok) {
559 throw new Error(`HTTP error! Status: ${response.status}`);
560 }
561 const data = await response.json();
562 return data.description;
563 } catch (error) {
564 console.error("Error getting commit description:", error);
565 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700566 }
567 }
bankseanc67d7bc2025-07-23 10:59:02 -0700568
569 /**
570 * Check if this session has ended (no more updates will come)
571 */
572 public get sessionEnded(): boolean {
573 return this.isSessionEnded;
574 }
575
576 /**
577 * Check if the current user can send messages (write access)
578 */
579 public get canSendMessages(): boolean {
580 return this.userCanSendMessages;
581 }
582
583 /**
584 * Check if this is effectively read-only (either ended or no write permission)
585 * @deprecated Use sessionEnded and canSendMessages instead for more precise control
586 */
587 public get readOnlyMode(): boolean {
588 return !this.userCanSendMessages;
589 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700590}