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