blob: 070ed216b360fee6bf721c81de3b53738ab096e2 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
2import { html } from "lit";
3import { property, state } from "lit/decorators.js";
David Crawshaw77358442025-06-25 00:26:08 +00004import { ConnectionStatus, DataManager } from "../data";
philip.zeyliger26bc6592025-06-30 20:15:30 -07005import { AgentMessage, State } from "../types";
David Crawshaw77358442025-06-25 00:26:08 +00006import { aggregateAgentMessages } from "./aggregateAgentMessages";
7import { SketchTailwindElement } from "./sketch-tailwind-element";
8
9import "./sketch-chat-input";
10import "./sketch-container-status";
11
12import "./sketch-diff2-view";
13import { SketchDiff2View } from "./sketch-diff2-view";
14import { DefaultGitDataService } from "./git-data-service";
15import "./sketch-monaco-view";
16import "./sketch-network-status";
17import "./sketch-call-status";
18import "./sketch-terminal";
19import "./sketch-timeline";
20import "./sketch-view-mode-select";
21import "./sketch-todo-panel";
22
philip.zeyliger26bc6592025-06-30 20:15:30 -070023import { createRef } from "lit/directives/ref.js";
David Crawshaw77358442025-06-25 00:26:08 +000024import { SketchChatInput } from "./sketch-chat-input";
25
26type ViewMode = "chat" | "diff2" | "terminal";
27
28// Base class for sketch app shells - contains shared logic
29export abstract class SketchAppShellBase extends SketchTailwindElement {
30 // Current view mode (chat, diff, terminal)
31 @state()
32 viewMode: ViewMode = "chat";
33
34 // Current commit hash for diff view
35 @state()
36 currentCommitHash: string = "";
37
38 // Last commit information
39 @state()
40
41 // Reference to the container status element
42 containerStatusElement: any = null;
43
44 // Note: CSS styles have been converted to Tailwind classes applied directly to HTML elements
45 // since this component now extends SketchTailwindElement which disables shadow DOM
46
47 // Override createRenderRoot to apply host styles for proper sizing while still using light DOM
48 createRenderRoot() {
49 // Use light DOM like SketchTailwindElement but still apply host styles
50 const style = document.createElement("style");
51 style.textContent = `
52 sketch-app-shell {
53 display: block;
54 width: 100%;
55 height: 100vh;
56 max-width: 100%;
57 box-sizing: border-box;
58 overflow: hidden;
59 }
60 `;
61
62 // Add the style to the document head if not already present
63 if (!document.head.querySelector("style[data-sketch-app-shell]")) {
64 style.setAttribute("data-sketch-app-shell", "");
65 document.head.appendChild(style);
66 }
67
68 return this;
69 }
70
71 // Header bar: Network connection status details
72 @property()
73 connectionStatus: ConnectionStatus = "disconnected";
74
75 // Track if the last commit info has been copied
76 @state()
77 // lastCommitCopied moved to sketch-container-status
78
79 // Track notification preferences
80 @state()
81 notificationsEnabled: boolean = false;
82
83 // Track if the window is focused to control notifications
84 @state()
85 private _windowFocused: boolean = document.hasFocus();
86
87 // Track if the todo panel should be visible
88 @state()
89 protected _todoPanelVisible: boolean = false;
90
91 // Store scroll position for the chat view to preserve it when switching tabs
92 @state()
93 private _chatScrollPosition: number = 0;
94
95 // ResizeObserver for tracking chat input height changes
96 private chatInputResizeObserver: ResizeObserver | null = null;
97
98 @property()
99 connectionErrorMessage: string = "";
100
101 // Chat messages
102 @property({ attribute: false })
103 messages: AgentMessage[] = [];
104
105 @property()
106 set slug(value: string) {
107 const oldValue = this._slug;
108 this._slug = value;
109 this.requestUpdate("slug", oldValue);
110 // Update document title when slug property changes
111 this.updateDocumentTitle();
112 }
113
114 get slug(): string {
115 return this._slug;
116 }
117
118 private _slug: string = "";
119
120 private dataManager = new DataManager();
121
122 @property({ attribute: false })
123 containerState: State = {
124 state_version: 2,
125 slug: "",
126 os: "",
127 message_count: 0,
128 hostname: "",
129 working_dir: "",
130 initial_commit: "",
131 outstanding_llm_calls: 0,
132 outstanding_tool_calls: [],
133 session_id: "",
134 ssh_available: false,
135 ssh_error: "",
136 in_container: false,
137 first_message_index: 0,
138 diff_lines_added: 0,
139 diff_lines_removed: 0,
140 };
141
142 // Mutation observer to detect when new messages are added
143 private mutationObserver: MutationObserver | null = null;
144
145 constructor() {
146 super();
147
148 // Reference to the container status element
149 this.containerStatusElement = null;
150
151 // Binding methods to this
152 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
153 this._handlePopState = this._handlePopState.bind(this);
154 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
155 this._handleMutlipleChoiceSelected =
156 this._handleMutlipleChoiceSelected.bind(this);
157 this._handleStopClick = this._handleStopClick.bind(this);
158 this._handleEndClick = this._handleEndClick.bind(this);
159 this._handleNotificationsToggle =
160 this._handleNotificationsToggle.bind(this);
161 this._handleWindowFocus = this._handleWindowFocus.bind(this);
162 this._handleWindowBlur = this._handleWindowBlur.bind(this);
163
164 // Load notification preference from localStorage
165 try {
166 const savedPref = localStorage.getItem("sketch-notifications-enabled");
167 if (savedPref !== null) {
168 this.notificationsEnabled = savedPref === "true";
169 }
170 } catch (error) {
171 console.error("Error loading notification preference:", error);
172 }
173 }
174
175 // See https://lit.dev/docs/components/lifecycle/
176 connectedCallback() {
177 super.connectedCallback();
178
179 // Get reference to the container status element
180 setTimeout(() => {
Sean McCullough261c9112025-06-25 16:36:20 -0700181 this.containerStatusElement = this.querySelector("#container-status");
David Crawshaw77358442025-06-25 00:26:08 +0000182 }, 0);
183
184 // Initialize client-side nav history.
185 const url = new URL(window.location.href);
186 const mode = url.searchParams.get("view") || "chat";
187 window.history.replaceState({ mode }, "", url.toString());
188
189 this.toggleViewMode(mode as ViewMode, false);
190 // Add popstate event listener to handle browser back/forward navigation
191 window.addEventListener("popstate", this._handlePopState);
192
193 // Add event listeners
194 window.addEventListener("view-mode-select", this._handleViewModeSelect);
195 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
196
197 // Add window focus/blur listeners for controlling notifications
198 window.addEventListener("focus", this._handleWindowFocus);
199 window.addEventListener("blur", this._handleWindowBlur);
200 window.addEventListener(
201 "multiple-choice-selected",
202 this._handleMutlipleChoiceSelected,
203 );
204
205 // register event listeners
206 this.dataManager.addEventListener(
207 "dataChanged",
208 this.handleDataChanged.bind(this),
209 );
210 this.dataManager.addEventListener(
211 "connectionStatusChanged",
212 this.handleConnectionStatusChanged.bind(this),
213 );
214
215 // Set initial document title
216 this.updateDocumentTitle();
217
218 // Initialize the data manager
219 this.dataManager.initialize();
220
221 // Process existing messages for commit info
222 if (this.messages && this.messages.length > 0) {
223 // Update last commit info via container status component
224 setTimeout(() => {
225 if (this.containerStatusElement) {
226 this.containerStatusElement.updateLastCommitInfo(this.messages);
227 }
228 }, 100);
229 }
230
231 // Check if todo panel should be visible on initial load
232 this.checkTodoPanelVisibility();
233
234 // Set up ResizeObserver for chat input to update todo panel height
235 this.setupChatInputObserver();
236 }
237
238 // See https://lit.dev/docs/components/lifecycle/
239 disconnectedCallback() {
240 super.disconnectedCallback();
241 window.removeEventListener("popstate", this._handlePopState);
242
243 // Remove event listeners
244 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
245 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
246 window.removeEventListener("focus", this._handleWindowFocus);
247 window.removeEventListener("blur", this._handleWindowBlur);
248 window.removeEventListener(
249 "multiple-choice-selected",
250 this._handleMutlipleChoiceSelected,
251 );
252
253 // unregister data manager event listeners
254 this.dataManager.removeEventListener(
255 "dataChanged",
256 this.handleDataChanged.bind(this),
257 );
258 this.dataManager.removeEventListener(
259 "connectionStatusChanged",
260 this.handleConnectionStatusChanged.bind(this),
261 );
262
263 // Disconnect mutation observer if it exists
264 if (this.mutationObserver) {
265 this.mutationObserver.disconnect();
266 this.mutationObserver = null;
267 }
268
269 // Disconnect chat input resize observer if it exists
270 if (this.chatInputResizeObserver) {
271 this.chatInputResizeObserver.disconnect();
272 this.chatInputResizeObserver = null;
273 }
274 }
275
276 updateUrlForViewMode(mode: ViewMode): void {
277 // Get the current URL without search parameters
278 const url = new URL(window.location.href);
279
280 // Clear existing parameters
281 url.search = "";
282
283 // Only add view parameter if not in default chat view
284 if (mode !== "chat") {
285 url.searchParams.set("view", mode);
Sean McCullough261c9112025-06-25 16:36:20 -0700286 const diff2View = this.querySelector(
David Crawshaw77358442025-06-25 00:26:08 +0000287 "sketch-diff2-view",
288 ) as SketchDiff2View;
289
290 // If in diff2 view and there's a commit hash, include that too
291 if (mode === "diff2" && diff2View?.commit) {
292 url.searchParams.set("commit", diff2View.commit);
293 }
294 }
295
296 // Update the browser history without reloading the page
297 window.history.pushState({ mode }, "", url.toString());
298 }
299
300 private _handlePopState(event: PopStateEvent) {
301 if (event.state && event.state.mode) {
302 this.toggleViewMode(event.state.mode, false);
303 } else {
304 this.toggleViewMode("chat", false);
305 }
306 }
307
308 /**
309 * Handle view mode selection event
310 */
311 private _handleViewModeSelect(event: CustomEvent) {
312 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
313 this.toggleViewMode(mode, true);
314 }
315
316 /**
317 * Handle show commit diff event
318 */
319 private _handleShowCommitDiff(event: CustomEvent) {
320 const { commitHash } = event.detail;
321 if (commitHash) {
322 this.showCommitDiff(commitHash);
323 }
324 }
325
philip.zeyliger26bc6592025-06-30 20:15:30 -0700326 private _handleDiffComment(_event: CustomEvent) {
David Crawshaw77358442025-06-25 00:26:08 +0000327 // Empty stub required by the event binding in the template
328 // Actual handling occurs at global level in sketch-chat-input component
329 }
330 /**
331 * Listen for commit diff event
332 * @param commitHash The commit hash to show diff for
333 */
334 private showCommitDiff(commitHash: string): void {
335 // Store the commit hash
336 this.currentCommitHash = commitHash;
337
338 this.toggleViewMode("diff2", true);
339
340 this.updateComplete.then(() => {
Sean McCullough261c9112025-06-25 16:36:20 -0700341 const diff2View = this.querySelector("sketch-diff2-view");
David Crawshaw77358442025-06-25 00:26:08 +0000342 if (diff2View) {
343 (diff2View as SketchDiff2View).refreshDiffView();
344 }
345 });
346 }
347
348 /**
349 * Toggle between different view modes: chat, diff2, terminal
350 */
351 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
352 // Don't do anything if the mode is already active
353 if (this.viewMode === mode) return;
354
355 // Store scroll position if we're leaving the chat view
356 if (this.viewMode === "chat" && this.scrollContainerRef.value) {
357 // Only store scroll position if we actually have meaningful content
358 const scrollTop = this.scrollContainerRef.value.scrollTop;
359 const scrollHeight = this.scrollContainerRef.value.scrollHeight;
360 const clientHeight = this.scrollContainerRef.value.clientHeight;
361
362 // Store position only if we have scrollable content and have actually scrolled
363 if (scrollHeight > clientHeight && scrollTop > 0) {
364 this._chatScrollPosition = scrollTop;
365 }
366 }
367
368 // Update the view mode
369 this.viewMode = mode;
370
371 if (updateHistory) {
372 // Update URL with the current view mode
373 this.updateUrlForViewMode(mode);
374 }
375
376 // Wait for DOM update to complete
377 this.updateComplete.then(() => {
378 // Handle scroll position restoration for chat view
379 if (
380 mode === "chat" &&
381 this.scrollContainerRef.value &&
382 this._chatScrollPosition > 0
383 ) {
384 // Use requestAnimationFrame to ensure DOM is ready
385 requestAnimationFrame(() => {
386 if (this.scrollContainerRef.value) {
387 // Double-check that we're still in chat mode and the container is available
388 if (
389 this.viewMode === "chat" &&
390 this.scrollContainerRef.value.isConnected
391 ) {
392 this.scrollContainerRef.value.scrollTop =
393 this._chatScrollPosition;
394 }
395 }
396 });
397 }
398
399 // Handle diff2 view specific logic
400 if (mode === "diff2") {
401 // Refresh git/recentlog when Monaco diff view is opened
402 // This ensures branch information is always up-to-date, as branches can change frequently
403 const diff2ViewComp = this.querySelector("sketch-diff2-view");
404 if (diff2ViewComp) {
405 (diff2ViewComp as SketchDiff2View).refreshDiffView();
406 }
407 }
408
409 // Update view mode buttons
410 const viewModeSelect = this.querySelector("sketch-view-mode-select");
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 }
421
422 /**
423 * Updates the document title based on current slug and connection status
424 */
425 private updateDocumentTitle(): void {
426 let docTitle = `sk: ${this.slug || "untitled"}`;
427
428 // Add red circle emoji if disconnected
429 if (this.connectionStatus === "disconnected") {
430 docTitle += " 🔴";
431 }
432
433 document.title = docTitle;
434 }
435
436 // Check and request notification permission if needed
437 private async checkNotificationPermission(): Promise<boolean> {
438 // Check if the Notification API is supported
439 if (!("Notification" in window)) {
440 console.log("This browser does not support notifications");
441 return false;
442 }
443
444 // Check if permission is already granted
445 if (Notification.permission === "granted") {
446 return true;
447 }
448
449 // If permission is not denied, request it
450 if (Notification.permission !== "denied") {
451 const permission = await Notification.requestPermission();
452 return permission === "granted";
453 }
454
455 return false;
456 }
457
458 // Handle notifications toggle click
459 private _handleNotificationsToggle(): void {
460 this.notificationsEnabled = !this.notificationsEnabled;
461
462 // If enabling notifications, check permissions
463 if (this.notificationsEnabled) {
464 this.checkNotificationPermission();
465 }
466
467 // Save preference to localStorage
468 try {
469 localStorage.setItem(
470 "sketch-notifications-enabled",
471 String(this.notificationsEnabled),
472 );
473 } catch (error) {
474 console.error("Error saving notification preference:", error);
475 }
476 }
477
478 // Handle window focus event
479 private _handleWindowFocus(): void {
480 this._windowFocused = true;
481 }
482
483 // Handle window blur event
484 private _handleWindowBlur(): void {
485 this._windowFocused = false;
486 }
487
488 // Get the last user or agent message (ignore system messages like commit, error, etc.)
489 // For example, when Sketch notices a new commit, it'll send a message,
490 // but it's still idle!
491 private getLastUserOrAgentMessage(): AgentMessage | null {
492 for (let i = this.messages.length - 1; i >= 0; i--) {
493 const message = this.messages[i];
494 if (message.type === "user" || message.type === "agent") {
495 return message;
496 }
497 }
498 return null;
499 }
500
501 // Show notification for message with EndOfTurn=true
502 private async showEndOfTurnNotification(
503 message: AgentMessage,
504 ): Promise<void> {
505 // Don't show notifications if they're disabled
506 if (!this.notificationsEnabled) return;
507
508 // Don't show notifications if the window is focused
509 if (this._windowFocused) return;
510
511 // Check if we have permission to show notifications
512 const hasPermission = await this.checkNotificationPermission();
513 if (!hasPermission) return;
514
515 // Only show notifications for agent messages with end_of_turn=true and no parent_conversation_id
516 if (
517 message.type !== "agent" ||
518 !message.end_of_turn ||
519 message.parent_conversation_id
520 )
521 return;
522
523 // Create a title that includes the sketch slug
524 const notificationTitle = `Sketch: ${this.slug || "untitled"}`;
525
526 // Extract the beginning of the message content (first 100 chars)
527 const messagePreview = message.content
528 ? message.content.substring(0, 100) +
529 (message.content.length > 100 ? "..." : "")
530 : "Agent has completed its turn";
531
532 // Create and show the notification
533 try {
534 new Notification(notificationTitle, {
535 body: messagePreview,
536 icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
537 });
538 } catch (error) {
539 console.error("Error showing notification:", error);
540 }
541 }
542
543 // Check if todo panel should be visible based on latest todo content from messages or state
544 private checkTodoPanelVisibility(): void {
545 // Find the latest todo content from messages first
546 let latestTodoContent = "";
547 for (let i = this.messages.length - 1; i >= 0; i--) {
548 const message = this.messages[i];
549 if (message.todo_content !== undefined) {
550 latestTodoContent = message.todo_content || "";
551 break;
552 }
553 }
554
555 // If no todo content found in messages, check the current state
556 if (latestTodoContent === "" && this.containerState?.todo_content) {
557 latestTodoContent = this.containerState.todo_content;
558 }
559
560 // Parse the todo data to check if there are any actual todos
561 let hasTodos = false;
562 if (latestTodoContent.trim()) {
563 try {
564 const todoData = JSON.parse(latestTodoContent);
565 hasTodos = todoData.items && todoData.items.length > 0;
philip.zeyliger26bc6592025-06-30 20:15:30 -0700566 } catch {
David Crawshaw77358442025-06-25 00:26:08 +0000567 // Invalid JSON, treat as no todos
568 hasTodos = false;
569 }
570 }
571
572 this._todoPanelVisible = hasTodos;
573
574 // Update todo panel content if visible
575 if (hasTodos) {
Sean McCullough261c9112025-06-25 16:36:20 -0700576 const todoPanel = this.querySelector("sketch-todo-panel") as any;
David Crawshaw77358442025-06-25 00:26:08 +0000577 if (todoPanel && todoPanel.updateTodoContent) {
578 todoPanel.updateTodoContent(latestTodoContent);
579 }
580 }
581 }
582
583 private handleDataChanged(eventData: {
584 state: State;
585 newMessages: AgentMessage[];
586 }): void {
587 const { state, newMessages } = eventData;
588
589 // Update state if we received it
590 if (state) {
591 // Ensure we're using the latest call status to prevent indicators from being stuck
592 if (
593 state.outstanding_llm_calls === 0 &&
594 state.outstanding_tool_calls.length === 0
595 ) {
596 // Force reset containerState calls when nothing is reported as in progress
597 state.outstanding_llm_calls = 0;
598 state.outstanding_tool_calls = [];
599 }
600
601 this.containerState = state;
602 this.slug = state.slug || "";
603
604 // Update document title when sketch slug changes
605 this.updateDocumentTitle();
606 }
607
608 // Update messages
609 const oldMessageCount = this.messages.length;
610 this.messages = aggregateAgentMessages(this.messages, newMessages);
611
612 // If new messages were added and we're in chat view, reset stored scroll position
613 // so the timeline can auto-scroll to bottom for new content
614 if (this.messages.length > oldMessageCount && this.viewMode === "chat") {
615 // Only reset if we were near the bottom (indicating user wants to follow new messages)
616 if (this.scrollContainerRef.value) {
617 const scrollTop = this.scrollContainerRef.value.scrollTop;
618 const scrollHeight = this.scrollContainerRef.value.scrollHeight;
619 const clientHeight = this.scrollContainerRef.value.clientHeight;
620 const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px tolerance
621
622 if (isNearBottom) {
623 this._chatScrollPosition = 0; // Reset stored position to allow auto-scroll
624 }
625 }
626 }
627
628 // Process new messages to find commit messages
629 // Update last commit info via container status component
630 if (this.containerStatusElement) {
631 this.containerStatusElement.updateLastCommitInfo(newMessages);
632 }
633
634 // Check for agent messages with end_of_turn=true and show notifications
635 if (newMessages && newMessages.length > 0) {
636 for (const message of newMessages) {
637 if (
638 message.type === "agent" &&
639 message.end_of_turn &&
640 !message.parent_conversation_id
641 ) {
642 this.showEndOfTurnNotification(message);
643 break; // Only show one notification per batch of messages
644 }
645 }
646 }
647
648 // Check if todo panel should be visible after agent loop iteration
649 this.checkTodoPanelVisibility();
650
651 // Ensure chat input observer is set up when new data comes in
652 if (!this.chatInputResizeObserver) {
653 this.setupChatInputObserver();
654 }
655 }
656
657 private handleConnectionStatusChanged(
658 status: ConnectionStatus,
659 errorMessage?: string,
660 ): void {
661 this.connectionStatus = status;
662 this.connectionErrorMessage = errorMessage || "";
663
664 // Update document title when connection status changes
665 this.updateDocumentTitle();
666 }
667
668 private async _handleStopClick(): Promise<void> {
669 try {
670 const response = await fetch("cancel", {
671 method: "POST",
672 headers: {
673 "Content-Type": "application/json",
674 },
675 body: JSON.stringify({ reason: "user requested cancellation" }),
676 });
677
678 if (!response.ok) {
679 const errorData = await response.text();
680 throw new Error(
681 `Failed to stop operation: ${response.status} - ${errorData}`,
682 );
683 }
684
685 // Stop request sent
686 } catch (error) {
687 console.error("Error stopping operation:", error);
688 }
689 }
690
691 private async _handleEndClick(event?: Event): Promise<void> {
692 if (event) {
693 event.preventDefault();
694 event.stopPropagation();
695 }
696
697 // Show confirmation dialog
698 const confirmed = window.confirm(
699 "Ending the session will shut down the underlying container. Are you sure?",
700 );
701 if (!confirmed) return;
702
703 try {
704 const response = await fetch("end", {
705 method: "POST",
706 headers: {
707 "Content-Type": "application/json",
708 },
709 body: JSON.stringify({ reason: "user requested end of session" }),
710 });
711
712 if (!response.ok) {
713 const errorData = await response.text();
714 throw new Error(
715 `Failed to end session: ${response.status} - ${errorData}`,
716 );
717 }
718
719 // After successful response, redirect to messages view
720 // Extract the session ID from the URL
721 const currentUrl = window.location.href;
722 // The URL pattern should be like https://sketch.dev/s/cs71-8qa6-1124-aw79/
723 const urlParts = currentUrl.split("/");
724 let sessionId = "";
725
726 // Find the session ID in the URL (should be after /s/)
727 for (let i = 0; i < urlParts.length; i++) {
728 if (urlParts[i] === "s" && i + 1 < urlParts.length) {
729 sessionId = urlParts[i + 1];
730 break;
731 }
732 }
733
734 if (sessionId) {
735 // Create the messages URL
736 const messagesUrl = `/messages/${sessionId}`;
737 // Redirect to messages view
738 window.location.href = messagesUrl;
739 }
740
741 // End request sent - connection will be closed by server
742 } catch (error) {
743 console.error("Error ending session:", error);
744 }
745 }
746
747 async _handleMutlipleChoiceSelected(e: CustomEvent) {
Sean McCullough261c9112025-06-25 16:36:20 -0700748 const chatInput = this.querySelector(
David Crawshaw77358442025-06-25 00:26:08 +0000749 "sketch-chat-input",
750 ) as SketchChatInput;
751 if (chatInput) {
752 if (chatInput.content && chatInput.content.trim() !== "") {
753 chatInput.content += "\n\n";
754 }
755 chatInput.content += e.detail.responseText;
756 chatInput.focus();
757 // Adjust textarea height to accommodate new content
758 requestAnimationFrame(() => {
759 if (chatInput.adjustChatSpacing) {
760 chatInput.adjustChatSpacing();
761 }
762 });
763 }
764 }
765
766 async _sendChat(e: CustomEvent) {
767 console.log("app shell: _sendChat", e);
768 e.preventDefault();
769 e.stopPropagation();
770 const message = e.detail.message?.trim();
771 if (message == "") {
772 return;
773 }
774 try {
775 // Always switch to chat view when sending a message so user can see processing
776 if (this.viewMode !== "chat") {
777 this.toggleViewMode("chat", true);
778 }
779
780 // Send the message to the server
781 const response = await fetch("chat", {
782 method: "POST",
783 headers: {
784 "Content-Type": "application/json",
785 },
786 body: JSON.stringify({ message }),
787 });
788
789 if (!response.ok) {
790 const errorData = await response.text();
791 throw new Error(`Server error: ${response.status} - ${errorData}`);
792 }
793 } catch (error) {
794 console.error("Error sending chat message:", error);
795 const statusText = document.getElementById("statusText");
796 if (statusText) {
797 statusText.textContent = "Error sending message";
798 }
799 }
800 }
801
802 protected scrollContainerRef = createRef<HTMLElement>();
803
804 /**
805 * Set up ResizeObserver to monitor chat input height changes
806 */
807 private setupChatInputObserver(): void {
808 // Wait for DOM to be ready
809 this.updateComplete.then(() => {
Sean McCullough261c9112025-06-25 16:36:20 -0700810 const chatInputElement = this.querySelector("#chat-input");
David Crawshaw77358442025-06-25 00:26:08 +0000811 if (chatInputElement && !this.chatInputResizeObserver) {
812 this.chatInputResizeObserver = new ResizeObserver((entries) => {
813 for (const entry of entries) {
814 this.updateTodoPanelHeight(entry.contentRect.height);
815 }
816 });
817
818 this.chatInputResizeObserver.observe(chatInputElement);
819
820 // Initial height calculation
821 const rect = chatInputElement.getBoundingClientRect();
822 this.updateTodoPanelHeight(rect.height);
823 }
824 });
825 }
826
827 /**
828 * Update the CSS custom property that controls todo panel bottom position
829 */
830 private updateTodoPanelHeight(chatInputHeight: number): void {
831 // Add some padding (20px) between todo panel and chat input
832 const bottomOffset = chatInputHeight;
833
834 // Update the CSS custom property on the host element
835 this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
836 }
837
838 // Abstract method to be implemented by subclasses
839 abstract render(): any;
840
841 // Protected helper methods for subclasses to render common UI elements
842 protected renderTopBanner() {
843 return html`
844 <!-- Top banner: flex row, space between, border bottom, shadow -->
845 <div
846 id="top-banner"
847 class="flex self-stretch justify-between items-center px-5 pr-8 mb-0 border-b border-gray-200 gap-5 bg-white shadow-md w-full h-12"
848 >
849 <!-- Title container -->
850 <div
851 class="flex flex-col whitespace-nowrap overflow-hidden text-ellipsis max-w-[30%] md:max-w-1/2 sm:max-w-[60%] py-1.5"
852 >
853 <h1
854 class="text-lg md:text-base sm:text-sm font-semibold m-0 min-w-24 whitespace-nowrap overflow-hidden text-ellipsis"
855 >
856 ${this.containerState?.skaband_addr
857 ? html`<a
858 href="${this.containerState.skaband_addr}"
859 target="_blank"
860 rel="noopener noreferrer"
861 class="text-inherit no-underline transition-opacity duration-200 ease-in-out flex items-center gap-2 hover:opacity-80 hover:underline"
862 >
863 <img
864 src="${this.containerState.skaband_addr}/sketch.dev.png"
865 alt="sketch"
866 class="w-5 h-5 md:w-[18px] md:h-[18px] sm:w-4 sm:h-4 rounded-sm"
867 />
868 sketch
869 </a>`
870 : html`sketch`}
871 </h1>
872 <h2
873 class="m-0 p-0 text-gray-600 text-sm font-normal italic whitespace-nowrap overflow-hidden text-ellipsis"
874 >
875 ${this.slug}
876 </h2>
877 </div>
878
879 <!-- Container status info moved above tabs -->
880 <sketch-container-status
881 .state=${this.containerState}
882 id="container-status"
883 ></sketch-container-status>
884
885 <!-- Last Commit section moved to sketch-container-status -->
886
887 <!-- Views section with tabs -->
888 <sketch-view-mode-select
889 .diffLinesAdded=${this.containerState?.diff_lines_added || 0}
890 .diffLinesRemoved=${this.containerState?.diff_lines_removed || 0}
891 ></sketch-view-mode-select>
892
893 <!-- Control buttons and status -->
894 <div
895 class="flex items-center mb-0 flex-nowrap whitespace-nowrap flex-shrink-0 gap-4 pl-4 mr-12"
896 >
897 <button
898 id="stopButton"
Sean McCullough8b2bc8e2025-06-25 12:52:16 -0700899 class="bg-red-600 hover:bg-red-700 disabled:bg-red-300 disabled:cursor-not-allowed disabled:opacity-70 text-white border-none px-1.5 py-1 xl:px-2.5 rounded cursor-pointer text-xs mr-1.5 flex items-center gap-1.5 transition-colors"
David Crawshaw77358442025-06-25 00:26:08 +0000900 ?disabled=${(this.containerState?.outstanding_llm_calls || 0) ===
901 0 &&
902 (this.containerState?.outstanding_tool_calls || []).length === 0}
903 >
904 <svg
905 class="w-4 h-4"
906 xmlns="http://www.w3.org/2000/svg"
907 viewBox="0 0 24 24"
908 fill="none"
909 stroke="currentColor"
910 stroke-width="2"
911 stroke-linecap="round"
912 stroke-linejoin="round"
913 >
914 <rect x="6" y="6" width="12" height="12" />
915 </svg>
Sean McCullough8b2bc8e2025-06-25 12:52:16 -0700916 <span class="max-sm:hidden sm:max-xl:hidden">Stop</span>
David Crawshaw77358442025-06-25 00:26:08 +0000917 </button>
918 <button
919 id="endButton"
Sean McCullough8b2bc8e2025-06-25 12:52:16 -0700920 class="bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 text-white border-none px-1.5 py-1 xl:px-2.5 rounded cursor-pointer text-xs mr-1.5 flex items-center gap-1.5 transition-colors"
David Crawshaw77358442025-06-25 00:26:08 +0000921 @click=${this._handleEndClick}
922 >
923 <svg
924 class="w-4 h-4"
925 xmlns="http://www.w3.org/2000/svg"
926 viewBox="0 0 24 24"
927 fill="none"
928 stroke="currentColor"
929 stroke-width="2"
930 stroke-linecap="round"
931 stroke-linejoin="round"
932 >
933 <path d="M18 6L6 18" />
934 <path d="M6 6l12 12" />
935 </svg>
Sean McCullough8b2bc8e2025-06-25 12:52:16 -0700936 <span class="max-sm:hidden sm:max-xl:hidden">End</span>
David Crawshaw77358442025-06-25 00:26:08 +0000937 </button>
938
939 <div
940 class="flex items-center text-xs mr-2.5 cursor-pointer"
941 @click=${this._handleNotificationsToggle}
942 title="${this.notificationsEnabled
943 ? "Disable"
944 : "Enable"} notifications when the agent completes its turn"
945 >
946 <div
947 class="w-5 h-5 relative inline-flex items-center justify-center"
948 >
949 <!-- Bell SVG icon -->
950 <svg
951 xmlns="http://www.w3.org/2000/svg"
952 width="16"
953 height="16"
954 fill="currentColor"
955 viewBox="0 0 16 16"
956 class="${!this.notificationsEnabled ? "relative z-10" : ""}"
957 >
958 <path
959 d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"
960 />
961 </svg>
962 ${!this.notificationsEnabled
963 ? html`<div
964 class="absolute w-0.5 h-6 bg-red-600 rotate-45 origin-center"
965 ></div>`
966 : ""}
967 </div>
968 </div>
969
970 <sketch-call-status
971 .agentState=${this.containerState?.agent_state}
972 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
973 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
974 .isIdle=${(() => {
975 const lastUserOrAgentMessage = this.getLastUserOrAgentMessage();
976 return lastUserOrAgentMessage
977 ? lastUserOrAgentMessage.end_of_turn &&
978 !lastUserOrAgentMessage.parent_conversation_id
979 : true;
980 })()}
981 .isDisconnected=${this.connectionStatus === "disconnected"}
982 ></sketch-call-status>
983
984 <sketch-network-status
985 connection=${this.connectionStatus}
986 error=${this.connectionErrorMessage}
987 ></sketch-network-status>
988 </div>
989 </div>
990 `;
991 }
992
993 protected renderMainViews() {
994 return html`
995 <!-- Chat View -->
996 <div
997 class="chat-view ${this.viewMode === "chat"
998 ? "view-active flex flex-col"
999 : "hidden"} w-full h-full"
1000 >
1001 <div
1002 class="${this._todoPanelVisible && this.viewMode === "chat"
bankseana70dcc42025-06-25 20:54:11 +00001003 ? "mr-[400px] xl:mr-[350px] lg:mr-[300px] w-[calc(100%-400px)] xl:w-[calc(100%-350px)] lg:w-[calc(100%-300px)]"
David Crawshaw77358442025-06-25 00:26:08 +00001004 : "mr-0"} flex-1 flex flex-col w-full h-full transition-[margin-right] duration-200 ease-in-out"
1005 >
1006 <sketch-timeline
1007 .messages=${this.messages}
1008 .scrollContainer=${this.scrollContainerRef}
1009 .agentState=${this.containerState?.agent_state}
1010 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1011 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
1012 .firstMessageIndex=${this.containerState?.first_message_index || 0}
1013 .state=${this.containerState}
1014 .dataManager=${this.dataManager}
1015 ></sketch-timeline>
1016 </div>
1017 </div>
1018
1019 <!-- Todo panel positioned outside the main flow - only visible in chat view -->
1020 <div
1021 class="${this._todoPanelVisible && this.viewMode === "chat"
1022 ? "block"
bankseana70dcc42025-06-25 20:54:11 +00001023 : "hidden"} fixed top-12 right-4 max-lg:hidden xl:w-[350px] lg:w-[300px] z-[100] transition-[bottom] duration-200 ease-in-out"
David Crawshaw77358442025-06-25 00:26:08 +00001024 style="bottom: var(--chat-input-height, 90px); background: linear-gradient(to bottom, #fafafa 0%, #fafafa 90%, rgba(250, 250, 250, 0.5) 95%, rgba(250, 250, 250, 0.2) 100%); border-left: 1px solid #e0e0e0;"
1025 >
1026 <sketch-todo-panel
1027 .visible=${this._todoPanelVisible && this.viewMode === "chat"}
1028 ></sketch-todo-panel>
1029 </div>
1030 <!-- Diff2 View -->
1031 <div
1032 class="diff2-view ${this.viewMode === "diff2"
1033 ? "view-active flex-1 overflow-hidden min-h-0 flex flex-col h-full"
1034 : "hidden"} w-full h-full"
1035 >
1036 <sketch-diff2-view
1037 .commit=${this.currentCommitHash}
1038 .gitService=${new DefaultGitDataService()}
1039 @diff-comment="${this._handleDiffComment}"
1040 ></sketch-diff2-view>
1041 </div>
1042
1043 <!-- Terminal View -->
1044 <div
1045 class="terminal-view ${this.viewMode === "terminal"
1046 ? "view-active flex flex-col"
1047 : "hidden"} w-full h-full"
1048 >
1049 <sketch-terminal></sketch-terminal>
1050 </div>
1051 `;
1052 }
1053
1054 protected renderChatInput() {
1055 return html`
1056 <!-- Chat input fixed at bottom -->
1057 <div
1058 id="chat-input"
1059 class="self-end w-full shadow-[0_-2px_10px_rgba(0,0,0,0.1)]"
1060 >
1061 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
1062 </div>
1063 `;
1064 }
1065
1066 /**
1067 * Lifecycle callback when component is first connected to DOM
1068 */
1069 firstUpdated(): void {
1070 if (this.viewMode !== "chat") {
1071 return;
1072 }
1073
1074 // Initial scroll to bottom when component is first rendered
1075 setTimeout(
1076 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
1077 50,
1078 );
1079
1080 // Setup stop button
1081 const stopButton = this.renderRoot?.querySelector(
1082 "#stopButton",
1083 ) as HTMLButtonElement;
1084 stopButton?.addEventListener("click", async () => {
1085 try {
1086 const response = await fetch("cancel", {
1087 method: "POST",
1088 headers: {
1089 "Content-Type": "application/json",
1090 },
1091 body: JSON.stringify({ reason: "User clicked stop button" }),
1092 });
1093 if (!response.ok) {
1094 console.error("Failed to cancel:", await response.text());
1095 }
1096 } catch (error) {
1097 console.error("Error cancelling operation:", error);
1098 }
1099 });
1100
David Crawshaw77358442025-06-25 00:26:08 +00001101 // Process any existing messages to find commit information
1102 if (this.messages && this.messages.length > 0) {
1103 // Update last commit info via container status component
1104 if (this.containerStatusElement) {
1105 this.containerStatusElement.updateLastCommitInfo(this.messages);
1106 }
1107 }
1108
1109 // Set up chat input height observer for todo panel
1110 this.setupChatInputObserver();
1111 }
1112}
1113
1114// Export the ViewMode type for use in subclasses
1115export type { ViewMode };