blob: eef2726cff3865851ddfd7f7a77af048ec9d4b00 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { TimelineMessage } from "./timeline/types";
2import { formatNumber } from "./timeline/utils";
3import { checkShouldScroll } from "./timeline/scroll";
4import { ChartManager } from "./timeline/charts";
5import { ConnectionStatus, DataManager } from "./timeline/data";
6import { DiffViewer } from "./timeline/diffviewer";
7import { MessageRenderer } from "./timeline/renderer";
8import { TerminalHandler } from "./timeline/terminal";
9
10/**
11 * TimelineManager - Class to manage the timeline UI and functionality
12 */
13class TimelineManager {
14 private diffViewer = new DiffViewer();
15 private terminalHandler = new TerminalHandler();
16 private chartManager = new ChartManager();
17 private messageRenderer = new MessageRenderer();
18 private dataManager = new DataManager();
19
20 private viewMode: "chat" | "diff2" | "charts" | "terminal" = "chat";
21 shouldScrollToBottom: boolean;
22
23 constructor() {
24 // Initialize when DOM is ready
25 document.addEventListener("DOMContentLoaded", () => {
26 // First initialize from URL params to prevent flash of incorrect view
27 // This must happen before setting up other event handlers
28 void this.initializeViewFromUrl()
29 .then(() => {
30 // Continue with the rest of initialization
31 return this.initialize();
32 })
33 .catch((err) => {
34 console.error("Failed to initialize timeline:", err);
35 });
36 });
37
38 // Add popstate event listener to handle browser back/forward navigation
39 window.addEventListener("popstate", (event) => {
40 if (event.state && event.state.mode) {
41 // Using void to handle the promise returned by toggleViewMode
42 void this.toggleViewMode(event.state.mode);
43 } else {
44 // If no state or no mode in state, default to chat view
45 void this.toggleViewMode("chat");
46 }
47 });
48
49 // Listen for commit diff event from MessageRenderer
50 document.addEventListener("showCommitDiff", ((e: CustomEvent) => {
51 const { commitHash } = e.detail;
52 this.diffViewer.showCommitDiff(
53 commitHash,
54 (mode: "chat" | "diff2" | "terminal" | "charts") =>
55 this.toggleViewMode(mode)
56 );
57 }) as EventListener);
58 }
59
60 /**
61 * Initialize the timeline manager
62 */
63 private async initialize(): Promise<void> {
64 // Set up data manager event listeners
65 this.dataManager.addEventListener(
66 "dataChanged",
67 this.handleDataChanged.bind(this)
68 );
69 this.dataManager.addEventListener(
70 "connectionStatusChanged",
71 this.handleConnectionStatusChanged.bind(this)
72 );
73
74 // Initialize the data manager
75 await this.dataManager.initialize();
76
77 // URL parameters have already been read in constructor
78 // to prevent flash of incorrect content
79
80 // Set up conversation button handler
81 document
82 .getElementById("showConversationButton")
83 ?.addEventListener("click", async () => {
84 this.toggleViewMode("chat");
85 });
86
87 // Set up diff2 button handler
88 document
89 .getElementById("showDiff2Button")
90 ?.addEventListener("click", async () => {
91 this.toggleViewMode("diff2");
92 });
93
94 // Set up charts button handler
95 document
96 .getElementById("showChartsButton")
97 ?.addEventListener("click", async () => {
98 this.toggleViewMode("charts");
99 });
100
101 // Set up terminal button handler
102 document
103 .getElementById("showTerminalButton")
104 ?.addEventListener("click", async () => {
105 this.toggleViewMode("terminal");
106 });
107
108 // The active button will be set by toggleViewMode
109 // We'll initialize view based on URL params or default to chat view if no params
110 // We defer button activation to the toggleViewMode function
111
112 // Set up stop button handler
113 document
114 .getElementById("stopButton")
115 ?.addEventListener("click", async () => {
116 this.stopInnerLoop();
117 });
118
119 const pollToggleCheckbox = document.getElementById(
120 "pollToggle"
121 ) as HTMLInputElement;
122 pollToggleCheckbox?.addEventListener("change", () => {
123 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
124 const statusText = document.getElementById("statusText");
125 if (statusText) {
126 if (pollToggleCheckbox.checked) {
127 statusText.textContent = "Polling for updates...";
128 } else {
129 statusText.textContent = "Polling stopped";
130 }
131 }
132 });
133
134 // Initial data fetch and polling is now handled by the DataManager
135
136 // Set up chat functionality
137 this.setupChatBox();
138
139 // Set up keyboard shortcuts
140 this.setupKeyboardShortcuts();
141
142 // Set up spacing adjustments
143 this.adjustChatSpacing();
144 window.addEventListener("resize", () => this.adjustChatSpacing());
145 }
146
147 /**
148 * Set up chat box event listeners
149 */
150 private setupChatBox(): void {
151 const chatInput = document.getElementById(
152 "chatInput"
153 ) as HTMLTextAreaElement;
154 const sendButton = document.getElementById("sendChatButton");
155
156 // Handle pressing Enter in the text area
157 chatInput?.addEventListener("keydown", (event: KeyboardEvent) => {
158 // Send message if Enter is pressed without Shift key
159 if (event.key === "Enter" && !event.shiftKey) {
160 event.preventDefault(); // Prevent default newline
161 this.sendChatMessage();
162 }
163 });
164
165 // Handle send button click
166 sendButton?.addEventListener("click", () => this.sendChatMessage());
167
168 // Set up mutation observer for the chat container
169 if (chatInput) {
170 chatInput.addEventListener("input", () => {
171 // When content changes, adjust the spacing
172 requestAnimationFrame(() => this.adjustChatSpacing());
173 });
174 }
175 }
176
177 /**
178 * Send the chat message to the server
179 */
180 private async sendChatMessage(): Promise<void> {
181 const chatInput = document.getElementById(
182 "chatInput"
183 ) as HTMLTextAreaElement;
184 if (!chatInput) return;
185
186 const message = chatInput.value.trim();
187
188 // Don't send empty messages
189 if (!message) return;
190
191 try {
192 // Send the message to the server
193 const response = await fetch("chat", {
194 method: "POST",
195 headers: {
196 "Content-Type": "application/json",
197 },
198 body: JSON.stringify({ message }),
199 });
200
201 if (!response.ok) {
202 const errorData = await response.text();
203 throw new Error(`Server error: ${response.status} - ${errorData}`);
204 }
205
206 // Clear the input after sending
207 chatInput.value = "";
208
209 // Reset data manager state to force a full refresh after sending a message
210 // This ensures we get all messages in the correct order
211 // Use private API for now - TODO: add a resetState() method to DataManager
212 (this.dataManager as any).nextFetchIndex = 0;
213 (this.dataManager as any).currentFetchStartIndex = 0;
214
215 // If in diff view, switch to conversation view
216 if (this.viewMode === "diff2") {
217 await this.toggleViewMode("chat");
218 }
219
220 // Refresh the timeline data to show the new message
221 await this.dataManager.fetchData();
222 } catch (error) {
223 console.error("Error sending chat message:", error);
224 const statusText = document.getElementById("statusText");
225 if (statusText) {
226 statusText.textContent = "Error sending message";
227 }
228 }
229 }
230
231 /**
232 * Handle data changed event from the data manager
233 */
234 private handleDataChanged(eventData: {
235 state: any;
236 newMessages: TimelineMessage[];
237 isFirstFetch?: boolean;
238 }): void {
239 const { state, newMessages, isFirstFetch } = eventData;
240
241 // Check if we should scroll to bottom BEFORE handling new data
242 this.shouldScrollToBottom = this.checkShouldScroll();
243
244 // Update state info in the UI
245 this.updateUIWithState(state);
246
247 // Update the timeline if there are new messages
248 if (newMessages.length > 0) {
249 // Initialize the message renderer with current state
250 this.messageRenderer.initialize(
251 this.dataManager.getIsFirstLoad(),
252 this.dataManager.getCurrentFetchStartIndex()
253 );
254
255 this.messageRenderer.renderTimeline(newMessages, isFirstFetch || false);
256
257 // Update chart data using our full messages array
258 this.chartManager.setChartData(
259 this.chartManager.calculateCumulativeCostData(
260 this.dataManager.getMessages()
261 )
262 );
263
264 // If in charts view, update the charts
265 if (this.viewMode === "charts") {
266 this.chartManager.renderCharts();
267 }
268
269 const statusTextEl = document.getElementById("statusText");
270 if (statusTextEl) {
271 statusTextEl.textContent = "Updated just now";
272 }
273 } else {
274 const statusTextEl = document.getElementById("statusText");
275 if (statusTextEl) {
276 statusTextEl.textContent = "No new messages";
277 }
278 }
279 }
280
281 /**
282 * Handle connection status changed event from the data manager
283 */
284 private handleConnectionStatusChanged(
285 status: ConnectionStatus,
286 errorMessage?: string
287 ): void {
288 const pollingIndicator = document.getElementById("pollingIndicator");
289 if (!pollingIndicator) return;
290
291 // Remove all status classes
292 pollingIndicator.classList.remove("active", "error");
293
294 // Add appropriate class based on status
295 if (status === "connected") {
296 pollingIndicator.classList.add("active");
297 } else if (status === "disconnected") {
298 pollingIndicator.classList.add("error");
299 }
300
301 // Update status text if error message is provided
302 if (errorMessage) {
303 const statusTextEl = document.getElementById("statusText");
304 if (statusTextEl) {
305 statusTextEl.textContent = errorMessage;
306 }
307 }
308 }
309
310 /**
311 * Update UI elements with state data
312 */
313 private updateUIWithState(state: any): void {
314 // Update state info in the UI with safe getters
315 const hostnameEl = document.getElementById("hostname");
316 if (hostnameEl) {
317 hostnameEl.textContent = state?.hostname ?? "Unknown";
318 }
319
320 const workingDirEl = document.getElementById("workingDir");
321 if (workingDirEl) {
322 workingDirEl.textContent = state?.working_dir ?? "Unknown";
323 }
324
325 const initialCommitEl = document.getElementById("initialCommit");
326 if (initialCommitEl) {
327 initialCommitEl.textContent = state?.initial_commit
328 ? state.initial_commit.substring(0, 8)
329 : "Unknown";
330 }
331
332 const messageCountEl = document.getElementById("messageCount");
333 if (messageCountEl) {
334 messageCountEl.textContent = state?.message_count ?? "0";
335 }
336
337 const chatTitleEl = document.getElementById("chatTitle");
338 const bannerTitleEl = document.querySelector(".banner-title");
339
340 if (chatTitleEl && bannerTitleEl) {
341 if (state?.title) {
342 chatTitleEl.textContent = state.title;
343 chatTitleEl.style.display = "block";
344 bannerTitleEl.textContent = "sketch"; // Shorten title when chat title exists
345 } else {
346 chatTitleEl.style.display = "none";
347 bannerTitleEl.textContent = "sketch coding assistant"; // Full title when no chat title
348 }
349 }
350
351 // Get token and cost info safely
352 const inputTokens = state?.total_usage?.input_tokens ?? 0;
353 const outputTokens = state?.total_usage?.output_tokens ?? 0;
354 const cacheReadInputTokens =
355 state?.total_usage?.cache_read_input_tokens ?? 0;
356 const cacheCreationInputTokens =
357 state?.total_usage?.cache_creation_input_tokens ?? 0;
358 const totalCost = state?.total_usage?.total_cost_usd ?? 0;
359
360 const inputTokensEl = document.getElementById("inputTokens");
361 if (inputTokensEl) {
362 inputTokensEl.textContent = formatNumber(inputTokens, "0");
363 }
364
365 const outputTokensEl = document.getElementById("outputTokens");
366 if (outputTokensEl) {
367 outputTokensEl.textContent = formatNumber(outputTokens, "0");
368 }
369
370 const cacheReadInputTokensEl = document.getElementById(
371 "cacheReadInputTokens"
372 );
373 if (cacheReadInputTokensEl) {
374 cacheReadInputTokensEl.textContent = formatNumber(
375 cacheReadInputTokens,
376 "0"
377 );
378 }
379
380 const cacheCreationInputTokensEl = document.getElementById(
381 "cacheCreationInputTokens"
382 );
383 if (cacheCreationInputTokensEl) {
384 cacheCreationInputTokensEl.textContent = formatNumber(
385 cacheCreationInputTokens,
386 "0"
387 );
388 }
389
390 const totalCostEl = document.getElementById("totalCost");
391 if (totalCostEl) {
392 totalCostEl.textContent = `$${totalCost.toFixed(2)}`;
393 }
394 }
395
396 /**
397 * Check if we should scroll to the bottom
398 */
399 private checkShouldScroll(): boolean {
400 return checkShouldScroll(this.dataManager.getIsFirstLoad());
401 }
402
403 /**
404 * Dynamically adjust body padding based on the chat container height and top banner
405 */
406 private adjustChatSpacing(): void {
407 const chatContainer = document.querySelector(".chat-container");
408 const topBanner = document.querySelector(".top-banner");
409
410 if (chatContainer) {
411 const chatHeight = (chatContainer as HTMLElement).offsetHeight;
412 document.body.style.paddingBottom = `${chatHeight + 20}px`; // 20px extra for spacing
413 }
414
415 if (topBanner) {
416 const topHeight = (topBanner as HTMLElement).offsetHeight;
417 document.body.style.paddingTop = `${topHeight + 20}px`; // 20px extra for spacing
418 }
419 }
420
421 /**
422 * Set up keyboard shortcuts
423 */
424 private setupKeyboardShortcuts(): void {
425 // Add keyboard shortcut to automatically copy selected text with Ctrl+C (or Command+C on Mac)
426 document.addEventListener("keydown", (e: KeyboardEvent) => {
427 // We only want to handle Ctrl+C or Command+C
428 if ((e.ctrlKey || e.metaKey) && e.key === "c") {
429 // If text is already selected, we don't need to do anything special
430 // as the browser's default behavior will handle copying
431 // But we could add additional behavior here if needed
432 }
433 });
434 }
435
436 /**
437 * Toggle between different view modes: chat, diff2, charts
438 */
439 public async toggleViewMode(
440 mode: "chat" | "diff2" | "charts" | "terminal"
441 ): Promise<void> {
442 // Set the new view mode
443 this.viewMode = mode;
444
445 // Update URL with the current view mode
446 this.updateUrlForViewMode(mode);
447
448 // Get DOM elements
449 const timeline = document.getElementById("timeline");
450 const diff2View = document.getElementById("diff2View");
451 const chartView = document.getElementById("chartView");
452 const container = document.querySelector(".timeline-container");
453 const terminalView = document.getElementById("terminalView");
454 const conversationButton = document.getElementById(
455 "showConversationButton"
456 );
457 const diff2Button = document.getElementById("showDiff2Button");
458 const chartsButton = document.getElementById("showChartsButton");
459 const terminalButton = document.getElementById("showTerminalButton");
460
461 if (
462 !timeline ||
463 !diff2View ||
464 !chartView ||
465 !container ||
466 !conversationButton ||
467 !diff2Button ||
468 !chartsButton ||
469 !terminalView ||
470 !terminalButton
471 ) {
472 console.error("Required DOM elements not found");
473 return;
474 }
475
476 // Hide all views first
477 timeline.style.display = "none";
478 diff2View.style.display = "none";
479 chartView.style.display = "none";
480 terminalView.style.display = "none";
481
482 // Reset all button states
483 conversationButton.classList.remove("active");
484 diff2Button.classList.remove("active");
485 chartsButton.classList.remove("active");
486 terminalButton.classList.remove("active");
487
488 // Remove diff2-active and diff-active classes from container
489 container.classList.remove("diff2-active");
490 container.classList.remove("diff-active");
491
492 // If switching to chat view, clear the current commit hash
493 if (mode === "chat") {
494 this.diffViewer.clearCurrentCommitHash();
495 }
496
497 // Add class to indicate views are initialized (prevents flash of content)
498 container.classList.add("view-initialized");
499
500 // Show the selected view based on mode
501 switch (mode) {
502 case "chat":
503 timeline.style.display = "block";
504 conversationButton.classList.add("active");
505 break;
506 case "diff2":
507 diff2View.style.display = "block";
508 diff2Button.classList.add("active");
509 this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
510 await this.diffViewer.loadDiff2HtmlContent();
511 break;
512 case "charts":
513 chartView.style.display = "block";
514 chartsButton.classList.add("active");
515 await this.chartManager.renderCharts();
516 break;
517 case "terminal":
518 terminalView.style.display = "block";
519 terminalButton.classList.add("active");
520 this.terminalHandler.setViewMode(mode); // Update view mode in terminal handler
521 this.diffViewer.setViewMode(mode); // Update view mode in diff viewer
522 await this.initializeTerminal();
523 break;
524 }
525 }
526
527 /**
528 * Initialize the terminal view
529 */
530 private async initializeTerminal(): Promise<void> {
531 // Use the TerminalHandler to initialize the terminal
532 await this.terminalHandler.initializeTerminal();
533 }
534
535 /**
536 * Initialize the view based on URL parameters
537 * This allows bookmarking and sharing of specific views
538 */
539 private async initializeViewFromUrl(): Promise<void> {
540 // Parse the URL parameters
541 const urlParams = new URLSearchParams(window.location.search);
542 const viewParam = urlParams.get("view");
543 const commitParam = urlParams.get("commit");
544
545 // Default to chat view if no valid view parameter is provided
546 if (!viewParam) {
547 // Explicitly set chat view to ensure button state is correct
548 await this.toggleViewMode("chat");
549 return;
550 }
551
552 // Check if the view parameter is valid
553 if (
554 viewParam === "chat" ||
555 viewParam === "diff2" ||
556 viewParam === "charts" ||
557 viewParam === "terminal"
558 ) {
559 // If it's a diff view with a commit hash, set the commit hash
560 if (viewParam === "diff2" && commitParam) {
561 this.diffViewer.setCurrentCommitHash(commitParam);
562 }
563
564 // Set the view mode
565 await this.toggleViewMode(
566 viewParam as "chat" | "diff2" | "charts" | "terminal"
567 );
568 }
569 }
570
571 /**
572 * Update URL to reflect current view mode for bookmarking and sharing
573 * @param mode The current view mode
574 */
575 private updateUrlForViewMode(
576 mode: "chat" | "diff2" | "charts" | "terminal"
577 ): void {
578 // Get the current URL without search parameters
579 const url = new URL(window.location.href);
580
581 // Clear existing parameters
582 url.search = "";
583
584 // Only add view parameter if not in default chat view
585 if (mode !== "chat") {
586 url.searchParams.set("view", mode);
587
588 // If in diff view and there's a commit hash, include that too
589 if (mode === "diff2" && this.diffViewer.getCurrentCommitHash()) {
590 url.searchParams.set("commit", this.diffViewer.getCurrentCommitHash());
591 }
592 }
593
594 // Update the browser history without reloading the page
595 window.history.pushState({ mode }, "", url.toString());
596 }
597
598 /**
599 * Stop the inner loop by calling the /cancel endpoint
600 */
601 private async stopInnerLoop(): Promise<void> {
602 if (!confirm("Are you sure you want to stop the current operation?")) {
603 return;
604 }
605
606 try {
607 const statusText = document.getElementById("statusText");
608 if (statusText) {
609 statusText.textContent = "Cancelling...";
610 }
611
612 const response = await fetch("cancel", {
613 method: "POST",
614 headers: {
615 "Content-Type": "application/json",
616 },
617 body: JSON.stringify({ reason: "User requested cancellation via UI" }),
618 });
619
620 if (!response.ok) {
621 const errorData = await response.text();
622 throw new Error(`Server error: ${response.status} - ${errorData}`);
623 }
624
625 // Parse the response
626 const _result = await response.json();
627 if (statusText) {
628 statusText.textContent = "Operation cancelled";
629 }
630 } catch (error) {
631 console.error("Error cancelling operation:", error);
632 const statusText = document.getElementById("statusText");
633 if (statusText) {
634 statusText.textContent = "Error cancelling operation";
635 }
636 }
637 }
638}
639
640// Create and initialize the timeline manager when the page loads
641const _timelineManager = new TimelineManager();