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