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