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