Initial commit
diff --git a/loop/webui/src/timeline/data.ts b/loop/webui/src/timeline/data.ts
new file mode 100644
index 0000000..2130c21
--- /dev/null
+++ b/loop/webui/src/timeline/data.ts
@@ -0,0 +1,379 @@
+import { TimelineMessage } from "./types";
+import { formatNumber } from "./utils";
+
+/**
+ * Event types for data manager
+ */
+export type DataManagerEventType = 'dataChanged' | 'connectionStatusChanged';
+
+/**
+ * Connection status types
+ */
+export type ConnectionStatus = 'connected' | 'disconnected' | 'disabled';
+
+/**
+ * State interface
+ */
+export interface TimelineState {
+ hostname?: string;
+ working_dir?: string;
+ initial_commit?: string;
+ message_count?: number;
+ title?: string;
+ total_usage?: {
+ input_tokens: number;
+ output_tokens: number;
+ cache_read_input_tokens: number;
+ cache_creation_input_tokens: number;
+ total_cost_usd: number;
+ };
+}
+
+/**
+ * DataManager - Class to manage timeline data, fetching, and polling
+ */
+export class DataManager {
+ // State variables
+ private lastMessageCount: number = 0;
+ private nextFetchIndex: number = 0;
+ private currentFetchStartIndex: number = 0;
+ private currentPollController: AbortController | null = null;
+ private isFetchingMessages: boolean = false;
+ private isPollingEnabled: boolean = true;
+ private isFirstLoad: boolean = true;
+ private connectionStatus: ConnectionStatus = "disabled";
+ private messages: TimelineMessage[] = [];
+ private timelineState: TimelineState | null = null;
+
+ // Event listeners
+ private eventListeners: Map<DataManagerEventType, Array<(...args: any[]) => void>> = new Map();
+
+ constructor() {
+ // Initialize empty arrays for each event type
+ this.eventListeners.set('dataChanged', []);
+ this.eventListeners.set('connectionStatusChanged', []);
+ }
+
+ /**
+ * Initialize the data manager and fetch initial data
+ */
+ public async initialize(): Promise<void> {
+ try {
+ // Initial data fetch
+ await this.fetchData();
+ // Start polling for updates only if initial fetch succeeds
+ this.startPolling();
+ } catch (error) {
+ console.error("Initial data fetch failed, will retry via polling", error);
+ // Still start polling to recover
+ this.startPolling();
+ }
+ }
+
+ /**
+ * Get all messages
+ */
+ public getMessages(): TimelineMessage[] {
+ return this.messages;
+ }
+
+ /**
+ * Get the current state
+ */
+ public getState(): TimelineState | null {
+ return this.timelineState;
+ }
+
+ /**
+ * Get the connection status
+ */
+ public getConnectionStatus(): ConnectionStatus {
+ return this.connectionStatus;
+ }
+
+ /**
+ * Get the isFirstLoad flag
+ */
+ public getIsFirstLoad(): boolean {
+ return this.isFirstLoad;
+ }
+
+ /**
+ * Get the currentFetchStartIndex
+ */
+ public getCurrentFetchStartIndex(): number {
+ return this.currentFetchStartIndex;
+ }
+
+ /**
+ * Add an event listener
+ */
+ public addEventListener(event: DataManagerEventType, callback: (...args: any[]) => void): void {
+ const listeners = this.eventListeners.get(event) || [];
+ listeners.push(callback);
+ this.eventListeners.set(event, listeners);
+ }
+
+ /**
+ * Remove an event listener
+ */
+ public removeEventListener(event: DataManagerEventType, callback: (...args: any[]) => void): void {
+ const listeners = this.eventListeners.get(event) || [];
+ const index = listeners.indexOf(callback);
+ if (index !== -1) {
+ listeners.splice(index, 1);
+ this.eventListeners.set(event, listeners);
+ }
+ }
+
+ /**
+ * Emit an event
+ */
+ private emitEvent(event: DataManagerEventType, ...args: any[]): void {
+ const listeners = this.eventListeners.get(event) || [];
+ listeners.forEach(callback => callback(...args));
+ }
+
+ /**
+ * Set polling enabled/disabled state
+ */
+ public setPollingEnabled(enabled: boolean): void {
+ this.isPollingEnabled = enabled;
+
+ if (enabled) {
+ this.startPolling();
+ } else {
+ this.stopPolling();
+ }
+ }
+
+ /**
+ * Start polling for updates
+ */
+ public startPolling(): void {
+ this.stopPolling(); // Stop any existing polling
+
+ // Start long polling
+ this.longPoll();
+ }
+
+ /**
+ * Stop polling for updates
+ */
+ public stopPolling(): void {
+ // Abort any ongoing long poll request
+ if (this.currentPollController) {
+ this.currentPollController.abort();
+ this.currentPollController = null;
+ }
+
+ // If polling is disabled by user, set connection status to disabled
+ if (!this.isPollingEnabled) {
+ this.updateConnectionStatus("disabled");
+ }
+ }
+
+ /**
+ * Update the connection status
+ */
+ private updateConnectionStatus(status: ConnectionStatus): void {
+ if (this.connectionStatus !== status) {
+ this.connectionStatus = status;
+ this.emitEvent('connectionStatusChanged', status);
+ }
+ }
+
+ /**
+ * Long poll for updates
+ */
+ private async longPoll(): Promise<void> {
+ // Abort any existing poll request
+ if (this.currentPollController) {
+ this.currentPollController.abort();
+ this.currentPollController = null;
+ }
+
+ // If polling is disabled, don't start a new poll
+ if (!this.isPollingEnabled) {
+ return;
+ }
+
+ let timeoutId: number | undefined;
+
+ try {
+ // Create a new abort controller for this request
+ this.currentPollController = new AbortController();
+ const signal = this.currentPollController.signal;
+
+ // Get the URL with the current message count
+ const pollUrl = `state?poll=true&seen=${this.lastMessageCount}`;
+
+ // Make the long poll request
+ // Use explicit timeout to handle stalled connections (120s)
+ const controller = new AbortController();
+ timeoutId = window.setTimeout(() => controller.abort(), 120000);
+
+ interface CustomFetchOptions extends RequestInit {
+ [Symbol.toStringTag]?: unknown;
+ }
+
+ const fetchOptions: CustomFetchOptions = {
+ signal: controller.signal,
+ // Use the original signal to allow manual cancellation too
+ get [Symbol.toStringTag]() {
+ if (signal.aborted) controller.abort();
+ return "";
+ },
+ };
+
+ try {
+ const response = await fetch(pollUrl, fetchOptions);
+ // Clear the timeout since we got a response
+ clearTimeout(timeoutId);
+
+ // Parse the JSON response
+ const _data = await response.json();
+
+ // If we got here, data has changed, so fetch the latest data
+ await this.fetchData();
+
+ // Start a new long poll (if polling is still enabled)
+ if (this.isPollingEnabled) {
+ this.longPoll();
+ }
+ } catch (error) {
+ // Handle fetch errors inside the inner try block
+ clearTimeout(timeoutId);
+ throw error; // Re-throw to be caught by the outer catch block
+ }
+ } catch (error: unknown) {
+ // Clean up timeout if we're handling an error
+ if (timeoutId) clearTimeout(timeoutId);
+
+ // Don't log or treat manual cancellations as errors
+ const isErrorWithName = (
+ err: unknown,
+ ): err is { name: string; message?: string } =>
+ typeof err === "object" && err !== null && "name" in err;
+
+ if (
+ isErrorWithName(error) &&
+ error.name === "AbortError" &&
+ this.currentPollController?.signal.aborted
+ ) {
+ console.log("Polling cancelled by user");
+ return;
+ }
+
+ // Handle different types of errors with specific messages
+ let errorMessage = "Not connected";
+
+ if (isErrorWithName(error)) {
+ if (error.name === "AbortError") {
+ // This was our timeout abort
+ errorMessage = "Connection timeout - not connected";
+ console.error("Long polling timeout");
+ } else if (error.name === "SyntaxError") {
+ // JSON parsing error
+ errorMessage = "Invalid response from server - not connected";
+ console.error("JSON parsing error:", error);
+ } else if (
+ error.name === "TypeError" &&
+ error.message?.includes("NetworkError")
+ ) {
+ // Network connectivity issues
+ errorMessage = "Network connection lost - not connected";
+ console.error("Network error during polling:", error);
+ } else {
+ // Generic error
+ console.error("Long polling error:", error);
+ }
+ }
+
+ // Disable polling on error
+ this.isPollingEnabled = false;
+
+ // Update connection status to disconnected
+ this.updateConnectionStatus("disconnected");
+
+ // Emit an event that we're disconnected with the error message
+ this.emitEvent('connectionStatusChanged', this.connectionStatus, errorMessage);
+ }
+ }
+
+ /**
+ * Fetch timeline data
+ */
+ public async fetchData(): Promise<void> {
+ // If we're already fetching messages, don't start another fetch
+ if (this.isFetchingMessages) {
+ console.log("Already fetching messages, skipping request");
+ return;
+ }
+
+ this.isFetchingMessages = true;
+
+ try {
+ // Fetch state first
+ const stateResponse = await fetch("state");
+ const state = await stateResponse.json();
+ this.timelineState = state;
+
+ // Check if new messages are available
+ if (
+ state.message_count === this.lastMessageCount &&
+ this.lastMessageCount > 0
+ ) {
+ // No new messages, early return
+ this.isFetchingMessages = false;
+ this.emitEvent('dataChanged', { state, newMessages: [] });
+ return;
+ }
+
+ // Fetch messages with a start parameter
+ this.currentFetchStartIndex = this.nextFetchIndex;
+ const messagesResponse = await fetch(
+ `messages?start=${this.nextFetchIndex}`,
+ );
+ const newMessages = await messagesResponse.json() || [];
+
+ // Store messages in our array
+ if (this.nextFetchIndex === 0) {
+ // If this is the first fetch, replace the entire array
+ this.messages = [...newMessages];
+ } else {
+ // Otherwise append the new messages
+ this.messages = [...this.messages, ...newMessages];
+ }
+
+ // Update connection status to connected
+ this.updateConnectionStatus("connected");
+
+ // Update the last message index for next fetch
+ if (newMessages && newMessages.length > 0) {
+ this.nextFetchIndex += newMessages.length;
+ }
+
+ // Update the message count
+ this.lastMessageCount = state?.message_count ?? 0;
+
+ // Mark that we've completed first load
+ if (this.isFirstLoad) {
+ this.isFirstLoad = false;
+ }
+
+ // Emit an event that data has changed
+ this.emitEvent('dataChanged', { state, newMessages, isFirstFetch: this.nextFetchIndex === newMessages.length });
+ } catch (error) {
+ console.error("Error fetching data:", error);
+
+ // Update connection status to disconnected
+ this.updateConnectionStatus("disconnected");
+
+ // Emit an event that we're disconnected
+ this.emitEvent('connectionStatusChanged', this.connectionStatus, "Not connected");
+ } finally {
+ this.isFetchingMessages = false;
+ }
+ }
+}