blob: 2c7b111ecb431e44fea7d3a6dfc7963ef045d9f8 [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import { PropertyValues } from "lit";
4import { DataManager, ConnectionStatus } from "../data";
5import { State, TimelineMessage, ToolCall } from "../types";
6import "./sketch-container-status";
7import "./sketch-view-mode-select";
8import "./sketch-network-status";
9import "./sketch-timeline";
10import "./sketch-chat-input";
11import "./sketch-diff-view";
12import "./sketch-charts";
13import "./sketch-terminal";
14import { SketchDiffView } from "./sketch-diff-view";
15import { View } from "vega";
16
17type ViewMode = "chat" | "diff" | "charts" | "terminal";
18
19@customElement("sketch-app-shell")
20export class SketchAppShell extends LitElement {
21 // Current view mode (chat, diff, charts, terminal)
22 @state()
23 viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
24
25 // Current commit hash for diff view
26 @state()
27 currentCommitHash: string = "";
28
29 // Reference to the diff view component
30 private diffViewRef?: HTMLElement;
31
32 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
33 // Note that these styles only apply to the scope of this web component's
34 // shadow DOM node, so they won't leak out or collide with CSS declared in
35 // other components or the containing web page (...unless you want it to do that).
36 static styles = css`
37 :host {
38 display: block;
39 font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
40 Roboto, sans-serif;
41 color: #333;
42 line-height: 1.4;
43 min-height: 100vh;
44 width: 100%;
45 position: relative;
46 overflow-x: hidden;
47 }
48
49 /* Top banner with combined elements */
50 .top-banner {
51 display: flex;
52 justify-content: space-between;
53 align-items: center;
54 padding: 5px 20px;
55 margin-bottom: 0;
56 border-bottom: 1px solid #eee;
57 gap: 10px;
58 position: fixed;
59 top: 0;
60 left: 0;
61 right: 0;
62 background: white;
63 z-index: 100;
64 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
65 max-width: 100%;
66 }
67
68 .banner-title {
69 font-size: 18px;
70 font-weight: 600;
71 margin: 0;
72 min-width: 6em;
73 white-space: nowrap;
74 overflow: hidden;
75 text-overflow: ellipsis;
76 }
77
78 .chat-title {
79 margin: 0;
80 padding: 0;
81 color: rgba(82, 82, 82, 0.85);
82 font-size: 16px;
83 font-weight: normal;
84 font-style: italic;
85 white-space: nowrap;
86 overflow: hidden;
87 text-overflow: ellipsis;
88 }
89
90 /* View mode container styles - mirroring timeline.css structure */
91 .view-container {
92 max-width: 1200px;
93 margin: 0 auto;
94 margin-top: 65px; /* Space for the top banner */
95 margin-bottom: 90px; /* Increased space for the chat input */
96 position: relative;
97 padding-bottom: 15px; /* Additional padding to prevent clipping */
98 padding-top: 15px; /* Add padding at top to prevent content touching the header */
99 }
100
101 /* Allow the container to expand to full width in diff mode */
102 .view-container.diff-active {
103 max-width: 100%;
104 }
105
106 /* Individual view styles */
107 .chat-view,
108 .diff-view,
109 .chart-view,
110 .terminal-view {
111 display: none; /* Hidden by default */
112 width: 100%;
113 }
114
115 /* Active view styles - these will be applied via JavaScript */
116 .view-active {
117 display: flex;
118 flex-direction: column;
119 }
120
121 .title-container {
122 display: flex;
123 flex-direction: column;
124 white-space: nowrap;
125 overflow: hidden;
126 text-overflow: ellipsis;
127 max-width: 33%;
128 }
129
130 .refresh-control {
131 display: flex;
132 align-items: center;
133 margin-bottom: 0;
134 flex-wrap: nowrap;
135 white-space: nowrap;
136 flex-shrink: 0;
137 }
138
139 .refresh-button {
140 background: #4caf50;
141 color: white;
142 border: none;
143 padding: 4px 10px;
144 border-radius: 4px;
145 cursor: pointer;
146 font-size: 12px;
147 margin-right: 5px;
148 }
149
150 .stop-button:hover {
151 background-color: #c82333 !important;
152 }
153
154 .poll-updates {
155 display: flex;
156 align-items: center;
157 margin: 0 5px;
158 font-size: 12px;
159 }
160 `;
161
162 // Header bar: Network connection status details
163 @property()
164 connectionStatus: ConnectionStatus = "disconnected";
165
166 @property()
167 connectionErrorMessage: string = "";
168
169 @property()
170 messageStatus: string = "";
171
172 // Chat messages
173 @property()
174 messages: TimelineMessage[] = [];
175
176 @property()
177 chatMessageText: string = "";
178
179 @property()
180 title: string = "";
181
182 private dataManager = new DataManager();
183
184 @property()
185 containerState: State = { title: "", os: "", total_usage: {} };
186
187 // Track if this is the first load of messages
188 @state()
189 private isFirstLoad: boolean = true;
190
191 // Track if we should scroll to the bottom
192 @state()
193 private shouldScrollToBottom: boolean = true;
194
195 // Mutation observer to detect when new messages are added
196 private mutationObserver: MutationObserver | null = null;
197
198 constructor() {
199 super();
200
201 // Binding methods to this
202 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
203 this._handleDiffComment = this._handleDiffComment.bind(this);
204 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
205 this._handlePopState = this._handlePopState.bind(this);
206 }
207
208 // See https://lit.dev/docs/components/lifecycle/
209 connectedCallback() {
210 super.connectedCallback();
211
212 // Initialize client-side nav history.
213 const url = new URL(window.location.href);
214 const mode = url.searchParams.get("view") || "chat";
215 window.history.replaceState({ mode }, "", url.toString());
216
217 this.toggleViewMode(mode as ViewMode, false);
218 // Add popstate event listener to handle browser back/forward navigation
219 window.addEventListener(
220 "popstate",
221 this._handlePopState as EventListener);
222
223 // Add event listeners
224 window.addEventListener(
225 "view-mode-select",
226 this._handleViewModeSelect as EventListener
227 );
228 window.addEventListener(
229 "diff-comment",
230 this._handleDiffComment as EventListener
231 );
232 window.addEventListener(
233 "show-commit-diff",
234 this._handleShowCommitDiff as EventListener
235 );
236
237 // register event listeners
238 this.dataManager.addEventListener(
239 "dataChanged",
240 this.handleDataChanged.bind(this)
241 );
242 this.dataManager.addEventListener(
243 "connectionStatusChanged",
244 this.handleConnectionStatusChanged.bind(this)
245 );
246
247 // Initialize the data manager
248 this.dataManager.initialize();
249 }
250
251 // See https://lit.dev/docs/components/lifecycle/
252 disconnectedCallback() {
253 super.disconnectedCallback();
254 window.removeEventListener(
255 "popstate",
256 this._handlePopState as EventListener);
257
258 // Remove event listeners
259 window.removeEventListener(
260 "view-mode-select",
261 this._handleViewModeSelect as EventListener
262 );
263 window.removeEventListener(
264 "diff-comment",
265 this._handleDiffComment as EventListener
266 );
267 window.removeEventListener(
268 "show-commit-diff",
269 this._handleShowCommitDiff as EventListener
270 );
271
272 // unregister data manager event listeners
273 this.dataManager.removeEventListener(
274 "dataChanged",
275 this.handleDataChanged.bind(this)
276 );
277 this.dataManager.removeEventListener(
278 "connectionStatusChanged",
279 this.handleConnectionStatusChanged.bind(this)
280 );
281
282 // Disconnect mutation observer if it exists
283 if (this.mutationObserver) {
284 console.log("Auto-scroll: Disconnecting mutation observer");
285 this.mutationObserver.disconnect();
286 this.mutationObserver = null;
287 }
288 }
289
290 updateUrlForViewMode(
291 mode: "chat" | "diff" | "charts" | "terminal"
292 ): void {
293 // Get the current URL without search parameters
294 const url = new URL(window.location.href);
295
296 // Clear existing parameters
297 url.search = "";
298
299 // Only add view parameter if not in default chat view
300 if (mode !== "chat") {
301 url.searchParams.set("view", mode);
302 const diffView = this.shadowRoot?.querySelector(".diff-view") as SketchDiffView;
303
304 // If in diff view and there's a commit hash, include that too
305 if (mode === "diff" && diffView.commitHash) {
306 url.searchParams.set("commit", diffView.commitHash);
307 }
308 }
309
310 // Update the browser history without reloading the page
311 window.history.pushState({ mode }, "", url.toString());
312 }
313
314 _handlePopState(event) {
315 if (event.state && event.state.mode) {
316 this.toggleViewMode(event.state.mode, false);
317 } else {
318 this.toggleViewMode("chat", false);
319 }
320 }
321
322 /**
323 * Handle view mode selection event
324 */
325 private _handleViewModeSelect(event: CustomEvent) {
326 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
327 this.toggleViewMode(mode, true);
328 }
329
330 /**
331 * Handle show commit diff event
332 */
333 private _handleShowCommitDiff(event: CustomEvent) {
334 const { commitHash } = event.detail;
335 if (commitHash) {
336 this.showCommitDiff(commitHash);
337 }
338 }
339
340 /**
341 * Handle diff comment event
342 */
343 private _handleDiffComment(event: CustomEvent) {
344 const { comment } = event.detail;
345 if (!comment) return;
346
347 // Find the chat input textarea
348 const chatInput = this.shadowRoot?.querySelector("sketch-chat-input");
349 if (chatInput) {
350 // Update the chat input content using property
351 const currentContent = chatInput.getAttribute("content") || "";
352 const newContent = currentContent
353 ? `${currentContent}\n\n${comment}`
354 : comment;
355 chatInput.setAttribute("content", newContent);
356
357 // Dispatch an event to update the textarea value in the chat input component
358 const updateEvent = new CustomEvent("update-content", {
359 detail: { content: newContent },
360 bubbles: true,
361 composed: true,
362 });
363 chatInput.dispatchEvent(updateEvent);
364
365 // Switch back to chat view
366 this.toggleViewMode("chat", true);
367 }
368 }
369
370 /**
371 * Listen for commit diff event
372 * @param commitHash The commit hash to show diff for
373 */
374 public showCommitDiff(commitHash: string): void {
375 // Store the commit hash
376 this.currentCommitHash = commitHash;
377
378 // Switch to diff view
379 this.toggleViewMode("diff", true);
380
381 // Wait for DOM update to complete
382 this.updateComplete.then(() => {
383 // Get the diff view component
384 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
385 if (diffView) {
386 // Call the showCommitDiff method
387 (diffView as any).showCommitDiff(commitHash);
388 }
389 });
390 }
391
392 /**
393 * Toggle between different view modes: chat, diff, charts, terminal
394 */
395 public toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
396 // Don't do anything if the mode is already active
397 if (this.viewMode === mode) return;
398
399 // Update the view mode
400 this.viewMode = mode;
401
402 if (updateHistory) {
403 // Update URL with the current view mode
404 this.updateUrlForViewMode(mode);
405 }
406
407 // Wait for DOM update to complete
408 this.updateComplete.then(() => {
409 // Update active view
410 const viewContainer = this.shadowRoot?.querySelector(".view-container");
411 const chatView = this.shadowRoot?.querySelector(".chat-view");
412 const diffView = this.shadowRoot?.querySelector(".diff-view");
413 const chartView = this.shadowRoot?.querySelector(".chart-view");
414 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
415
416 // Remove active class from all views
417 chatView?.classList.remove("view-active");
418 diffView?.classList.remove("view-active");
419 chartView?.classList.remove("view-active");
420 terminalView?.classList.remove("view-active");
421
422 // Add/remove diff-active class on view container
423 if (mode === "diff") {
424 viewContainer?.classList.add("diff-active");
425 } else {
426 viewContainer?.classList.remove("diff-active");
427 }
428
429 // Add active class to the selected view
430 switch (mode) {
431 case "chat":
432 chatView?.classList.add("view-active");
433 break;
434 case "diff":
435 diffView?.classList.add("view-active");
436 // Load diff content if we have a diff view
437 const diffViewComp =
438 this.shadowRoot?.querySelector("sketch-diff-view");
439 if (diffViewComp && this.currentCommitHash) {
440 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
441 } else if (diffViewComp) {
442 (diffViewComp as any).loadDiffContent();
443 }
444 break;
445 case "charts":
446 chartView?.classList.add("view-active");
447 break;
448 case "terminal":
449 terminalView?.classList.add("view-active");
450 break;
451 }
452
453 // Update view mode buttons
454 const viewModeSelect = this.shadowRoot?.querySelector(
455 "sketch-view-mode-select"
456 );
457 if (viewModeSelect) {
458 const event = new CustomEvent("update-active-mode", {
459 detail: { mode },
460 bubbles: true,
461 composed: true,
462 });
463 viewModeSelect.dispatchEvent(event);
464 }
465
466 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
467 // When the chart is in the background, its container has a width of 0, so vega
468 // renders width 0 and only changes that width on a resize event.
469 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
470 window.dispatchEvent(new Event("resize"));
471 });
472 }
473
474 mergeAndDedupe(
475 arr1: TimelineMessage[],
476 arr2: TimelineMessage[]
477 ): TimelineMessage[] {
478 const mergedArray = [...arr1, ...arr2];
479 const seenIds = new Set<number>();
480 const toolCallResults = new Map<string, TimelineMessage>();
481
482 let ret: TimelineMessage[] = mergedArray
483 .filter((msg) => {
484 if (msg.type == "tool") {
485 toolCallResults.set(msg.tool_call_id, msg);
486 return false;
487 }
488 if (seenIds.has(msg.idx)) {
489 return false; // Skip if idx is already seen
490 }
491
492 seenIds.add(msg.idx);
493 return true;
494 })
495 .sort((a: TimelineMessage, b: TimelineMessage) => a.idx - b.idx);
496
497 // Attach any tool_call result messages to the original message's tool_call object.
498 ret.forEach((msg) => {
499 msg.tool_calls?.forEach((toolCall) => {
500 if (toolCallResults.has(toolCall.tool_call_id)) {
501 toolCall.result_message = toolCallResults.get(toolCall.tool_call_id);
502 }
503 });
504 });
505 return ret;
506 }
507
508 private handleDataChanged(eventData: {
509 state: State;
510 newMessages: TimelineMessage[];
511 isFirstFetch?: boolean;
512 }): void {
513 const { state, newMessages, isFirstFetch } = eventData;
514
515 // Check if this is the first data fetch or if there are new messages
516 if (isFirstFetch) {
517 console.log("Auto-scroll: First data fetch, will scroll to bottom");
518 this.isFirstLoad = true;
519 this.shouldScrollToBottom = true;
520 this.messageStatus = "Initial messages loaded";
521 } else if (newMessages && newMessages.length > 0) {
522 console.log(`Auto-scroll: Received ${newMessages.length} new messages`);
523 this.messageStatus = "Updated just now";
524 // Check if we should scroll before updating messages
525 this.shouldScrollToBottom = this.checkShouldScroll();
526 } else {
527 this.messageStatus = "No new messages";
528 }
529
530 // Update state if we received it
531 if (state) {
532 this.containerState = state;
533 this.title = state.title;
534 }
535
536 // Create a copy of the current messages before updating
537 const oldMessageCount = this.messages.length;
538
539 // Update messages
540 this.messages = this.mergeAndDedupe(this.messages, newMessages);
541
542 // Log information about the message update
543 if (this.messages.length > oldMessageCount) {
544 console.log(
545 `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}, shouldScroll=${this.shouldScrollToBottom}`
546 );
547 }
548 }
549
550 private handleConnectionStatusChanged(
551 status: ConnectionStatus,
552 errorMessage?: string
553 ): void {
554 this.connectionStatus = status;
555 this.connectionErrorMessage = errorMessage || "";
556 }
557
558 async _sendChat(e: CustomEvent) {
559 console.log("app shell: _sendChat", e);
560 const message = e.detail.message?.trim();
561 if (message == "") {
562 return;
563 }
564 try {
565 // Send the message to the server
566 const response = await fetch("chat", {
567 method: "POST",
568 headers: {
569 "Content-Type": "application/json",
570 },
571 body: JSON.stringify({ message }),
572 });
573
574 if (!response.ok) {
575 const errorData = await response.text();
576 throw new Error(`Server error: ${response.status} - ${errorData}`);
577 }
578 // Clear the input after successfully sending the message.
579 this.chatMessageText = "";
580
581 // Reset data manager state to force a full refresh after sending a message
582 // This ensures we get all messages in the correct order
583 // Use private API for now - TODO: add a resetState() method to DataManager
584 (this.dataManager as any).nextFetchIndex = 0;
585 (this.dataManager as any).currentFetchStartIndex = 0;
586
587 // Always scroll to bottom after sending a message
588 console.log("Auto-scroll: User sent a message, forcing scroll to bottom");
589 this.shouldScrollToBottom = true;
590
591 // // If in diff view, switch to conversation view
592 // if (this.viewMode === "diff") {
593 // await this.toggleViewMode("chat");
594 // }
595
596 // Refresh the timeline data to show the new message
597 await this.dataManager.fetchData();
598
599 // Force multiple scroll attempts to ensure the user message is visible
600 // This addresses potential timing issues with DOM updates
601 const forceScrollAttempts = () => {
602 console.log("Auto-scroll: Forcing scroll after user message");
603 this.shouldScrollToBottom = true;
604
605 // Update the timeline component's scroll state
606 const timeline = this.shadowRoot?.querySelector(
607 "sketch-timeline"
608 ) as any;
609 if (timeline && timeline.setShouldScrollToLatest) {
610 timeline.setShouldScrollToLatest(true);
611 timeline.scrollToLatest();
612 } else {
613 this.scrollToBottom();
614 }
615 };
616
617 // Make multiple scroll attempts with different timings
618 // This ensures we catch the DOM after various update stages
619 setTimeout(forceScrollAttempts, 100);
620 setTimeout(forceScrollAttempts, 300);
621 setTimeout(forceScrollAttempts, 600);
622 } catch (error) {
623 console.error("Error sending chat message:", error);
624 const statusText = document.getElementById("statusText");
625 if (statusText) {
626 statusText.textContent = "Error sending message";
627 }
628 }
629 }
630
631 render() {
632 return html`
633 <div class="top-banner">
634 <div class="title-container">
635 <h1 class="banner-title">sketch</h1>
636 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
637 </div>
638
639 <sketch-container-status
640 .state=${this.containerState}
641 ></sketch-container-status>
642
643 <div class="refresh-control">
644 <sketch-view-mode-select></sketch-view-mode-select>
645
646 <button id="stopButton" class="refresh-button stop-button">
647 Stop
648 </button>
649
650 <div class="poll-updates">
651 <input type="checkbox" id="pollToggle" checked />
652 <label for="pollToggle">Poll</label>
653 </div>
654
655 <sketch-network-status
656 message=${this.messageStatus}
657 connection=${this.connectionStatus}
658 error=${this.connectionErrorMessage}
659 ></sketch-network-status>
660 </div>
661 </div>
662
663 <div class="view-container">
664 <div class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}">
665 <sketch-timeline .messages=${this.messages}></sketch-timeline>
666 </div>
667
668 <div class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}">
669 <sketch-diff-view
670 .commitHash=${this.currentCommitHash}
671 ></sketch-diff-view>
672 </div>
673
674 <div
675 class="chart-view ${this.viewMode === "charts" ? "view-active" : ""}"
676 >
677 <sketch-charts .messages=${this.messages}></sketch-charts>
678 </div>
679
680 <div
681 class="terminal-view ${this.viewMode === "terminal"
682 ? "view-active"
683 : ""}"
684 >
685 <sketch-terminal></sketch-terminal>
686 </div>
687 </div>
688
689 <sketch-chat-input
690 .content=${this.chatMessageText}
691 @send-chat="${this._sendChat}"
692 ></sketch-chat-input>
693 `;
694 }
695
696 /**
697 * Check if the page should scroll to the bottom based on current view position
698 * @returns Boolean indicating if we should scroll to the bottom
699 */
700 private checkShouldScroll(): boolean {
701 // If we're not in chat view, don't auto-scroll
702 if (this.viewMode !== "chat") {
703 return false;
704 }
705
706 // More generous threshold - if we're within 500px of the bottom, auto-scroll
707 // This ensures we start scrolling sooner when new messages appear
708 const scrollPosition = window.scrollY;
709 const windowHeight = window.innerHeight;
710 const documentHeight = document.body.scrollHeight;
711 const distanceFromBottom = documentHeight - (scrollPosition + windowHeight);
712 const threshold = 500; // Increased threshold to be more responsive
713
714 return distanceFromBottom <= threshold;
715 }
716
717 /**
718 * Scroll to the bottom of the timeline
719 */
720 private scrollToBottom(): void {
721 if (!this.checkShouldScroll()) {
722 return;
723 }
724
725 this.scrollTo({ top: this.scrollHeight, behavior: "smooth" });
726 }
727
728 /**
729 * Called after the component's properties have been updated
730 */
731 updated(changedProperties: PropertyValues): void {
732 // If messages have changed, scroll to bottom if needed
733 if (changedProperties.has("messages") && this.messages.length > 0) {
734 setTimeout(() => this.scrollToBottom(), 50);
735 }
736 }
737
738 /**
739 * Lifecycle callback when component is first connected to DOM
740 */
741 firstUpdated(): void {
742 if (this.viewMode !== "chat") {
743 return;
744 }
745
746 // Initial scroll to bottom when component is first rendered
747 setTimeout(
748 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
749 50
750 );
751
752 const pollToggleCheckbox = this.renderRoot?.querySelector("#pollToggle") as HTMLInputElement;
753 pollToggleCheckbox?.addEventListener("change", () => {
754 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
755 if (!pollToggleCheckbox.checked) {
756 this.connectionStatus = "disabled";
757 this.messageStatus = "Polling stopped";
758 } else {
759 this.messageStatus = "Polling for updates...";
760 }
761 });
762 }
763}
764
765declare global {
766 interface HTMLElementTagNameMap {
767 "sketch-app-shell": SketchAppShell;
768 }
769}