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