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