blob: a0dd5aaa88d1d5da303bba7a3f795631c7b12b20 [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";
Sean McCulloughd9f13372025-04-21 15:08:49 -07004import { State, AgentMessage } from "../types";
Sean McCullough86b56862025-04-18 13:04:03 -07005import "./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";
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010014import { aggregateAgentMessages } from "./aggregateAgentMessages";
Sean McCullough86b56862025-04-18 13:04:03 -070015
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
Sean McCullough86b56862025-04-18 13:04:03 -070028 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
29 // Note that these styles only apply to the scope of this web component's
30 // shadow DOM node, so they won't leak out or collide with CSS declared in
31 // other components or the containing web page (...unless you want it to do that).
32 static styles = css`
33 :host {
34 display: block;
Sean McCullough71941bd2025-04-18 13:31:48 -070035 font-family:
36 system-ui,
37 -apple-system,
38 BlinkMacSystemFont,
39 "Segoe UI",
40 Roboto,
41 sans-serif;
Sean McCullough86b56862025-04-18 13:04:03 -070042 color: #333;
43 line-height: 1.4;
44 min-height: 100vh;
45 width: 100%;
46 position: relative;
47 overflow-x: hidden;
48 }
49
50 /* Top banner with combined elements */
51 .top-banner {
52 display: flex;
53 justify-content: space-between;
54 align-items: center;
55 padding: 5px 20px;
56 margin-bottom: 0;
57 border-bottom: 1px solid #eee;
58 gap: 10px;
59 position: fixed;
60 top: 0;
61 left: 0;
62 right: 0;
63 background: white;
64 z-index: 100;
65 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
66 max-width: 100%;
67 }
68
69 .banner-title {
70 font-size: 18px;
71 font-weight: 600;
72 margin: 0;
73 min-width: 6em;
74 white-space: nowrap;
75 overflow: hidden;
76 text-overflow: ellipsis;
77 }
78
79 .chat-title {
80 margin: 0;
81 padding: 0;
82 color: rgba(82, 82, 82, 0.85);
83 font-size: 16px;
84 font-weight: normal;
85 font-style: italic;
86 white-space: nowrap;
87 overflow: hidden;
88 text-overflow: ellipsis;
89 }
90
91 /* View mode container styles - mirroring timeline.css structure */
92 .view-container {
93 max-width: 1200px;
94 margin: 0 auto;
95 margin-top: 65px; /* Space for the top banner */
96 margin-bottom: 90px; /* Increased space for the chat input */
97 position: relative;
98 padding-bottom: 15px; /* Additional padding to prevent clipping */
99 padding-top: 15px; /* Add padding at top to prevent content touching the header */
100 }
101
102 /* Allow the container to expand to full width in diff mode */
103 .view-container.diff-active {
104 max-width: 100%;
105 }
106
107 /* Individual view styles */
108 .chat-view,
109 .diff-view,
110 .chart-view,
111 .terminal-view {
112 display: none; /* Hidden by default */
113 width: 100%;
114 }
115
116 /* Active view styles - these will be applied via JavaScript */
117 .view-active {
118 display: flex;
119 flex-direction: column;
120 }
121
122 .title-container {
123 display: flex;
124 flex-direction: column;
125 white-space: nowrap;
126 overflow: hidden;
127 text-overflow: ellipsis;
128 max-width: 33%;
129 }
130
131 .refresh-control {
132 display: flex;
133 align-items: center;
134 margin-bottom: 0;
135 flex-wrap: nowrap;
136 white-space: nowrap;
137 flex-shrink: 0;
138 }
139
140 .refresh-button {
141 background: #4caf50;
142 color: white;
143 border: none;
144 padding: 4px 10px;
145 border-radius: 4px;
146 cursor: pointer;
147 font-size: 12px;
148 margin-right: 5px;
149 }
150
151 .stop-button:hover {
152 background-color: #c82333 !important;
153 }
154
155 .poll-updates {
156 display: flex;
157 align-items: center;
158 margin: 0 5px;
159 font-size: 12px;
160 }
161 `;
162
163 // Header bar: Network connection status details
164 @property()
165 connectionStatus: ConnectionStatus = "disconnected";
166
167 @property()
168 connectionErrorMessage: string = "";
169
170 @property()
171 messageStatus: string = "";
172
173 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100174 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700175 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700176
177 @property()
178 chatMessageText: string = "";
179
180 @property()
181 title: string = "";
182
183 private dataManager = new DataManager();
184
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100185 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700186 containerState: State = {
187 title: "",
188 os: "",
189 message_count: 0,
190 hostname: "",
191 working_dir: "",
192 initial_commit: "",
193 };
Sean McCullough86b56862025-04-18 13:04:03 -0700194
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();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100200 console.log("Hello!");
Sean McCullough86b56862025-04-18 13:04:03 -0700201
202 // Binding methods to this
203 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
204 this._handleDiffComment = this._handleDiffComment.bind(this);
205 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
206 this._handlePopState = this._handlePopState.bind(this);
207 }
208
209 // See https://lit.dev/docs/components/lifecycle/
210 connectedCallback() {
211 super.connectedCallback();
212
213 // Initialize client-side nav history.
214 const url = new URL(window.location.href);
215 const mode = url.searchParams.get("view") || "chat";
216 window.history.replaceState({ mode }, "", url.toString());
217
218 this.toggleViewMode(mode as ViewMode, false);
219 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100220 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700221
222 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100223 window.addEventListener("view-mode-select", this._handleViewModeSelect);
224 window.addEventListener("diff-comment", this._handleDiffComment);
225 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700226
227 // register event listeners
228 this.dataManager.addEventListener(
229 "dataChanged",
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100230 this.handleDataChanged.bind(this)
Sean McCullough86b56862025-04-18 13:04:03 -0700231 );
232 this.dataManager.addEventListener(
233 "connectionStatusChanged",
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100234 this.handleConnectionStatusChanged.bind(this)
Sean McCullough86b56862025-04-18 13:04:03 -0700235 );
236
237 // Initialize the data manager
238 this.dataManager.initialize();
239 }
240
241 // See https://lit.dev/docs/components/lifecycle/
242 disconnectedCallback() {
243 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100244 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700245
246 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100247 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
248 window.removeEventListener("diff-comment", this._handleDiffComment);
249 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700250
251 // unregister data manager event listeners
252 this.dataManager.removeEventListener(
253 "dataChanged",
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100254 this.handleDataChanged.bind(this)
Sean McCullough86b56862025-04-18 13:04:03 -0700255 );
256 this.dataManager.removeEventListener(
257 "connectionStatusChanged",
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100258 this.handleConnectionStatusChanged.bind(this)
Sean McCullough86b56862025-04-18 13:04:03 -0700259 );
260
261 // Disconnect mutation observer if it exists
262 if (this.mutationObserver) {
263 console.log("Auto-scroll: Disconnecting mutation observer");
264 this.mutationObserver.disconnect();
265 this.mutationObserver = null;
266 }
267 }
268
Sean McCullough71941bd2025-04-18 13:31:48 -0700269 updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700270 // Get the current URL without search parameters
271 const url = new URL(window.location.href);
272
273 // Clear existing parameters
274 url.search = "";
275
276 // Only add view parameter if not in default chat view
277 if (mode !== "chat") {
278 url.searchParams.set("view", mode);
Sean McCullough71941bd2025-04-18 13:31:48 -0700279 const diffView = this.shadowRoot?.querySelector(
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100280 ".diff-view"
Sean McCullough71941bd2025-04-18 13:31:48 -0700281 ) as SketchDiffView;
Sean McCullough86b56862025-04-18 13:04:03 -0700282
283 // If in diff view and there's a commit hash, include that too
284 if (mode === "diff" && diffView.commitHash) {
285 url.searchParams.set("commit", diffView.commitHash);
286 }
287 }
288
289 // Update the browser history without reloading the page
290 window.history.pushState({ mode }, "", url.toString());
291 }
292
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100293 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700294 if (event.state && event.state.mode) {
295 this.toggleViewMode(event.state.mode, false);
296 } else {
297 this.toggleViewMode("chat", false);
298 }
299 }
300
301 /**
302 * Handle view mode selection event
303 */
304 private _handleViewModeSelect(event: CustomEvent) {
305 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
306 this.toggleViewMode(mode, true);
307 }
308
309 /**
310 * Handle show commit diff event
311 */
312 private _handleShowCommitDiff(event: CustomEvent) {
313 const { commitHash } = event.detail;
314 if (commitHash) {
315 this.showCommitDiff(commitHash);
316 }
317 }
318
319 /**
320 * Handle diff comment event
321 */
322 private _handleDiffComment(event: CustomEvent) {
323 const { comment } = event.detail;
324 if (!comment) return;
325
326 // Find the chat input textarea
327 const chatInput = this.shadowRoot?.querySelector("sketch-chat-input");
328 if (chatInput) {
329 // Update the chat input content using property
330 const currentContent = chatInput.getAttribute("content") || "";
331 const newContent = currentContent
332 ? `${currentContent}\n\n${comment}`
333 : comment;
334 chatInput.setAttribute("content", newContent);
335
336 // Dispatch an event to update the textarea value in the chat input component
337 const updateEvent = new CustomEvent("update-content", {
338 detail: { content: newContent },
339 bubbles: true,
340 composed: true,
341 });
342 chatInput.dispatchEvent(updateEvent);
Sean McCullough86b56862025-04-18 13:04:03 -0700343 }
344 }
345
346 /**
347 * Listen for commit diff event
348 * @param commitHash The commit hash to show diff for
349 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100350 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700351 // Store the commit hash
352 this.currentCommitHash = commitHash;
353
354 // Switch to diff view
Sean McCullough71941bd2025-04-18 13:31:48 -0700355 this.toggleViewMode("diff", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700356
357 // Wait for DOM update to complete
358 this.updateComplete.then(() => {
359 // Get the diff view component
360 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
361 if (diffView) {
362 // Call the showCommitDiff method
363 (diffView as any).showCommitDiff(commitHash);
364 }
365 });
366 }
367
368 /**
369 * Toggle between different view modes: chat, diff, charts, terminal
370 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100371 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700372 // Don't do anything if the mode is already active
373 if (this.viewMode === mode) return;
374
375 // Update the view mode
376 this.viewMode = mode;
377
378 if (updateHistory) {
379 // Update URL with the current view mode
380 this.updateUrlForViewMode(mode);
381 }
382
383 // Wait for DOM update to complete
384 this.updateComplete.then(() => {
385 // Update active view
386 const viewContainer = this.shadowRoot?.querySelector(".view-container");
387 const chatView = this.shadowRoot?.querySelector(".chat-view");
388 const diffView = this.shadowRoot?.querySelector(".diff-view");
389 const chartView = this.shadowRoot?.querySelector(".chart-view");
390 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
391
392 // Remove active class from all views
393 chatView?.classList.remove("view-active");
394 diffView?.classList.remove("view-active");
395 chartView?.classList.remove("view-active");
396 terminalView?.classList.remove("view-active");
397
398 // Add/remove diff-active class on view container
399 if (mode === "diff") {
400 viewContainer?.classList.add("diff-active");
401 } else {
402 viewContainer?.classList.remove("diff-active");
403 }
404
405 // Add active class to the selected view
406 switch (mode) {
407 case "chat":
408 chatView?.classList.add("view-active");
409 break;
410 case "diff":
411 diffView?.classList.add("view-active");
412 // Load diff content if we have a diff view
413 const diffViewComp =
414 this.shadowRoot?.querySelector("sketch-diff-view");
415 if (diffViewComp && this.currentCommitHash) {
416 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
417 } else if (diffViewComp) {
418 (diffViewComp as any).loadDiffContent();
419 }
420 break;
421 case "charts":
422 chartView?.classList.add("view-active");
423 break;
424 case "terminal":
425 terminalView?.classList.add("view-active");
426 break;
427 }
428
429 // Update view mode buttons
430 const viewModeSelect = this.shadowRoot?.querySelector(
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100431 "sketch-view-mode-select"
Sean McCullough86b56862025-04-18 13:04:03 -0700432 );
433 if (viewModeSelect) {
434 const event = new CustomEvent("update-active-mode", {
435 detail: { mode },
436 bubbles: true,
437 composed: true,
438 });
439 viewModeSelect.dispatchEvent(event);
440 }
441
442 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
443 // When the chart is in the background, its container has a width of 0, so vega
444 // renders width 0 and only changes that width on a resize event.
445 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
446 window.dispatchEvent(new Event("resize"));
447 });
448 }
449
Sean McCullough86b56862025-04-18 13:04:03 -0700450 private handleDataChanged(eventData: {
451 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700452 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700453 isFirstFetch?: boolean;
454 }): void {
455 const { state, newMessages, isFirstFetch } = eventData;
456
457 // Check if this is the first data fetch or if there are new messages
458 if (isFirstFetch) {
Sean McCullough86b56862025-04-18 13:04:03 -0700459 this.messageStatus = "Initial messages loaded";
460 } else if (newMessages && newMessages.length > 0) {
Sean McCullough86b56862025-04-18 13:04:03 -0700461 this.messageStatus = "Updated just now";
Sean McCullough86b56862025-04-18 13:04:03 -0700462 } else {
463 this.messageStatus = "No new messages";
464 }
465
466 // Update state if we received it
467 if (state) {
468 this.containerState = state;
469 this.title = state.title;
470 }
471
472 // Create a copy of the current messages before updating
473 const oldMessageCount = this.messages.length;
474
475 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100476 this.messages = aggregateAgentMessages(this.messages, newMessages);
Sean McCullough86b56862025-04-18 13:04:03 -0700477
478 // Log information about the message update
479 if (this.messages.length > oldMessageCount) {
480 console.log(
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100481 `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}`
Sean McCullough86b56862025-04-18 13:04:03 -0700482 );
483 }
484 }
485
486 private handleConnectionStatusChanged(
487 status: ConnectionStatus,
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100488 errorMessage?: string
Sean McCullough86b56862025-04-18 13:04:03 -0700489 ): void {
490 this.connectionStatus = status;
491 this.connectionErrorMessage = errorMessage || "";
492 }
493
494 async _sendChat(e: CustomEvent) {
495 console.log("app shell: _sendChat", e);
496 const message = e.detail.message?.trim();
497 if (message == "") {
498 return;
499 }
500 try {
501 // Send the message to the server
502 const response = await fetch("chat", {
503 method: "POST",
504 headers: {
505 "Content-Type": "application/json",
506 },
507 body: JSON.stringify({ message }),
508 });
509
510 if (!response.ok) {
511 const errorData = await response.text();
512 throw new Error(`Server error: ${response.status} - ${errorData}`);
513 }
514 // Clear the input after successfully sending the message.
515 this.chatMessageText = "";
516
517 // Reset data manager state to force a full refresh after sending a message
518 // This ensures we get all messages in the correct order
519 // Use private API for now - TODO: add a resetState() method to DataManager
520 (this.dataManager as any).nextFetchIndex = 0;
521 (this.dataManager as any).currentFetchStartIndex = 0;
522
Sean McCullough86b56862025-04-18 13:04:03 -0700523 // // If in diff view, switch to conversation view
524 // if (this.viewMode === "diff") {
525 // await this.toggleViewMode("chat");
526 // }
527
528 // Refresh the timeline data to show the new message
529 await this.dataManager.fetchData();
Sean McCullough86b56862025-04-18 13:04:03 -0700530 } catch (error) {
531 console.error("Error sending chat message:", error);
532 const statusText = document.getElementById("statusText");
533 if (statusText) {
534 statusText.textContent = "Error sending message";
535 }
536 }
537 }
538
539 render() {
540 return html`
541 <div class="top-banner">
542 <div class="title-container">
543 <h1 class="banner-title">sketch</h1>
544 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
545 </div>
546
547 <sketch-container-status
548 .state=${this.containerState}
549 ></sketch-container-status>
550
551 <div class="refresh-control">
552 <sketch-view-mode-select></sketch-view-mode-select>
553
554 <button id="stopButton" class="refresh-button stop-button">
555 Stop
556 </button>
557
558 <div class="poll-updates">
559 <input type="checkbox" id="pollToggle" checked />
560 <label for="pollToggle">Poll</label>
561 </div>
562
563 <sketch-network-status
564 message=${this.messageStatus}
565 connection=${this.connectionStatus}
566 error=${this.connectionErrorMessage}
567 ></sketch-network-status>
568 </div>
569 </div>
570
571 <div class="view-container">
572 <div class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}">
Sean McCullough2c5bba42025-04-20 19:33:17 -0700573 <sketch-timeline
574 .messages=${this.messages}
575 .scrollContainer=${this}
576 ></sketch-timeline>
Sean McCullough86b56862025-04-18 13:04:03 -0700577 </div>
578
579 <div class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}">
580 <sketch-diff-view
581 .commitHash=${this.currentCommitHash}
582 ></sketch-diff-view>
583 </div>
584
585 <div
586 class="chart-view ${this.viewMode === "charts" ? "view-active" : ""}"
587 >
588 <sketch-charts .messages=${this.messages}></sketch-charts>
589 </div>
590
591 <div
592 class="terminal-view ${this.viewMode === "terminal"
593 ? "view-active"
594 : ""}"
595 >
596 <sketch-terminal></sketch-terminal>
597 </div>
598 </div>
599
600 <sketch-chat-input
601 .content=${this.chatMessageText}
602 @send-chat="${this._sendChat}"
603 ></sketch-chat-input>
604 `;
605 }
606
607 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700608 * Lifecycle callback when component is first connected to DOM
609 */
610 firstUpdated(): void {
611 if (this.viewMode !== "chat") {
612 return;
613 }
614
615 // Initial scroll to bottom when component is first rendered
616 setTimeout(
617 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100618 50
Sean McCullough86b56862025-04-18 13:04:03 -0700619 );
620
Sean McCullough71941bd2025-04-18 13:31:48 -0700621 const pollToggleCheckbox = this.renderRoot?.querySelector(
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100622 "#pollToggle"
Sean McCullough71941bd2025-04-18 13:31:48 -0700623 ) as HTMLInputElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700624 pollToggleCheckbox?.addEventListener("change", () => {
625 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
626 if (!pollToggleCheckbox.checked) {
627 this.connectionStatus = "disabled";
628 this.messageStatus = "Polling stopped";
629 } else {
630 this.messageStatus = "Polling for updates...";
631 }
632 });
633 }
634}
635
636declare global {
637 interface HTMLElementTagNameMap {
638 "sketch-app-shell": SketchAppShell;
639 }
640}