File size: 2,661 Bytes
94193b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// lib/server-generate/sse-client.ts

type SSEEventHandler = (event: string, data: Record<string, unknown>) => void;

interface SSEClientOptions {
  onEvent: SSEEventHandler;
  onConnect?: () => void;
  onDisconnect?: () => void;
  onSyncGap?: (projectId: string) => void;
}

export class SSEClient {
  private eventSource: EventSource | null = null;
  private lastEventId = '0';
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private disposed = false;

  constructor(private readonly options: SSEClientOptions) {}

  connect(): void {
    if (this.disposed) return;

    const url = new URL('/api/server-generate/events', window.location.origin);
    if (this.lastEventId !== '0') {
      url.searchParams.set('lastEventId', this.lastEventId);
    }
    this.eventSource = new EventSource(url.toString());

    this.eventSource.onopen = () => {
      this.options.onConnect?.();
    };

    this.eventSource.onerror = () => {
      this.eventSource?.close();
      this.eventSource = null;
      this.options.onDisconnect?.();
      if (!this.disposed) {
        this.reconnectTimer = setTimeout(() => this.connect(), 2000);
      }
    };

    const eventTypes = [
      // Chat panel rendering events
      'assistant_delta', 'reasoning_delta', 'reasoning_start', 'reasoning_complete',
      'toolCalls', 'tool_status', 'tool_param_delta', 'tool_result', 'tool_healed',
      'conversation_message', 'conversation_replaced', 'waiting', 'iteration', 'progress',
      'error', 'error_paused', 'stopped', 'compaction', 'agent_progress',
      'usage', 'skill_evaluation', 'checkpoint_created', 'exit_reason',
      // Server generation lifecycle
      'files_changed', 'build_requested', 'usage_update',
      'task_complete', 'sync_gap', 'notification', 'runtimeChanged',
    ];

    for (const eventType of eventTypes) {
      this.eventSource.addEventListener(eventType, (e: MessageEvent) => {
        if (e.lastEventId) {
          this.lastEventId = e.lastEventId;
        }
        try {
          const data = JSON.parse(e.data);
          if (eventType === 'sync_gap') {
            this.options.onSyncGap?.(data.sourceProjectId);
          } else {
            this.options.onEvent(eventType, data);
          }
        } catch {
          // Malformed event data — skip
        }
      });
    }
  }

  disconnect(): void {
    this.disposed = true;
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
    this.eventSource?.close();
    this.eventSource = null;
  }

  isConnected(): boolean {
    return this.eventSource?.readyState === EventSource.OPEN;
  }
}