blob: eef2726cff3865851ddfd7f7a77af048ec9d4b00 [file] [log] [blame]
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();