blob: 1340dccbef63cb4daee58fe7646bbb01d387b57f [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 {
76 // If we're already connecting or connected, don't start another connection attempt
77 if (
78 this.eventSource &&
79 (this.connectionStatus === "connecting" ||
80 this.connectionStatus === "connected")
81 ) {
82 return;
Earl Lee2e463fb2025-04-17 11:22:22 -070083 }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000084
85 // Close any existing connection
86 this.closeEventSource();
87
banksean54777362025-06-19 16:38:30 +000088 // Reset initial load state for new connection
89 this.expectedMessageCount = null;
90 this.isInitialLoadComplete = false;
91
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000092 // Update connection status to connecting
93 this.updateConnectionStatus("connecting", "Connecting...");
94
95 // Determine the starting point for the stream based on what we already have
96 const fromIndex =
97 this.messages.length > 0
98 ? this.messages[this.messages.length - 1].idx + 1
99 : 0;
100
101 // Create a new EventSource connection
102 this.eventSource = new EventSource(`stream?from=${fromIndex}`);
103
104 // Set up event handlers
105 this.eventSource.addEventListener("open", () => {
106 console.log("SSE stream opened");
107 this.reconnectAttempt = 0; // Reset reconnect attempt counter on successful connection
108 this.updateConnectionStatus("connected");
109 this.lastHeartbeatTime = Date.now(); // Set initial heartbeat time
110 });
111
112 this.eventSource.addEventListener("error", (event) => {
113 console.error("SSE stream error:", event);
114 this.closeEventSource();
115 this.updateConnectionStatus("disconnected", "Connection lost");
116 this.scheduleReconnect();
117 });
118
119 // Handle incoming messages
120 this.eventSource.addEventListener("message", (event) => {
121 const message = JSON.parse(event.data) as AgentMessage;
122 this.processNewMessage(message);
123 });
124
125 // Handle state updates
126 this.eventSource.addEventListener("state", (event) => {
127 const state = JSON.parse(event.data) as State;
128 this.timelineState = state;
banksean54777362025-06-19 16:38:30 +0000129
bankseanc67d7bc2025-07-23 10:59:02 -0700130 // Check session state and user permissions from server
131 const stateData = state;
132 if (stateData.session_ended === true) {
133 this.isSessionEnded = true;
134 this.userCanSendMessages = false;
135 console.log("Detected ended session from state event");
136 } else if (stateData.can_send_messages === false) {
137 // Session is active but user has read-only access
138 this.userCanSendMessages = false;
139 console.log("Detected read-only access to active session");
140 }
141
banksean54777362025-06-19 16:38:30 +0000142 // Store expected message count for initial load detection
143 if (this.expectedMessageCount === null) {
144 this.expectedMessageCount = state.message_count;
145 console.log(
146 `Initial load expects ${this.expectedMessageCount} messages`,
147 );
148
149 // Handle empty conversation case - immediately mark as complete
150 if (this.expectedMessageCount === 0) {
151 this.isInitialLoadComplete = true;
152 console.log(`Initial load complete: Empty conversation (0 messages)`);
153 this.emitEvent("initialLoadComplete", {
154 messageCount: 0,
155 expectedCount: 0,
156 });
157 }
158 }
159
bankseanc67d7bc2025-07-23 10:59:02 -0700160 // Update connection status when we receive state
161 if (this.connectionStatus !== "connected" && !this.isSessionEnded) {
162 this.updateConnectionStatus("connected");
163 }
164
banksean54777362025-06-19 16:38:30 +0000165 this.checkInitialLoadComplete();
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000166 this.emitEvent("dataChanged", { state, newMessages: [] });
167 });
168
169 // Handle heartbeats
170 this.eventSource.addEventListener("heartbeat", () => {
171 this.lastHeartbeatTime = Date.now();
172 // Make sure connection status is updated if it wasn't already
173 if (this.connectionStatus !== "connected") {
174 this.updateConnectionStatus("connected");
175 }
176 });
bankseanc67d7bc2025-07-23 10:59:02 -0700177
178 // Handle session ended events for inactive sessions
179 this.eventSource.addEventListener("session_ended", (event) => {
180 const data = JSON.parse(event.data);
181 console.log("Session ended:", data.message);
182
183 this.isSessionEnded = true;
184 this.userCanSendMessages = false;
185 this.isInitialLoadComplete = true;
186
187 // Close the connection since no more data will come
188 this.closeEventSource();
189
190 // Clear any pending reconnection attempts
191 if (this.reconnectTimer !== null) {
192 window.clearTimeout(this.reconnectTimer);
193 this.reconnectTimer = null;
194 }
195 this.reconnectAttempt = 0;
196
197 // Update status to indicate session has ended
198 this.updateConnectionStatus("disabled", "Session ended");
199
200 // Notify listeners about the state change
201 this.emitEvent("sessionEnded", data);
202 this.emitEvent("dataChanged", {
203 state: this.timelineState,
204 newMessages: [],
205 });
206 this.emitEvent("initialLoadComplete", {
207 messageCount: this.messages.length,
208 expectedCount: this.messages.length,
209 });
210 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000211 }
212
213 /**
214 * Close the current EventSource connection
215 */
216 private closeEventSource(): void {
217 if (this.eventSource) {
218 this.eventSource.close();
219 this.eventSource = null;
220 }
221 }
222
223 /**
224 * Schedule a reconnection attempt with exponential backoff
225 */
226 private scheduleReconnect(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700227 // Don't schedule reconnections for ended sessions
228 if (this.isSessionEnded) {
229 console.log("Skipping reconnection attempt - session has ended");
230 return;
231 }
232
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000233 if (this.reconnectTimer !== null) {
234 window.clearTimeout(this.reconnectTimer);
235 this.reconnectTimer = null;
236 }
237
Josh Bleecher Snyder15a0ffa2025-07-21 15:53:48 -0700238 const delayIndex = Math.min(
239 this.reconnectAttempt,
240 this.reconnectDelaysMs.length - 1,
241 );
242 let delay = this.reconnectDelaysMs[delayIndex];
243 // Add jitter: +/- 10% of the delay
244 delay *= 0.9 + Math.random() * 0.2;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000245
246 console.log(
247 `Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt + 1})`,
248 );
249
250 // Increment reconnect attempt counter
251 this.reconnectAttempt++;
252
253 // Schedule the reconnect
254 this.reconnectTimer = window.setTimeout(() => {
255 this.reconnectTimer = null;
256 this.connect();
257 }, delay);
258 }
259
260 /**
261 * Check heartbeat status to determine if connection is still active
262 */
263 private checkConnectionStatus(): void {
bankseanc67d7bc2025-07-23 10:59:02 -0700264 if (this.connectionStatus !== "connected" || this.isSessionEnded) {
265 return; // Only check if we think we're connected and session hasn't ended
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000266 }
267
268 const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeatTime;
269 if (timeSinceLastHeartbeat > 90000) {
270 // 90 seconds without heartbeat
271 console.warn(
272 "No heartbeat received in 90 seconds, connection appears to be lost",
273 );
274 this.closeEventSource();
275 this.updateConnectionStatus(
276 "disconnected",
277 "Connection timed out (no heartbeat)",
278 );
279 this.scheduleReconnect();
280 }
281 }
282
283 /**
banksean54777362025-06-19 16:38:30 +0000284 * Check if initial load is complete based on expected message count
285 */
286 private checkInitialLoadComplete(): void {
287 if (
288 this.expectedMessageCount !== null &&
289 this.expectedMessageCount > 0 &&
290 this.messages.length >= this.expectedMessageCount &&
291 !this.isInitialLoadComplete
292 ) {
293 this.isInitialLoadComplete = true;
294 console.log(
295 `Initial load complete: ${this.messages.length}/${this.expectedMessageCount} messages loaded`,
296 );
297
298 this.emitEvent("initialLoadComplete", {
299 messageCount: this.messages.length,
300 expectedCount: this.expectedMessageCount,
301 });
302 }
303 }
304
305 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000306 * Process a new message from the SSE stream
307 */
308 private processNewMessage(message: AgentMessage): void {
309 // Find the message's position in the array
310 const existingIndex = this.messages.findIndex((m) => m.idx === message.idx);
311
312 if (existingIndex >= 0) {
313 // This shouldn't happen - we should never receive duplicates
314 console.error(
315 `Received duplicate message with idx ${message.idx}`,
316 message,
317 );
318 return;
319 } else {
320 // Add the new message to our array
321 this.messages.push(message);
322 // Sort messages by idx to ensure they're in the correct order
323 this.messages.sort((a, b) => a.idx - b.idx);
324 }
325
326 // Mark that we've completed first load
327 if (this.isFirstLoad) {
328 this.isFirstLoad = false;
329 }
330
banksean54777362025-06-19 16:38:30 +0000331 // Check if initial load is now complete
332 this.checkInitialLoadComplete();
333
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000334 // Emit an event that data has changed
335 this.emitEvent("dataChanged", {
336 state: this.timelineState,
337 newMessages: [message],
banksean54777362025-06-19 16:38:30 +0000338 isFirstFetch: this.isInitialLoadComplete,
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000339 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700340 }
341
342 /**
343 * Get all messages
344 */
Sean McCulloughd9f13372025-04-21 15:08:49 -0700345 public getMessages(): AgentMessage[] {
Earl Lee2e463fb2025-04-17 11:22:22 -0700346 return this.messages;
347 }
348
349 /**
350 * Get the current state
351 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000352 public getState(): State | null {
Earl Lee2e463fb2025-04-17 11:22:22 -0700353 return this.timelineState;
354 }
355
356 /**
357 * Get the connection status
358 */
359 public getConnectionStatus(): ConnectionStatus {
360 return this.connectionStatus;
361 }
362
363 /**
364 * Get the isFirstLoad flag
365 */
366 public getIsFirstLoad(): boolean {
367 return this.isFirstLoad;
368 }
369
370 /**
banksean54777362025-06-19 16:38:30 +0000371 * Get the initial load completion status
372 */
373 public getIsInitialLoadComplete(): boolean {
374 return this.isInitialLoadComplete;
375 }
376
377 /**
378 * Get the expected message count for initial load
379 */
380 public getExpectedMessageCount(): number | null {
381 return this.expectedMessageCount;
382 }
383
384 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700385 * Add an event listener
386 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700387 public addEventListener(
388 event: DataManagerEventType,
389 callback: (...args: any[]) => void,
390 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700391 const listeners = this.eventListeners.get(event) || [];
392 listeners.push(callback);
393 this.eventListeners.set(event, listeners);
394 }
395
396 /**
397 * Remove an event listener
398 */
Sean McCullough71941bd2025-04-18 13:31:48 -0700399 public removeEventListener(
400 event: DataManagerEventType,
401 callback: (...args: any[]) => void,
402 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700403 const listeners = this.eventListeners.get(event) || [];
404 const index = listeners.indexOf(callback);
405 if (index !== -1) {
406 listeners.splice(index, 1);
407 this.eventListeners.set(event, listeners);
408 }
409 }
410
411 /**
412 * Emit an event
413 */
414 private emitEvent(event: DataManagerEventType, ...args: any[]): void {
415 const listeners = this.eventListeners.get(event) || [];
Sean McCullough71941bd2025-04-18 13:31:48 -0700416 listeners.forEach((callback) => callback(...args));
Earl Lee2e463fb2025-04-17 11:22:22 -0700417 }
418
419 /**
Earl Lee2e463fb2025-04-17 11:22:22 -0700420 * Update the connection status
421 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000422 private updateConnectionStatus(
423 status: ConnectionStatus,
424 message?: string,
425 ): void {
Earl Lee2e463fb2025-04-17 11:22:22 -0700426 if (this.connectionStatus !== status) {
427 this.connectionStatus = status;
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000428 this.emitEvent("connectionStatusChanged", status, message || "");
Earl Lee2e463fb2025-04-17 11:22:22 -0700429 }
430 }
431
432 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000433 * Send a message to the agent
Earl Lee2e463fb2025-04-17 11:22:22 -0700434 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000435 public async send(message: string): Promise<boolean> {
436 // Attempt to connect if we're not already connected
437 if (
438 this.connectionStatus !== "connected" &&
439 this.connectionStatus !== "connecting"
440 ) {
441 this.connect();
Earl Lee2e463fb2025-04-17 11:22:22 -0700442 }
443
Earl Lee2e463fb2025-04-17 11:22:22 -0700444 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000445 const response = await fetch("chat", {
446 method: "POST",
447 headers: {
448 "Content-Type": "application/json",
Earl Lee2e463fb2025-04-17 11:22:22 -0700449 },
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000450 body: JSON.stringify({ message }),
451 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700452
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000453 if (!response.ok) {
454 throw new Error(`HTTP error! Status: ${response.status}`);
Earl Lee2e463fb2025-04-17 11:22:22 -0700455 }
456
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000457 return true;
458 } catch (error) {
459 console.error("Error sending message:", error);
460 return false;
Earl Lee2e463fb2025-04-17 11:22:22 -0700461 }
462 }
463
464 /**
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000465 * Cancel the current conversation
Earl Lee2e463fb2025-04-17 11:22:22 -0700466 */
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000467 public async cancel(): Promise<boolean> {
Earl Lee2e463fb2025-04-17 11:22:22 -0700468 try {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000469 const response = await fetch("cancel", {
470 method: "POST",
471 headers: {
472 "Content-Type": "application/json",
473 },
474 body: JSON.stringify({ reason: "User cancelled" }),
Sean McCullough71941bd2025-04-18 13:31:48 -0700475 });
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000476
477 if (!response.ok) {
478 throw new Error(`HTTP error! Status: ${response.status}`);
479 }
480
481 return true;
Earl Lee2e463fb2025-04-17 11:22:22 -0700482 } catch (error) {
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000483 console.error("Error cancelling conversation:", error);
484 return false;
485 }
486 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700487
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000488 /**
489 * Cancel a specific tool call
490 */
491 public async cancelToolUse(toolCallId: string): Promise<boolean> {
492 try {
493 const response = await fetch("cancel", {
494 method: "POST",
495 headers: {
496 "Content-Type": "application/json",
497 },
498 body: JSON.stringify({
499 reason: "User cancelled tool use",
500 tool_call_id: toolCallId,
501 }),
502 });
Earl Lee2e463fb2025-04-17 11:22:22 -0700503
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000504 if (!response.ok) {
505 throw new Error(`HTTP error! Status: ${response.status}`);
506 }
507
508 return true;
509 } catch (error) {
510 console.error("Error cancelling tool use:", error);
511 return false;
512 }
513 }
514
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000515 /**
516 * Download the conversation data
517 */
518 public downloadConversation(): void {
519 window.location.href = "download";
520 }
521
522 /**
523 * Get a suggested reprompt
524 */
525 public async getSuggestedReprompt(): Promise<string | null> {
526 try {
527 const response = await fetch("suggest-reprompt");
528 if (!response.ok) {
529 throw new Error(`HTTP error! Status: ${response.status}`);
530 }
531 const data = await response.json();
532 return data.prompt;
533 } catch (error) {
534 console.error("Error getting suggested reprompt:", error);
535 return null;
536 }
537 }
538
539 /**
540 * Get description for a commit
541 */
542 public async getCommitDescription(revision: string): Promise<string | null> {
543 try {
544 const response = await fetch(
545 `commit-description?revision=${encodeURIComponent(revision)}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700546 );
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000547 if (!response.ok) {
548 throw new Error(`HTTP error! Status: ${response.status}`);
549 }
550 const data = await response.json();
551 return data.description;
552 } catch (error) {
553 console.error("Error getting commit description:", error);
554 return null;
Earl Lee2e463fb2025-04-17 11:22:22 -0700555 }
556 }
bankseanc67d7bc2025-07-23 10:59:02 -0700557
558 /**
559 * Check if this session has ended (no more updates will come)
560 */
561 public get sessionEnded(): boolean {
562 return this.isSessionEnded;
563 }
564
565 /**
566 * Check if the current user can send messages (write access)
567 */
568 public get canSendMessages(): boolean {
569 return this.userCanSendMessages;
570 }
571
572 /**
573 * Check if this is effectively read-only (either ended or no write permission)
574 * @deprecated Use sessionEnded and canSendMessages instead for more precise control
575 */
576 public get readOnlyMode(): boolean {
577 return !this.userCanSendMessages;
578 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700579}