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