blob: c22f9d9dfe7c70d8ebc874dfb5c56bb46f968565 [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
Pokey Rule4097e532025-04-24 18:55:28 +01003import { ConnectionStatus, DataManager } from "../data";
4import { AgentMessage, State } from "../types";
Pokey Rulee2a8c2f2025-04-23 15:09:25 +01005import { aggregateAgentMessages } from "./aggregateAgentMessages";
Pokey Rule4097e532025-04-24 18:55:28 +01006import "./sketch-charts";
7import "./sketch-chat-input";
8import "./sketch-container-status";
9import "./sketch-diff-view";
10import { SketchDiffView } from "./sketch-diff-view";
11import "./sketch-network-status";
12import "./sketch-terminal";
13import "./sketch-timeline";
14import "./sketch-view-mode-select";
15
16import { createRef, ref } from "lit/directives/ref.js";
Sean McCullough86b56862025-04-18 13:04:03 -070017
18type ViewMode = "chat" | "diff" | "charts" | "terminal";
19
20@customElement("sketch-app-shell")
21export class SketchAppShell extends LitElement {
22 // Current view mode (chat, diff, charts, terminal)
23 @state()
24 viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
25
26 // Current commit hash for diff view
27 @state()
28 currentCommitHash: string = "";
29
Sean McCullough86b56862025-04-18 13:04:03 -070030 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
31 // Note that these styles only apply to the scope of this web component's
32 // shadow DOM node, so they won't leak out or collide with CSS declared in
33 // other components or the containing web page (...unless you want it to do that).
34 static styles = css`
35 :host {
36 display: block;
Sean McCullough71941bd2025-04-18 13:31:48 -070037 font-family:
38 system-ui,
39 -apple-system,
40 BlinkMacSystemFont,
41 "Segoe UI",
42 Roboto,
43 sans-serif;
Sean McCullough86b56862025-04-18 13:04:03 -070044 color: #333;
45 line-height: 1.4;
Pokey Rule4097e532025-04-24 18:55:28 +010046 height: 100vh;
Sean McCullough86b56862025-04-18 13:04:03 -070047 width: 100%;
48 position: relative;
49 overflow-x: hidden;
Pokey Rule4097e532025-04-24 18:55:28 +010050 display: flex;
51 flex-direction: column;
Sean McCullough86b56862025-04-18 13:04:03 -070052 }
53
54 /* Top banner with combined elements */
Pokey Rule4097e532025-04-24 18:55:28 +010055 #top-banner {
Sean McCullough86b56862025-04-18 13:04:03 -070056 display: flex;
Pokey Rule4097e532025-04-24 18:55:28 +010057 align-self: flex-start;
Sean McCullough86b56862025-04-18 13:04:03 -070058 justify-content: space-between;
59 align-items: center;
60 padding: 5px 20px;
61 margin-bottom: 0;
62 border-bottom: 1px solid #eee;
63 gap: 10px;
Sean McCullough86b56862025-04-18 13:04:03 -070064 top: 0;
65 left: 0;
66 right: 0;
67 background: white;
Sean McCullough86b56862025-04-18 13:04:03 -070068 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
69 max-width: 100%;
70 }
71
Pokey Rule4097e532025-04-24 18:55:28 +010072 /* View mode container styles - mirroring timeline.css structure */
73 #view-container {
74 align-self: stretch;
75 overflow-y: auto;
76 flex: 1;
77 }
78
79 #view-container-inner {
80 max-width: 1200px;
81 margin: 0 auto;
82 position: relative;
83 padding-bottom: 10px;
84 padding-top: 10px;
85 }
86
87 #chat-input {
88 align-self: flex-end;
89 width: 100%;
90 box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
91 }
92
Sean McCullough86b56862025-04-18 13:04:03 -070093 .banner-title {
94 font-size: 18px;
95 font-weight: 600;
96 margin: 0;
97 min-width: 6em;
98 white-space: nowrap;
99 overflow: hidden;
100 text-overflow: ellipsis;
101 }
102
103 .chat-title {
104 margin: 0;
105 padding: 0;
106 color: rgba(82, 82, 82, 0.85);
107 font-size: 16px;
108 font-weight: normal;
109 font-style: italic;
110 white-space: nowrap;
111 overflow: hidden;
112 text-overflow: ellipsis;
113 }
114
Sean McCullough86b56862025-04-18 13:04:03 -0700115 /* Allow the container to expand to full width in diff mode */
Pokey Rule4097e532025-04-24 18:55:28 +0100116 #view-container.diff-active {
Sean McCullough86b56862025-04-18 13:04:03 -0700117 max-width: 100%;
118 }
119
120 /* Individual view styles */
121 .chat-view,
122 .diff-view,
123 .chart-view,
124 .terminal-view {
125 display: none; /* Hidden by default */
126 width: 100%;
127 }
128
129 /* Active view styles - these will be applied via JavaScript */
130 .view-active {
131 display: flex;
132 flex-direction: column;
133 }
134
135 .title-container {
136 display: flex;
137 flex-direction: column;
138 white-space: nowrap;
139 overflow: hidden;
140 text-overflow: ellipsis;
141 max-width: 33%;
142 }
143
144 .refresh-control {
145 display: flex;
146 align-items: center;
147 margin-bottom: 0;
148 flex-wrap: nowrap;
149 white-space: nowrap;
150 flex-shrink: 0;
151 }
152
153 .refresh-button {
154 background: #4caf50;
155 color: white;
156 border: none;
157 padding: 4px 10px;
158 border-radius: 4px;
159 cursor: pointer;
160 font-size: 12px;
161 margin-right: 5px;
162 }
163
164 .stop-button:hover {
165 background-color: #c82333 !important;
166 }
167
168 .poll-updates {
169 display: flex;
170 align-items: center;
171 margin: 0 5px;
172 font-size: 12px;
173 }
174 `;
175
176 // Header bar: Network connection status details
177 @property()
178 connectionStatus: ConnectionStatus = "disconnected";
179
180 @property()
181 connectionErrorMessage: string = "";
182
183 @property()
184 messageStatus: string = "";
185
186 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100187 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700188 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700189
190 @property()
Sean McCullough86b56862025-04-18 13:04:03 -0700191 title: string = "";
192
193 private dataManager = new DataManager();
194
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100195 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700196 containerState: State = {
197 title: "",
198 os: "",
199 message_count: 0,
200 hostname: "",
201 working_dir: "",
202 initial_commit: "",
203 };
Sean McCullough86b56862025-04-18 13:04:03 -0700204
Sean McCullough86b56862025-04-18 13:04:03 -0700205 // Mutation observer to detect when new messages are added
206 private mutationObserver: MutationObserver | null = null;
207
208 constructor() {
209 super();
210
211 // Binding methods to this
212 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700213 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
214 this._handlePopState = this._handlePopState.bind(this);
215 }
216
217 // See https://lit.dev/docs/components/lifecycle/
218 connectedCallback() {
219 super.connectedCallback();
220
221 // Initialize client-side nav history.
222 const url = new URL(window.location.href);
223 const mode = url.searchParams.get("view") || "chat";
224 window.history.replaceState({ mode }, "", url.toString());
225
226 this.toggleViewMode(mode as ViewMode, false);
227 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100228 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700229
230 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100231 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100232 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700233
234 // register event listeners
235 this.dataManager.addEventListener(
236 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700237 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700238 );
239 this.dataManager.addEventListener(
240 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700241 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700242 );
243
244 // Initialize the data manager
245 this.dataManager.initialize();
246 }
247
248 // See https://lit.dev/docs/components/lifecycle/
249 disconnectedCallback() {
250 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100251 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700252
253 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100254 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100255 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700256
257 // unregister data manager event listeners
258 this.dataManager.removeEventListener(
259 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700260 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700261 );
262 this.dataManager.removeEventListener(
263 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700264 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700265 );
266
267 // Disconnect mutation observer if it exists
268 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700269 this.mutationObserver.disconnect();
270 this.mutationObserver = null;
271 }
272 }
273
Sean McCullough71941bd2025-04-18 13:31:48 -0700274 updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700275 // Get the current URL without search parameters
276 const url = new URL(window.location.href);
277
278 // Clear existing parameters
279 url.search = "";
280
281 // Only add view parameter if not in default chat view
282 if (mode !== "chat") {
283 url.searchParams.set("view", mode);
Sean McCullough71941bd2025-04-18 13:31:48 -0700284 const diffView = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700285 ".diff-view",
Sean McCullough71941bd2025-04-18 13:31:48 -0700286 ) as SketchDiffView;
Sean McCullough86b56862025-04-18 13:04:03 -0700287
288 // If in diff view and there's a commit hash, include that too
289 if (mode === "diff" && diffView.commitHash) {
290 url.searchParams.set("commit", diffView.commitHash);
291 }
292 }
293
294 // Update the browser history without reloading the page
295 window.history.pushState({ mode }, "", url.toString());
296 }
297
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100298 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700299 if (event.state && event.state.mode) {
300 this.toggleViewMode(event.state.mode, false);
301 } else {
302 this.toggleViewMode("chat", false);
303 }
304 }
305
306 /**
307 * Handle view mode selection event
308 */
309 private _handleViewModeSelect(event: CustomEvent) {
310 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
311 this.toggleViewMode(mode, true);
312 }
313
314 /**
315 * Handle show commit diff event
316 */
317 private _handleShowCommitDiff(event: CustomEvent) {
318 const { commitHash } = event.detail;
319 if (commitHash) {
320 this.showCommitDiff(commitHash);
321 }
322 }
323
324 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700325 * Listen for commit diff event
326 * @param commitHash The commit hash to show diff for
327 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100328 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700329 // Store the commit hash
330 this.currentCommitHash = commitHash;
331
332 // Switch to diff view
Sean McCullough71941bd2025-04-18 13:31:48 -0700333 this.toggleViewMode("diff", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700334
335 // Wait for DOM update to complete
336 this.updateComplete.then(() => {
337 // Get the diff view component
338 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
339 if (diffView) {
340 // Call the showCommitDiff method
341 (diffView as any).showCommitDiff(commitHash);
342 }
343 });
344 }
345
346 /**
347 * Toggle between different view modes: chat, diff, charts, terminal
348 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100349 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700350 // Don't do anything if the mode is already active
351 if (this.viewMode === mode) return;
352
353 // Update the view mode
354 this.viewMode = mode;
355
356 if (updateHistory) {
357 // Update URL with the current view mode
358 this.updateUrlForViewMode(mode);
359 }
360
361 // Wait for DOM update to complete
362 this.updateComplete.then(() => {
363 // Update active view
Pokey Rule4097e532025-04-24 18:55:28 +0100364 const viewContainer = this.shadowRoot?.querySelector("#view-container");
Sean McCullough86b56862025-04-18 13:04:03 -0700365 const chatView = this.shadowRoot?.querySelector(".chat-view");
366 const diffView = this.shadowRoot?.querySelector(".diff-view");
367 const chartView = this.shadowRoot?.querySelector(".chart-view");
368 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
369
370 // Remove active class from all views
371 chatView?.classList.remove("view-active");
372 diffView?.classList.remove("view-active");
373 chartView?.classList.remove("view-active");
374 terminalView?.classList.remove("view-active");
375
376 // Add/remove diff-active class on view container
377 if (mode === "diff") {
378 viewContainer?.classList.add("diff-active");
379 } else {
380 viewContainer?.classList.remove("diff-active");
381 }
382
383 // Add active class to the selected view
384 switch (mode) {
385 case "chat":
386 chatView?.classList.add("view-active");
387 break;
388 case "diff":
389 diffView?.classList.add("view-active");
390 // Load diff content if we have a diff view
391 const diffViewComp =
392 this.shadowRoot?.querySelector("sketch-diff-view");
393 if (diffViewComp && this.currentCommitHash) {
394 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
395 } else if (diffViewComp) {
396 (diffViewComp as any).loadDiffContent();
397 }
398 break;
399 case "charts":
400 chartView?.classList.add("view-active");
401 break;
402 case "terminal":
403 terminalView?.classList.add("view-active");
404 break;
405 }
406
407 // Update view mode buttons
408 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700409 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700410 );
411 if (viewModeSelect) {
412 const event = new CustomEvent("update-active-mode", {
413 detail: { mode },
414 bubbles: true,
415 composed: true,
416 });
417 viewModeSelect.dispatchEvent(event);
418 }
419
420 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
421 // When the chart is in the background, its container has a width of 0, so vega
422 // renders width 0 and only changes that width on a resize event.
423 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
424 window.dispatchEvent(new Event("resize"));
425 });
426 }
427
Sean McCullough86b56862025-04-18 13:04:03 -0700428 private handleDataChanged(eventData: {
429 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700430 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700431 isFirstFetch?: boolean;
432 }): void {
433 const { state, newMessages, isFirstFetch } = eventData;
434
435 // Check if this is the first data fetch or if there are new messages
436 if (isFirstFetch) {
Sean McCullough86b56862025-04-18 13:04:03 -0700437 this.messageStatus = "Initial messages loaded";
438 } else if (newMessages && newMessages.length > 0) {
Sean McCullough86b56862025-04-18 13:04:03 -0700439 this.messageStatus = "Updated just now";
Sean McCullough86b56862025-04-18 13:04:03 -0700440 } else {
441 this.messageStatus = "No new messages";
442 }
443
444 // Update state if we received it
445 if (state) {
446 this.containerState = state;
447 this.title = state.title;
448 }
449
Sean McCullough86b56862025-04-18 13:04:03 -0700450 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100451 this.messages = aggregateAgentMessages(this.messages, newMessages);
Sean McCullough86b56862025-04-18 13:04:03 -0700452 }
453
454 private handleConnectionStatusChanged(
455 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -0700456 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -0700457 ): void {
458 this.connectionStatus = status;
459 this.connectionErrorMessage = errorMessage || "";
460 }
461
462 async _sendChat(e: CustomEvent) {
463 console.log("app shell: _sendChat", e);
464 const message = e.detail.message?.trim();
465 if (message == "") {
466 return;
467 }
468 try {
469 // Send the message to the server
470 const response = await fetch("chat", {
471 method: "POST",
472 headers: {
473 "Content-Type": "application/json",
474 },
475 body: JSON.stringify({ message }),
476 });
477
478 if (!response.ok) {
479 const errorData = await response.text();
480 throw new Error(`Server error: ${response.status} - ${errorData}`);
481 }
Sean McCullough86b56862025-04-18 13:04:03 -0700482
Philip Zeyliger73db6052025-04-23 13:09:07 -0700483 // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
Sean McCullough86b56862025-04-18 13:04:03 -0700484 // Reset data manager state to force a full refresh after sending a message
485 // This ensures we get all messages in the correct order
486 // Use private API for now - TODO: add a resetState() method to DataManager
487 (this.dataManager as any).nextFetchIndex = 0;
488 (this.dataManager as any).currentFetchStartIndex = 0;
489
Sean McCullough86b56862025-04-18 13:04:03 -0700490 // // If in diff view, switch to conversation view
491 // if (this.viewMode === "diff") {
492 // await this.toggleViewMode("chat");
493 // }
494
495 // Refresh the timeline data to show the new message
496 await this.dataManager.fetchData();
Sean McCullough86b56862025-04-18 13:04:03 -0700497 } catch (error) {
498 console.error("Error sending chat message:", error);
499 const statusText = document.getElementById("statusText");
500 if (statusText) {
501 statusText.textContent = "Error sending message";
502 }
503 }
504 }
505
Pokey Rule4097e532025-04-24 18:55:28 +0100506 private scrollContainerRef = createRef<HTMLElement>();
507
Sean McCullough86b56862025-04-18 13:04:03 -0700508 render() {
509 return html`
Pokey Rule4097e532025-04-24 18:55:28 +0100510 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -0700511 <div class="title-container">
512 <h1 class="banner-title">sketch</h1>
513 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
514 </div>
515
516 <sketch-container-status
517 .state=${this.containerState}
518 ></sketch-container-status>
519
520 <div class="refresh-control">
521 <sketch-view-mode-select></sketch-view-mode-select>
522
523 <button id="stopButton" class="refresh-button stop-button">
524 Stop
525 </button>
526
527 <div class="poll-updates">
528 <input type="checkbox" id="pollToggle" checked />
529 <label for="pollToggle">Poll</label>
530 </div>
531
532 <sketch-network-status
533 message=${this.messageStatus}
534 connection=${this.connectionStatus}
535 error=${this.connectionErrorMessage}
536 ></sketch-network-status>
537 </div>
538 </div>
539
Pokey Rule4097e532025-04-24 18:55:28 +0100540 <div id="view-container" ${ref(this.scrollContainerRef)}>
541 <div id="view-container-inner">
542 <div
543 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
544 >
545 <sketch-timeline
546 .messages=${this.messages}
547 .scrollContainer=${this.scrollContainerRef}
548 ></sketch-timeline>
549 </div>
550 <div
551 class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
552 >
553 <sketch-diff-view
554 .commitHash=${this.currentCommitHash}
555 ></sketch-diff-view>
556 </div>
557 <div
558 class="chart-view ${this.viewMode === "charts"
559 ? "view-active"
560 : ""}"
561 >
562 <sketch-charts .messages=${this.messages}></sketch-charts>
563 </div>
564 <div
565 class="terminal-view ${this.viewMode === "terminal"
566 ? "view-active"
567 : ""}"
568 >
569 <sketch-terminal></sketch-terminal>
570 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700571 </div>
572 </div>
573
Pokey Rule4097e532025-04-24 18:55:28 +0100574 <div id="chat-input">
575 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
576 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700577 `;
578 }
579
580 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700581 * Lifecycle callback when component is first connected to DOM
582 */
583 firstUpdated(): void {
584 if (this.viewMode !== "chat") {
585 return;
586 }
587
588 // Initial scroll to bottom when component is first rendered
589 setTimeout(
590 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -0700591 50,
Sean McCullough86b56862025-04-18 13:04:03 -0700592 );
593
Sean McCullough71941bd2025-04-18 13:31:48 -0700594 const pollToggleCheckbox = this.renderRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700595 "#pollToggle",
Sean McCullough71941bd2025-04-18 13:31:48 -0700596 ) as HTMLInputElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700597 pollToggleCheckbox?.addEventListener("change", () => {
598 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
599 if (!pollToggleCheckbox.checked) {
600 this.connectionStatus = "disabled";
601 this.messageStatus = "Polling stopped";
602 } else {
603 this.messageStatus = "Polling for updates...";
604 }
605 });
606 }
607}
608
609declare global {
610 interface HTMLElementTagNameMap {
611 "sketch-app-shell": SketchAppShell;
612 }
613}