Initial commit
diff --git a/loop/webui/src/timeline.ts b/loop/webui/src/timeline.ts
new file mode 100644
index 0000000..eef2726
--- /dev/null
+++ b/loop/webui/src/timeline.ts
@@ -0,0 +1,641 @@
+import { TimelineMessage } from "./timeline/types";
+import { formatNumber } from "./timeline/utils";
+import { checkShouldScroll } from "./timeline/scroll";
+import { ChartManager } from "./timeline/charts";
+import { ConnectionStatus, DataManager } from "./timeline/data";
+import { DiffViewer } from "./timeline/diffviewer";
+import { MessageRenderer } from "./timeline/renderer";
+import { TerminalHandler } from "./timeline/terminal";
+
+/**
+ * TimelineManager - Class to manage the timeline UI and functionality
+ */
+class TimelineManager {
+ private diffViewer = new DiffViewer();
+ private terminalHandler = new TerminalHandler();
+ private chartManager = new ChartManager();
+ private messageRenderer = new MessageRenderer();
+ private dataManager = new DataManager();
+
+ private viewMode: "chat" | "diff2" | "charts" | "terminal" = "chat";
+ shouldScrollToBottom: boolean;
+
+ constructor() {
+ // Initialize when DOM is ready
+ document.addEventListener("DOMContentLoaded", () => {
+ // First initialize from URL params to prevent flash of incorrect view
+ // This must happen before setting up other event handlers
+ void this.initializeViewFromUrl()
+ .then(() => {
+ // Continue with the rest of initialization
+ return this.initialize();
+ })
+ .catch((err) => {
+ console.error("Failed to initialize timeline:", err);
+ });
+ });
+
+ // Add popstate event listener to handle browser back/forward navigation
+ window.addEventListener("popstate", (event) => {
+ if (event.state && event.state.mode) {
+ // Using void to handle the promise returned by toggleViewMode
+ void this.toggleViewMode(event.state.mode);
+ } else {
+ // If no state or no mode in state, default to chat view
+ void this.toggleViewMode("chat");
+ }
+ });
+
+ // Listen for commit diff event from MessageRenderer
+ document.addEventListener("showCommitDiff", ((e: CustomEvent) => {
+ const { commitHash } = e.detail;
+ this.diffViewer.showCommitDiff(
+ commitHash,
+ (mode: "chat" | "diff2" | "terminal" | "charts") =>
+ this.toggleViewMode(mode)
+ );
+ }) as EventListener);
+ }
+
+ /**
+ * Initialize the timeline manager
+ */
+ private async initialize(): Promise<void> {
+ // Set up data manager event listeners
+ this.dataManager.addEventListener(
+ "dataChanged",
+ this.handleDataChanged.bind(this)
+ );
+ this.dataManager.addEventListener(
+ "connectionStatusChanged",
+ this.handleConnectionStatusChanged.bind(this)
+ );
+
+ // Initialize the data manager
+ await this.dataManager.initialize();
+
+ // URL parameters have already been read in constructor
+ // to prevent flash of incorrect content
+
+ // Set up conversation button handler
+ document
+ .getElementById("showConversationButton")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("chat");
+ });
+
+ // Set up diff2 button handler
+ document
+ .getElementById("showDiff2Button")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("diff2");
+ });
+
+ // Set up charts button handler
+ document
+ .getElementById("showChartsButton")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("charts");
+ });
+
+ // Set up terminal button handler
+ document
+ .getElementById("showTerminalButton")
+ ?.addEventListener("click", async () => {
+ this.toggleViewMode("terminal");
+ });
+
+ // The active button will be set by toggleViewMode
+ // We'll initialize view based on URL params or default to chat view if no params
+ // We defer button activation to the toggleViewMode function
+
+ // Set up stop button handler
+ document
+ .getElementById("stopButton")
+ ?.addEventListener("click", async () => {
+ this.stopInnerLoop();
+ });
+
+ const pollToggleCheckbox = document.getElementById(
+ "pollToggle"
+ ) as HTMLInputElement;
+ pollToggleCheckbox?.addEventListener("change", () => {
+ this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ if (pollToggleCheckbox.checked) {
+ statusText.textContent = "Polling for updates...";
+ } else {
+ statusText.textContent = "Polling stopped";
+ }
+ }
+ });
+
+ // Initial data fetch and polling is now handled by the DataManager
+
+ // Set up chat functionality
+ this.setupChatBox();
+
+ // Set up keyboard shortcuts
+ this.setupKeyboardShortcuts();
+
+ // Set up spacing adjustments
+ this.adjustChatSpacing();
+ window.addEventListener("resize", () => this.adjustChatSpacing());
+ }
+
+ /**
+ * Set up chat box event listeners
+ */
+ private setupChatBox(): void {
+ const chatInput = document.getElementById(
+ "chatInput"
+ ) as HTMLTextAreaElement;
+ const sendButton = document.getElementById("sendChatButton");
+
+ // Handle pressing Enter in the text area
+ chatInput?.addEventListener("keydown", (event: KeyboardEvent) => {
+ // Send message if Enter is pressed without Shift key
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault(); // Prevent default newline
+ this.sendChatMessage();
+ }
+ });
+
+ // Handle send button click
+ sendButton?.addEventListener("click", () => this.sendChatMessage());
+
+ // Set up mutation observer for the chat container
+ if (chatInput) {
+ chatInput.addEventListener("input", () => {
+ // When content changes, adjust the spacing
+ requestAnimationFrame(() => this.adjustChatSpacing());
+ });
+ }
+ }
+
+ /**
+ * Send the chat message to the server
+ */
+ private async sendChatMessage(): Promise<void> {
+ const chatInput = document.getElementById(
+ "chatInput"
+ ) as HTMLTextAreaElement;
+ if (!chatInput) return;
+
+ const message = chatInput.value.trim();
+
+ // Don't send empty messages
+ if (!message) return;
+
+ try {
+ // Send the message to the server
+ const response = await fetch("chat", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ message }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ throw new Error(`Server error: ${response.status} - ${errorData}`);
+ }
+
+ // Clear the input after sending
+ chatInput.value = "";
+
+ // Reset data manager state to force a full refresh after sending a message
+ // This ensures we get all messages in the correct order
+ // Use private API for now - TODO: add a resetState() method to DataManager
+ (this.dataManager as any).nextFetchIndex = 0;
+ (this.dataManager as any).currentFetchStartIndex = 0;
+
+ // If in diff view, switch to conversation view
+ if (this.viewMode === "diff2") {
+ await this.toggleViewMode("chat");
+ }
+
+ // Refresh the timeline data to show the new message
+ await this.dataManager.fetchData();
+ } catch (error) {
+ console.error("Error sending chat message:", error);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Error sending message";
+ }
+ }
+ }
+
+ /**
+ * Handle data changed event from the data manager
+ */
+ private handleDataChanged(eventData: {
+ state: any;
+ newMessages: TimelineMessage[];
+ isFirstFetch?: boolean;
+ }): void {
+ const { state, newMessages, isFirstFetch } = eventData;
+
+ // Check if we should scroll to bottom BEFORE handling new data
+ this.shouldScrollToBottom = this.checkShouldScroll();
+
+ // Update state info in the UI
+ this.updateUIWithState(state);
+
+ // Update the timeline if there are new messages
+ if (newMessages.length > 0) {
+ // Initialize the message renderer with current state
+ this.messageRenderer.initialize(
+ this.dataManager.getIsFirstLoad(),
+ this.dataManager.getCurrentFetchStartIndex()
+ );
+
+ this.messageRenderer.renderTimeline(newMessages, isFirstFetch || false);
+
+ // Update chart data using our full messages array
+ this.chartManager.setChartData(
+ this.chartManager.calculateCumulativeCostData(
+ this.dataManager.getMessages()
+ )
+ );
+
+ // If in charts view, update the charts
+ if (this.viewMode === "charts") {
+ this.chartManager.renderCharts();
+ }
+
+ const statusTextEl = document.getElementById("statusText");
+ if (statusTextEl) {
+ statusTextEl.textContent = "Updated just now";
+ }
+ } else {
+ const statusTextEl = document.getElementById("statusText");
+ if (statusTextEl) {
+ statusTextEl.textContent = "No new messages";
+ }
+ }
+ }
+
+ /**
+ * Handle connection status changed event from the data manager
+ */
+ private handleConnectionStatusChanged(
+ status: ConnectionStatus,
+ errorMessage?: string
+ ): void {
+ const pollingIndicator = document.getElementById("pollingIndicator");
+ if (!pollingIndicator) return;
+
+ // Remove all status classes
+ pollingIndicator.classList.remove("active", "error");
+
+ // Add appropriate class based on status
+ if (status === "connected") {
+ pollingIndicator.classList.add("active");
+ } else if (status === "disconnected") {
+ pollingIndicator.classList.add("error");
+ }
+
+ // Update status text if error message is provided
+ if (errorMessage) {
+ const statusTextEl = document.getElementById("statusText");
+ if (statusTextEl) {
+ statusTextEl.textContent = errorMessage;
+ }
+ }
+ }
+
+ /**
+ * Update UI elements with state data
+ */
+ private updateUIWithState(state: any): void {
+ // Update state info in the UI with safe getters
+ const hostnameEl = document.getElementById("hostname");
+ if (hostnameEl) {
+ hostnameEl.textContent = state?.hostname ?? "Unknown";
+ }
+
+ const workingDirEl = document.getElementById("workingDir");
+ if (workingDirEl) {
+ workingDirEl.textContent = state?.working_dir ?? "Unknown";
+ }
+
+ const initialCommitEl = document.getElementById("initialCommit");
+ if (initialCommitEl) {
+ initialCommitEl.textContent = state?.initial_commit
+ ? state.initial_commit.substring(0, 8)
+ : "Unknown";
+ }
+
+ const messageCountEl = document.getElementById("messageCount");
+ if (messageCountEl) {
+ messageCountEl.textContent = state?.message_count ?? "0";
+ }
+
+ const chatTitleEl = document.getElementById("chatTitle");
+ const bannerTitleEl = document.querySelector(".banner-title");
+
+ if (chatTitleEl && bannerTitleEl) {
+ if (state?.title) {
+ chatTitleEl.textContent = state.title;
+ chatTitleEl.style.display = "block";
+ bannerTitleEl.textContent = "sketch"; // Shorten title when chat title exists
+ } else {
+ chatTitleEl.style.display = "none";
+ bannerTitleEl.textContent = "sketch coding assistant"; // Full title when no chat title
+ }
+ }
+
+ // Get token and cost info safely
+ const inputTokens = state?.total_usage?.input_tokens ?? 0;
+ const outputTokens = state?.total_usage?.output_tokens ?? 0;
+ const cacheReadInputTokens =
+ state?.total_usage?.cache_read_input_tokens ?? 0;
+ const cacheCreationInputTokens =
+ state?.total_usage?.cache_creation_input_tokens ?? 0;
+ const totalCost = state?.total_usage?.total_cost_usd ?? 0;
+
+ const inputTokensEl = document.getElementById("inputTokens");
+ if (inputTokensEl) {
+ inputTokensEl.textContent = formatNumber(inputTokens, "0");
+ }
+
+ const outputTokensEl = document.getElementById("outputTokens");
+ if (outputTokensEl) {
+ outputTokensEl.textContent = formatNumber(outputTokens, "0");
+ }
+
+ const cacheReadInputTokensEl = document.getElementById(
+ "cacheReadInputTokens"
+ );
+ if (cacheReadInputTokensEl) {
+ cacheReadInputTokensEl.textContent = formatNumber(
+ cacheReadInputTokens,
+ "0"
+ );
+ }
+
+ const cacheCreationInputTokensEl = document.getElementById(
+ "cacheCreationInputTokens"
+ );
+ if (cacheCreationInputTokensEl) {
+ cacheCreationInputTokensEl.textContent = formatNumber(
+ cacheCreationInputTokens,
+ "0"
+ );
+ }
+
+ const totalCostEl = document.getElementById("totalCost");
+ if (totalCostEl) {
+ totalCostEl.textContent = `$${totalCost.toFixed(2)}`;
+ }
+ }
+
+ /**
+ * Check if we should scroll to the bottom
+ */
+ private checkShouldScroll(): boolean {
+ return checkShouldScroll(this.dataManager.getIsFirstLoad());
+ }
+
+ /**
+ * Dynamically adjust body padding based on the chat container height and top banner
+ */
+ private adjustChatSpacing(): void {
+ const chatContainer = document.querySelector(".chat-container");
+ const topBanner = document.querySelector(".top-banner");
+
+ if (chatContainer) {
+ const chatHeight = (chatContainer as HTMLElement).offsetHeight;
+ document.body.style.paddingBottom = `${chatHeight + 20}px`; // 20px extra for spacing
+ }
+
+ if (topBanner) {
+ const topHeight = (topBanner as HTMLElement).offsetHeight;
+ document.body.style.paddingTop = `${topHeight + 20}px`; // 20px extra for spacing
+ }
+ }
+
+ /**
+ * Set up keyboard shortcuts
+ */
+ private setupKeyboardShortcuts(): void {
+ // Add keyboard shortcut to automatically copy selected text with Ctrl+C (or Command+C on Mac)
+ document.addEventListener("keydown", (e: KeyboardEvent) => {
+ // We only want to handle Ctrl+C or Command+C
+ if ((e.ctrlKey || e.metaKey) && e.key === "c") {
+ // If text is already selected, we don't need to do anything special
+ // as the browser's default behavior will handle copying
+ // But we could add additional behavior here if needed
+ }
+ });
+ }
+
+ /**
+ * Toggle between different view modes: chat, diff2, charts
+ */
+ public async toggleViewMode(
+ mode: "chat" | "diff2" | "charts" | "terminal"
+ ): Promise<void> {
+ // Set the new view mode
+ this.viewMode = mode;
+
+ // Update URL with the current view mode
+ this.updateUrlForViewMode(mode);
+
+ // Get DOM elements
+ const timeline = document.getElementById("timeline");
+ const diff2View = document.getElementById("diff2View");
+ const chartView = document.getElementById("chartView");
+ const container = document.querySelector(".timeline-container");
+ const terminalView = document.getElementById("terminalView");
+ const conversationButton = document.getElementById(
+ "showConversationButton"
+ );
+ const diff2Button = document.getElementById("showDiff2Button");
+ const chartsButton = document.getElementById("showChartsButton");
+ const terminalButton = document.getElementById("showTerminalButton");
+
+ if (
+ !timeline ||
+ !diff2View ||
+ !chartView ||
+ !container ||
+ !conversationButton ||
+ !diff2Button ||
+ !chartsButton ||
+ !terminalView ||
+ !terminalButton
+ ) {
+ console.error("Required DOM elements not found");
+ return;
+ }
+
+ // Hide all views first
+ timeline.style.display = "none";
+ diff2View.style.display = "none";
+ chartView.style.display = "none";
+ terminalView.style.display = "none";
+
+ // Reset all button states
+ conversationButton.classList.remove("active");
+ diff2Button.classList.remove("active");
+ chartsButton.classList.remove("active");
+ terminalButton.classList.remove("active");
+
+ // Remove diff2-active and diff-active classes from container
+ container.classList.remove("diff2-active");
+ container.classList.remove("diff-active");
+
+ // If switching to chat view, clear the current commit hash
+ if (mode === "chat") {
+ this.diffViewer.clearCurrentCommitHash();
+ }
+
+ // Add class to indicate views are initialized (prevents flash of content)
+ container.classList.add("view-initialized");
+
+ // Show the selected view based on mode
+ switch (mode) {
+ case "chat":
+ timeline.style.display = "block";
+ conversationButton.classList.add("active");
+ break;
+ case "diff2":
+ diff2View.style.display = "block";
+ diff2Button.classList.add("active");
+ this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
+ await this.diffViewer.loadDiff2HtmlContent();
+ break;
+ case "charts":
+ chartView.style.display = "block";
+ chartsButton.classList.add("active");
+ await this.chartManager.renderCharts();
+ break;
+ case "terminal":
+ terminalView.style.display = "block";
+ terminalButton.classList.add("active");
+ this.terminalHandler.setViewMode(mode); // Update view mode in terminal handler
+ this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
+ await this.initializeTerminal();
+ break;
+ }
+ }
+
+ /**
+ * Initialize the terminal view
+ */
+ private async initializeTerminal(): Promise<void> {
+ // Use the TerminalHandler to initialize the terminal
+ await this.terminalHandler.initializeTerminal();
+ }
+
+ /**
+ * Initialize the view based on URL parameters
+ * This allows bookmarking and sharing of specific views
+ */
+ private async initializeViewFromUrl(): Promise<void> {
+ // Parse the URL parameters
+ const urlParams = new URLSearchParams(window.location.search);
+ const viewParam = urlParams.get("view");
+ const commitParam = urlParams.get("commit");
+
+ // Default to chat view if no valid view parameter is provided
+ if (!viewParam) {
+ // Explicitly set chat view to ensure button state is correct
+ await this.toggleViewMode("chat");
+ return;
+ }
+
+ // Check if the view parameter is valid
+ if (
+ viewParam === "chat" ||
+ viewParam === "diff2" ||
+ viewParam === "charts" ||
+ viewParam === "terminal"
+ ) {
+ // If it's a diff view with a commit hash, set the commit hash
+ if (viewParam === "diff2" && commitParam) {
+ this.diffViewer.setCurrentCommitHash(commitParam);
+ }
+
+ // Set the view mode
+ await this.toggleViewMode(
+ viewParam as "chat" | "diff2" | "charts" | "terminal"
+ );
+ }
+ }
+
+ /**
+ * Update URL to reflect current view mode for bookmarking and sharing
+ * @param mode The current view mode
+ */
+ private updateUrlForViewMode(
+ mode: "chat" | "diff2" | "charts" | "terminal"
+ ): void {
+ // Get the current URL without search parameters
+ const url = new URL(window.location.href);
+
+ // Clear existing parameters
+ url.search = "";
+
+ // Only add view parameter if not in default chat view
+ if (mode !== "chat") {
+ url.searchParams.set("view", mode);
+
+ // If in diff view and there's a commit hash, include that too
+ if (mode === "diff2" && this.diffViewer.getCurrentCommitHash()) {
+ url.searchParams.set("commit", this.diffViewer.getCurrentCommitHash());
+ }
+ }
+
+ // Update the browser history without reloading the page
+ window.history.pushState({ mode }, "", url.toString());
+ }
+
+ /**
+ * Stop the inner loop by calling the /cancel endpoint
+ */
+ private async stopInnerLoop(): Promise<void> {
+ if (!confirm("Are you sure you want to stop the current operation?")) {
+ return;
+ }
+
+ try {
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Cancelling...";
+ }
+
+ const response = await fetch("cancel", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ reason: "User requested cancellation via UI" }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ throw new Error(`Server error: ${response.status} - ${errorData}`);
+ }
+
+ // Parse the response
+ const _result = await response.json();
+ if (statusText) {
+ statusText.textContent = "Operation cancelled";
+ }
+ } catch (error) {
+ console.error("Error cancelling operation:", error);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Error cancelling operation";
+ }
+ }
+ }
+}
+
+// Create and initialize the timeline manager when the page loads
+const _timelineManager = new TimelineManager();