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