blob: 247a9cb4ceefbe23181c3cdf040c7fac6e1871d4 [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"
10 | "sessionEnded";
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
bankseanc67d7bc2025-07-23 10:59:02 -070049 // Session state tracking
50 private isSessionEnded: boolean = false;
51 private userCanSendMessages: boolean = true; // User permission to send messages
52
Earl Lee2e463fb2025-04-17 11:22:22 -070053 constructor() {
54 // Initialize empty arrays for each event type
Sean McCullough71941bd2025-04-18 13:31:48 -070055 this.eventListeners.set("dataChanged", []);
56 this.eventListeners.set("connectionStatusChanged", []);
banksean54777362025-06-19 16:38:30 +000057 this.eventListeners.set("initialLoadComplete", []);
bankseanc67d7bc2025-07-23 10:59:02 -070058 this.eventListeners.set("sessionEnded", []);
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000059
60 // Check connection status periodically
61 setInterval(() => this.checkConnectionStatus(), 5000);
Earl Lee2e463fb2025-04-17 11:22:22 -070062 }
63
64 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000065 * Initialize the data manager and connect to the SSE stream
Earl Lee2e463fb2025-04-17 11:22:22 -070066 */
67 public async initialize(): Promise<void> {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000068 // Connect to the SSE stream
69 this.connect();
70 }
71
72 /**
73 * Connect to the SSE stream
74 */
75 private connect(): void {
banksean7fe76972025-07-23 19:19:01 -070076 if (this.isSessionEnded) {
77 console.log("Skipping connection attempt - session has ended");
78 return;
79 }
80
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000081 // If we're already connecting or connected, don't start another connection attempt
82 if (
83 this.eventSource &&
84 (this.connectionStatus === "connecting" ||
85 this.connectionStatus === "connected")
86 ) {
87 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070088 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000089
90 // Close any existing connection
91 this.closeEventSource();
92
banksean54777362025-06-19 16:38:30 +000093 // Reset initial load state for new connection
94 this.expectedMessageCount = null;
95 this.isInitialLoadComplete = false;
96
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000097 // Update connection status to connecting
98 this.updateConnectionStatus("connecting", "Connecting...");
99
100 // Determine the starting point for the stream based on what we already have
101 const fromIndex =
102 this.messages.length > 0
103 ? this.messages[this.messages.length - 1].idx + 1
104 : 0;
105
106 // Create a new EventSource connection
107 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
108
109 // Set up event handlers
110 this.eventSource.addEventListener("open", () => {
111 console.log("SSE stream opened");
112 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
113 this.updateConnectionStatus("connected");
114 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
115 });
116
117 this.eventSource.addEventListener("error", (event) => {
118 console.error("SSE stream error:", event);
119 this.closeEventSource();
120 this.updateConnectionStatus("disconnected", "Connection lost");
121 this.scheduleReconnect();
122 });
123
124 // Handle incoming messages
125 this.eventSource.addEventListener("message", (event) => {
126 const message = JSON.parse(event.data) as AgentMessage;
127 this.processNewMessage(message);
128 });
129
130 // Handle state updates
131 this.eventSource.addEventListener("state", (event) => {
132 const state = JSON.parse(event.data) as State;
133 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000134
bankseanc67d7bc2025-07-23 10:59:02 -0700135 // Check session state and user permissions from server
136 const stateData = state;
137 if (stateData.session_ended === true) {
138 this.isSessionEnded = true;
139 this.userCanSendMessages = false;
140 console.log("Detected ended session from state event");
141 } else if (stateData.can_send_messages === false) {
142 // Session is active but user has read-only access
143 this.userCanSendMessages = false;
144 console.log("Detected read-only access to active session");
145 }
146
banksean54777362025-06-19 16:38:30 +0000147 // Store expected message count for initial load detection
148 if (this.expectedMessageCount === null) {
149 this.expectedMessageCount = state.message_count;
150 console.log(
151 `Initial load expects ${this.expectedMessageCount} messages`,
152 );
153
154 // Handle empty conversation case - immediately mark as complete
155 if (this.expectedMessageCount === 0) {
156 this.isInitialLoadComplete = true;
157 console.log(`Initial load complete: Empty conversation (0 messages)`);
158 this.emitEvent("initialLoadComplete", {
159 messageCount: 0,
160 expectedCount: 0,
161 });
162 }
163 }
164
bankseanc67d7bc2025-07-23 10:59:02 -0700165 // Update connection status when we receive state
166 if (this.connectionStatus !== "connected" && !this.isSessionEnded) {
167 this.updateConnectionStatus("connected");
168 }
169
banksean54777362025-06-19 16:38:30 +0000170 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000171 this.emitEvent("dataChanged", { state, newMessages: [] });
172 });
173
174 // Handle heartbeats
175 this.eventSource.addEventListener("heartbeat", () => {
176 this.lastHeartbeatTime = Date.now();
177 // Make sure connection status is updated if it wasn't already
178 if (this.connectionStatus !== "connected") {
179 this.updateConnectionStatus("connected");
180 }
181 });
bankseanc67d7bc2025-07-23 10:59:02 -0700182
183 // Handle session ended events for inactive sessions
184 this.eventSource.addEventListener("session_ended", (event) => {
185 const data = JSON.parse(event.data);
186 console.log("Session ended:", data.message);
187
188 this.isSessionEnded = true;
189 this.userCanSendMessages = false;
190 this.isInitialLoadComplete = true;
191
192 // Close the connection since no more data will come
193 this.closeEventSource();
194
195 // Clear any pending reconnection attempts
196 if (this.reconnectTimer !== null) {
197 window.clearTimeout(this.reconnectTimer);
198 this.reconnectTimer = null;
199 }
200 this.reconnectAttempt = 0;
201
202 // Update status to indicate session has ended
203 this.updateConnectionStatus("disabled", "Session ended");
204
205 // Notify listeners about the state change
206 this.emitEvent("sessionEnded", data);
207 this.emitEvent("dataChanged", {
208 state: this.timelineState,
209 newMessages: [],
210 });
211 this.emitEvent("initialLoadComplete", {
212 messageCount: this.messages.length,
213 expectedCount: this.messages.length,
214 });
215 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000216 }
217
218 /**
219 * Close the current EventSource connection
220 */
221 private closeEventSource(): void {
222 if (this.eventSource) {
223 this.eventSource.close();
224 this.eventSource = null;
225 }
226 }
227
228 /**
229 * Schedule a reconnection attempt with exponential backoff
230 */
231 private scheduleReconnect(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700232 // Don't schedule reconnections for ended sessions
233 if (this.isSessionEnded) {
234 console.log("Skipping reconnection attempt - session has ended");
235 return;
236 }
237
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000238 if (this.reconnectTimer !== null) {
239 window.clearTimeout(this.reconnectTimer);
240 this.reconnectTimer = null;
241 }
242
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700243 const delayIndex = Math.min(
244 this.reconnectAttempt,
245 this.reconnectDelaysMs.length - 1,
246 );
247 let delay = this.reconnectDelaysMs[delayIndex];
248 // Add jitter: +/- 10% of the delay
249 delay *= 0.9 + Math.random() * 0.2;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000250
251 console.log(
252 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
253 );
254
255 // Increment reconnect attempt counter
256 this.reconnectAttempt++;
257
258 // Schedule the reconnect
259 this.reconnectTimer = window.setTimeout(() => {
260 this.reconnectTimer = null;
261 this.connect();
262 }, delay);
263 }
264
265 /**
266 * Check heartbeat status to determine if connection is still active
267 */
268 private checkConnectionStatus(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700269 if (this.connectionStatus !== "connected" || this.isSessionEnded) {
270 return; // Only check if we think we're connected and session hasn't ended
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000271 }
272
273 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
274 if (timeSinceLastHeartbeat > 90000) {
275 // 90 seconds without heartbeat
276 console.warn(
277 "No heartbeat received in 90 seconds, connection appears to be lost",
278 );
279 this.closeEventSource();
280 this.updateConnectionStatus(
281 "disconnected",
282 "Connection timed out (no heartbeat)",
283 );
284 this.scheduleReconnect();
285 }
286 }
287
288 /**
banksean54777362025-06-19 16:38:30 +0000289 * Check if initial load is complete based on expected message count
290 */
291 private checkInitialLoadComplete(): void {
292 if (
293 this.expectedMessageCount !== null &&
294 this.expectedMessageCount > 0 &&
295 this.messages.length >= this.expectedMessageCount &&
296 !this.isInitialLoadComplete
297 ) {
298 this.isInitialLoadComplete = true;
299 console.log(
300 `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
301 );
302
303 this.emitEvent("initialLoadComplete", {
304 messageCount: this.messages.length,
305 expectedCount: this.expectedMessageCount,
306 });
307 }
308 }
309
310 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000311 * Process a new message from the SSE stream
312 */
313 private processNewMessage(message: AgentMessage): void {
314 // Find the message's position in the array
315 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
316
317 if (existingIndex >= 0) {
318 // This shouldn't happen - we should never receive duplicates
319 console.error(
320 `Received duplicate message with idx ${message.idx}`,
321 message,
322 );
323 return;
324 } else {
325 // Add the new message to our array
326 this.messages.push(message);
327 // Sort messages by idx to ensure they're in the correct order
328 this.messages.sort((a, b) => a.idx - b.idx);
329 }
330
331 // Mark that we've completed first load
332 if (this.isFirstLoad) {
333 this.isFirstLoad = false;
334 }
335
banksean54777362025-06-19 16:38:30 +0000336 // Check if initial load is now complete
337 this.checkInitialLoadComplete();
338
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000339 // Emit an event that data has changed
340 this.emitEvent("dataChanged", {
341 state: this.timelineState,
342 newMessages: [message],
banksean54777362025-06-19 16:38:30 +0000343 isFirstFetch: this.isInitialLoadComplete,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000344 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700345 }
346
347 /**
348 * Get all messages
349 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700350 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700351 return this.messages;
352 }
353
354 /**
355 * Get the current state
356 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000357 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700358 return this.timelineState;
359 }
360
361 /**
362 * Get the connection status
363 */
364 public getConnectionStatus(): ConnectionStatus {
365 return this.connectionStatus;
366 }
367
368 /**
369 * Get the isFirstLoad flag
370 */
371 public getIsFirstLoad(): boolean {
372 return this.isFirstLoad;
373 }
374
375 /**
banksean54777362025-06-19 16:38:30 +0000376 * Get the initial load completion status
377 */
378 public getIsInitialLoadComplete(): boolean {
379 return this.isInitialLoadComplete;
380 }
381
382 /**
383 * Get the expected message count for initial load
384 */
385 public getExpectedMessageCount(): number | null {
386 return this.expectedMessageCount;
387 }
388
389 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700390 * Add an event listener
391 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700392 public addEventListener(
393 event: DataManagerEventType,
394 callback: (...args: any[]) => void,
395 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700396 const listeners = this.eventListeners.get(event) || [];
397 listeners.push(callback);
398 this.eventListeners.set(event, listeners);
399 }
400
401 /**
402 * Remove an event listener
403 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700404 public removeEventListener(
405 event: DataManagerEventType,
406 callback: (...args: any[]) => void,
407 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700408 const listeners = this.eventListeners.get(event) || [];
409 const index = listeners.indexOf(callback);
410 if (index !== -1) {
411 listeners.splice(index, 1);
412 this.eventListeners.set(event, listeners);
413 }
414 }
415
416 /**
417 * Emit an event
418 */
419 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
420 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700421 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700422 }
423
424 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700425 * Update the connection status
426 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000427 private updateConnectionStatus(
428 status: ConnectionStatus,
429 message?: string,
430 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 if (this.connectionStatus !== status) {
432 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000433 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700434 }
435 }
436
437 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000438 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700439 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000440 public async send(message: string): Promise<boolean> {
441 // Attempt to connect if we're not already connected
442 if (
443 this.connectionStatus !== "connected" &&
444 this.connectionStatus !== "connecting"
445 ) {
446 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700447 }
448
Earl Lee2e463fb2025-04-17 11:22:22 -0700449 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000450 const response = await fetch("chat", {
451 method: "POST",
452 headers: {
453 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700454 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000455 body: JSON.stringify({ message }),
456 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700457
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000458 if (!response.ok) {
459 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700460 }
461
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000462 return true;
463 } catch (error) {
464 console.error("Error sending message:", error);
465 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700466 }
467 }
468
469 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000470 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700471 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000472 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700473 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000474 const response = await fetch("cancel", {
475 method: "POST",
476 headers: {
477 "Content-Type": "application/json",
478 },
479 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700480 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000481
482 if (!response.ok) {
483 throw new Error(`HTTP error! Status: ${response.status}`);
484 }
485
486 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700487 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000488 console.error("Error cancelling conversation:", error);
489 return false;
490 }
491 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700492
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000493 /**
494 * Cancel a specific tool call
495 */
496 public async cancelToolUse(toolCallId: string): Promise<boolean> {
497 try {
498 const response = await fetch("cancel", {
499 method: "POST",
500 headers: {
501 "Content-Type": "application/json",
502 },
503 body: JSON.stringify({
504 reason: "User cancelled tool use",
505 tool_call_id: toolCallId,
506 }),
507 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700508
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000509 if (!response.ok) {
510 throw new Error(`HTTP error! Status: ${response.status}`);
511 }
512
513 return true;
514 } catch (error) {
515 console.error("Error cancelling tool use:", error);
516 return false;
517 }
518 }
519
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000520 /**
521 * Download the conversation data
522 */
523 public downloadConversation(): void {
524 window.location.href = "download";
525 }
526
527 /**
528 * Get a suggested reprompt
529 */
530 public async getSuggestedReprompt(): Promise<string | null> {
531 try {
532 const response = await fetch("suggest-reprompt");
533 if (!response.ok) {
534 throw new Error(`HTTP error! Status: ${response.status}`);
535 }
536 const data = await response.json();
537 return data.prompt;
538 } catch (error) {
539 console.error("Error getting suggested reprompt:", error);
540 return null;
541 }
542 }
543
544 /**
545 * Get description for a commit
546 */
547 public async getCommitDescription(revision: string): Promise<string | null> {
548 try {
549 const response = await fetch(
550 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700551 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000552 if (!response.ok) {
553 throw new Error(`HTTP error! Status: ${response.status}`);
554 }
555 const data = await response.json();
556 return data.description;
557 } catch (error) {
558 console.error("Error getting commit description:", error);
559 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700560 }
561 }
bankseanc67d7bc2025-07-23 10:59:02 -0700562
563 /**
564 * Check if this session has ended (no more updates will come)
565 */
566 public get sessionEnded(): boolean {
567 return this.isSessionEnded;
568 }
569
570 /**
571 * Check if the current user can send messages (write access)
572 */
573 public get canSendMessages(): boolean {
574 return this.userCanSendMessages;
575 }
576
577 /**
578 * Check if this is effectively read-only (either ended or no write permission)
579 * @deprecated Use sessionEnded and canSendMessages instead for more precise control
580 */
581 public get readOnlyMode(): boolean {
582 return !this.userCanSendMessages;
583 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700584}