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