skaband: create forked app shell for /newsessions with shared base

Factor out shared components from sketch/webui app shell and create
newsessions-specific version under skaband with pinned topbar/textarea layout.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s9fcdda62becc98f9k
diff --git a/webui/src/web-components/sketch-app-shell-base.ts b/webui/src/web-components/sketch-app-shell-base.ts
new file mode 100644
index 0000000..07ce713
--- /dev/null
+++ b/webui/src/web-components/sketch-app-shell-base.ts
@@ -0,0 +1,1128 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { ConnectionStatus, DataManager } from "../data";
+import { AgentMessage, GitLogEntry, State } from "../types";
+import { aggregateAgentMessages } from "./aggregateAgentMessages";
+import { SketchTailwindElement } from "./sketch-tailwind-element";
+
+import "./sketch-chat-input";
+import "./sketch-container-status";
+
+import "./sketch-diff2-view";
+import { SketchDiff2View } from "./sketch-diff2-view";
+import { DefaultGitDataService } from "./git-data-service";
+import "./sketch-monaco-view";
+import "./sketch-network-status";
+import "./sketch-call-status";
+import "./sketch-terminal";
+import "./sketch-timeline";
+import "./sketch-view-mode-select";
+import "./sketch-todo-panel";
+
+import { createRef, ref } from "lit/directives/ref.js";
+import { SketchChatInput } from "./sketch-chat-input";
+
+type ViewMode = "chat" | "diff2" | "terminal";
+
+// Base class for sketch app shells - contains shared logic
+export abstract class SketchAppShellBase extends SketchTailwindElement {
+  // Current view mode (chat, diff, terminal)
+  @state()
+  viewMode: ViewMode = "chat";
+
+  // Current commit hash for diff view
+  @state()
+  currentCommitHash: string = "";
+
+  // Last commit information
+  @state()
+
+  // Reference to the container status element
+  containerStatusElement: any = null;
+
+  // Note: CSS styles have been converted to Tailwind classes applied directly to HTML elements
+  // since this component now extends SketchTailwindElement which disables shadow DOM
+
+  // Override createRenderRoot to apply host styles for proper sizing while still using light DOM
+  createRenderRoot() {
+    // Use light DOM like SketchTailwindElement but still apply host styles
+    const style = document.createElement("style");
+    style.textContent = `
+      sketch-app-shell {
+        display: block;
+        width: 100%;
+        height: 100vh;
+        max-width: 100%;
+        box-sizing: border-box;
+        overflow: hidden;
+      }
+    `;
+
+    // Add the style to the document head if not already present
+    if (!document.head.querySelector("style[data-sketch-app-shell]")) {
+      style.setAttribute("data-sketch-app-shell", "");
+      document.head.appendChild(style);
+    }
+
+    return this;
+  }
+
+  // Header bar: Network connection status details
+  @property()
+  connectionStatus: ConnectionStatus = "disconnected";
+
+  // Track if the last commit info has been copied
+  @state()
+  // lastCommitCopied moved to sketch-container-status
+
+  // Track notification preferences
+  @state()
+  notificationsEnabled: boolean = false;
+
+  // Track if the window is focused to control notifications
+  @state()
+  private _windowFocused: boolean = document.hasFocus();
+
+  // Track if the todo panel should be visible
+  @state()
+  protected _todoPanelVisible: boolean = false;
+
+  // Store scroll position for the chat view to preserve it when switching tabs
+  @state()
+  private _chatScrollPosition: number = 0;
+
+  // ResizeObserver for tracking chat input height changes
+  private chatInputResizeObserver: ResizeObserver | null = null;
+
+  @property()
+  connectionErrorMessage: string = "";
+
+  // Chat messages
+  @property({ attribute: false })
+  messages: AgentMessage[] = [];
+
+  @property()
+  set slug(value: string) {
+    const oldValue = this._slug;
+    this._slug = value;
+    this.requestUpdate("slug", oldValue);
+    // Update document title when slug property changes
+    this.updateDocumentTitle();
+  }
+
+  get slug(): string {
+    return this._slug;
+  }
+
+  private _slug: string = "";
+
+  private dataManager = new DataManager();
+
+  @property({ attribute: false })
+  containerState: State = {
+    state_version: 2,
+    slug: "",
+    os: "",
+    message_count: 0,
+    hostname: "",
+    working_dir: "",
+    initial_commit: "",
+    outstanding_llm_calls: 0,
+    outstanding_tool_calls: [],
+    session_id: "",
+    ssh_available: false,
+    ssh_error: "",
+    in_container: false,
+    first_message_index: 0,
+    diff_lines_added: 0,
+    diff_lines_removed: 0,
+  };
+
+  // Mutation observer to detect when new messages are added
+  private mutationObserver: MutationObserver | null = null;
+
+  constructor() {
+    super();
+
+    // Reference to the container status element
+    this.containerStatusElement = null;
+
+    // Binding methods to this
+    this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
+    this._handlePopState = this._handlePopState.bind(this);
+    this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+    this._handleMutlipleChoiceSelected =
+      this._handleMutlipleChoiceSelected.bind(this);
+    this._handleStopClick = this._handleStopClick.bind(this);
+    this._handleEndClick = this._handleEndClick.bind(this);
+    this._handleNotificationsToggle =
+      this._handleNotificationsToggle.bind(this);
+    this._handleWindowFocus = this._handleWindowFocus.bind(this);
+    this._handleWindowBlur = this._handleWindowBlur.bind(this);
+
+    // Load notification preference from localStorage
+    try {
+      const savedPref = localStorage.getItem("sketch-notifications-enabled");
+      if (savedPref !== null) {
+        this.notificationsEnabled = savedPref === "true";
+      }
+    } catch (error) {
+      console.error("Error loading notification preference:", error);
+    }
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Get reference to the container status element
+    setTimeout(() => {
+      this.containerStatusElement =
+        this.shadowRoot?.getElementById("container-status");
+    }, 0);
+
+    // Initialize client-side nav history.
+    const url = new URL(window.location.href);
+    const mode = url.searchParams.get("view") || "chat";
+    window.history.replaceState({ mode }, "", url.toString());
+
+    this.toggleViewMode(mode as ViewMode, false);
+    // Add popstate event listener to handle browser back/forward navigation
+    window.addEventListener("popstate", this._handlePopState);
+
+    // Add event listeners
+    window.addEventListener("view-mode-select", this._handleViewModeSelect);
+    window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
+
+    // Add window focus/blur listeners for controlling notifications
+    window.addEventListener("focus", this._handleWindowFocus);
+    window.addEventListener("blur", this._handleWindowBlur);
+    window.addEventListener(
+      "multiple-choice-selected",
+      this._handleMutlipleChoiceSelected,
+    );
+
+    // register event listeners
+    this.dataManager.addEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this),
+    );
+    this.dataManager.addEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this),
+    );
+
+    // Set initial document title
+    this.updateDocumentTitle();
+
+    // Initialize the data manager
+    this.dataManager.initialize();
+
+    // Process existing messages for commit info
+    if (this.messages && this.messages.length > 0) {
+      // Update last commit info via container status component
+      setTimeout(() => {
+        if (this.containerStatusElement) {
+          this.containerStatusElement.updateLastCommitInfo(this.messages);
+        }
+      }, 100);
+    }
+
+    // Check if todo panel should be visible on initial load
+    this.checkTodoPanelVisibility();
+
+    // Set up ResizeObserver for chat input to update todo panel height
+    this.setupChatInputObserver();
+  }
+
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    window.removeEventListener("popstate", this._handlePopState);
+
+    // Remove event listeners
+    window.removeEventListener("view-mode-select", this._handleViewModeSelect);
+    window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
+    window.removeEventListener("focus", this._handleWindowFocus);
+    window.removeEventListener("blur", this._handleWindowBlur);
+    window.removeEventListener(
+      "multiple-choice-selected",
+      this._handleMutlipleChoiceSelected,
+    );
+
+    // unregister data manager event listeners
+    this.dataManager.removeEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this),
+    );
+    this.dataManager.removeEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this),
+    );
+
+    // Disconnect mutation observer if it exists
+    if (this.mutationObserver) {
+      this.mutationObserver.disconnect();
+      this.mutationObserver = null;
+    }
+
+    // Disconnect chat input resize observer if it exists
+    if (this.chatInputResizeObserver) {
+      this.chatInputResizeObserver.disconnect();
+      this.chatInputResizeObserver = null;
+    }
+  }
+
+  updateUrlForViewMode(mode: ViewMode): void {
+    // Get the current URL without search parameters
+    const url = new URL(window.location.href);
+
+    // Clear existing parameters
+    url.search = "";
+
+    // Only add view parameter if not in default chat view
+    if (mode !== "chat") {
+      url.searchParams.set("view", mode);
+      const diff2View = this.shadowRoot?.querySelector(
+        "sketch-diff2-view",
+      ) as SketchDiff2View;
+
+      // If in diff2 view and there's a commit hash, include that too
+      if (mode === "diff2" && diff2View?.commit) {
+        url.searchParams.set("commit", diff2View.commit);
+      }
+    }
+
+    // Update the browser history without reloading the page
+    window.history.pushState({ mode }, "", url.toString());
+  }
+
+  private _handlePopState(event: PopStateEvent) {
+    if (event.state && event.state.mode) {
+      this.toggleViewMode(event.state.mode, false);
+    } else {
+      this.toggleViewMode("chat", false);
+    }
+  }
+
+  /**
+   * Handle view mode selection event
+   */
+  private _handleViewModeSelect(event: CustomEvent) {
+    const mode = event.detail.mode as "chat" | "diff2" | "terminal";
+    this.toggleViewMode(mode, true);
+  }
+
+  /**
+   * Handle show commit diff event
+   */
+  private _handleShowCommitDiff(event: CustomEvent) {
+    const { commitHash } = event.detail;
+    if (commitHash) {
+      this.showCommitDiff(commitHash);
+    }
+  }
+
+  private _handleMultipleChoice(event: CustomEvent) {
+    window.console.log("_handleMultipleChoice", event);
+    this._sendChat;
+  }
+
+  private _handleDiffComment(event: CustomEvent) {
+    // Empty stub required by the event binding in the template
+    // Actual handling occurs at global level in sketch-chat-input component
+  }
+  /**
+   * Listen for commit diff event
+   * @param commitHash The commit hash to show diff for
+   */
+  private showCommitDiff(commitHash: string): void {
+    // Store the commit hash
+    this.currentCommitHash = commitHash;
+
+    this.toggleViewMode("diff2", true);
+
+    this.updateComplete.then(() => {
+      const diff2View = this.shadowRoot?.querySelector("sketch-diff2-view");
+      if (diff2View) {
+        (diff2View as SketchDiff2View).refreshDiffView();
+      }
+    });
+  }
+
+  /**
+   * Toggle between different view modes: chat, diff2, terminal
+   */
+  private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
+    // Don't do anything if the mode is already active
+    if (this.viewMode === mode) return;
+
+    // Store scroll position if we're leaving the chat view
+    if (this.viewMode === "chat" && this.scrollContainerRef.value) {
+      // Only store scroll position if we actually have meaningful content
+      const scrollTop = this.scrollContainerRef.value.scrollTop;
+      const scrollHeight = this.scrollContainerRef.value.scrollHeight;
+      const clientHeight = this.scrollContainerRef.value.clientHeight;
+
+      // Store position only if we have scrollable content and have actually scrolled
+      if (scrollHeight > clientHeight && scrollTop > 0) {
+        this._chatScrollPosition = scrollTop;
+      }
+    }
+
+    // Update the view mode
+    this.viewMode = mode;
+
+    if (updateHistory) {
+      // Update URL with the current view mode
+      this.updateUrlForViewMode(mode);
+    }
+
+    // Wait for DOM update to complete
+    this.updateComplete.then(() => {
+      // Handle scroll position restoration for chat view
+      if (
+        mode === "chat" &&
+        this.scrollContainerRef.value &&
+        this._chatScrollPosition > 0
+      ) {
+        // Use requestAnimationFrame to ensure DOM is ready
+        requestAnimationFrame(() => {
+          if (this.scrollContainerRef.value) {
+            // Double-check that we're still in chat mode and the container is available
+            if (
+              this.viewMode === "chat" &&
+              this.scrollContainerRef.value.isConnected
+            ) {
+              this.scrollContainerRef.value.scrollTop =
+                this._chatScrollPosition;
+            }
+          }
+        });
+      }
+
+      // Handle diff2 view specific logic
+      if (mode === "diff2") {
+        // Refresh git/recentlog when Monaco diff view is opened
+        // This ensures branch information is always up-to-date, as branches can change frequently
+        const diff2ViewComp = this.querySelector("sketch-diff2-view");
+        if (diff2ViewComp) {
+          (diff2ViewComp as SketchDiff2View).refreshDiffView();
+        }
+      }
+
+      // Update view mode buttons
+      const viewModeSelect = this.querySelector("sketch-view-mode-select");
+      if (viewModeSelect) {
+        const event = new CustomEvent("update-active-mode", {
+          detail: { mode },
+          bubbles: true,
+          composed: true,
+        });
+        viewModeSelect.dispatchEvent(event);
+      }
+    });
+  }
+
+  /**
+   * Updates the document title based on current slug and connection status
+   */
+  private updateDocumentTitle(): void {
+    let docTitle = `sk: ${this.slug || "untitled"}`;
+
+    // Add red circle emoji if disconnected
+    if (this.connectionStatus === "disconnected") {
+      docTitle += " 🔴";
+    }
+
+    document.title = docTitle;
+  }
+
+  // Check and request notification permission if needed
+  private async checkNotificationPermission(): Promise<boolean> {
+    // Check if the Notification API is supported
+    if (!("Notification" in window)) {
+      console.log("This browser does not support notifications");
+      return false;
+    }
+
+    // Check if permission is already granted
+    if (Notification.permission === "granted") {
+      return true;
+    }
+
+    // If permission is not denied, request it
+    if (Notification.permission !== "denied") {
+      const permission = await Notification.requestPermission();
+      return permission === "granted";
+    }
+
+    return false;
+  }
+
+  // Handle notifications toggle click
+  private _handleNotificationsToggle(): void {
+    this.notificationsEnabled = !this.notificationsEnabled;
+
+    // If enabling notifications, check permissions
+    if (this.notificationsEnabled) {
+      this.checkNotificationPermission();
+    }
+
+    // Save preference to localStorage
+    try {
+      localStorage.setItem(
+        "sketch-notifications-enabled",
+        String(this.notificationsEnabled),
+      );
+    } catch (error) {
+      console.error("Error saving notification preference:", error);
+    }
+  }
+
+  // Handle window focus event
+  private _handleWindowFocus(): void {
+    this._windowFocused = true;
+  }
+
+  // Handle window blur event
+  private _handleWindowBlur(): void {
+    this._windowFocused = false;
+  }
+
+  // Get the last user or agent message (ignore system messages like commit, error, etc.)
+  // For example, when Sketch notices a new commit, it'll send a message,
+  // but it's still idle!
+  private getLastUserOrAgentMessage(): AgentMessage | null {
+    for (let i = this.messages.length - 1; i >= 0; i--) {
+      const message = this.messages[i];
+      if (message.type === "user" || message.type === "agent") {
+        return message;
+      }
+    }
+    return null;
+  }
+
+  // Show notification for message with EndOfTurn=true
+  private async showEndOfTurnNotification(
+    message: AgentMessage,
+  ): Promise<void> {
+    // Don't show notifications if they're disabled
+    if (!this.notificationsEnabled) return;
+
+    // Don't show notifications if the window is focused
+    if (this._windowFocused) return;
+
+    // Check if we have permission to show notifications
+    const hasPermission = await this.checkNotificationPermission();
+    if (!hasPermission) return;
+
+    // Only show notifications for agent messages with end_of_turn=true and no parent_conversation_id
+    if (
+      message.type !== "agent" ||
+      !message.end_of_turn ||
+      message.parent_conversation_id
+    )
+      return;
+
+    // Create a title that includes the sketch slug
+    const notificationTitle = `Sketch: ${this.slug || "untitled"}`;
+
+    // Extract the beginning of the message content (first 100 chars)
+    const messagePreview = message.content
+      ? message.content.substring(0, 100) +
+        (message.content.length > 100 ? "..." : "")
+      : "Agent has completed its turn";
+
+    // Create and show the notification
+    try {
+      new Notification(notificationTitle, {
+        body: messagePreview,
+        icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
+      });
+    } catch (error) {
+      console.error("Error showing notification:", error);
+    }
+  }
+
+  // Check if todo panel should be visible based on latest todo content from messages or state
+  private checkTodoPanelVisibility(): void {
+    // Find the latest todo content from messages first
+    let latestTodoContent = "";
+    for (let i = this.messages.length - 1; i >= 0; i--) {
+      const message = this.messages[i];
+      if (message.todo_content !== undefined) {
+        latestTodoContent = message.todo_content || "";
+        break;
+      }
+    }
+
+    // If no todo content found in messages, check the current state
+    if (latestTodoContent === "" && this.containerState?.todo_content) {
+      latestTodoContent = this.containerState.todo_content;
+    }
+
+    // Parse the todo data to check if there are any actual todos
+    let hasTodos = false;
+    if (latestTodoContent.trim()) {
+      try {
+        const todoData = JSON.parse(latestTodoContent);
+        hasTodos = todoData.items && todoData.items.length > 0;
+      } catch (error) {
+        // Invalid JSON, treat as no todos
+        hasTodos = false;
+      }
+    }
+
+    this._todoPanelVisible = hasTodos;
+
+    // Update todo panel content if visible
+    if (hasTodos) {
+      const todoPanel = this.shadowRoot?.querySelector(
+        "sketch-todo-panel",
+      ) as any;
+      if (todoPanel && todoPanel.updateTodoContent) {
+        todoPanel.updateTodoContent(latestTodoContent);
+      }
+    }
+  }
+
+  private handleDataChanged(eventData: {
+    state: State;
+    newMessages: AgentMessage[];
+  }): void {
+    const { state, newMessages } = eventData;
+
+    // Update state if we received it
+    if (state) {
+      // Ensure we're using the latest call status to prevent indicators from being stuck
+      if (
+        state.outstanding_llm_calls === 0 &&
+        state.outstanding_tool_calls.length === 0
+      ) {
+        // Force reset containerState calls when nothing is reported as in progress
+        state.outstanding_llm_calls = 0;
+        state.outstanding_tool_calls = [];
+      }
+
+      this.containerState = state;
+      this.slug = state.slug || "";
+
+      // Update document title when sketch slug changes
+      this.updateDocumentTitle();
+    }
+
+    // Update messages
+    const oldMessageCount = this.messages.length;
+    this.messages = aggregateAgentMessages(this.messages, newMessages);
+
+    // If new messages were added and we're in chat view, reset stored scroll position
+    // so the timeline can auto-scroll to bottom for new content
+    if (this.messages.length > oldMessageCount && this.viewMode === "chat") {
+      // Only reset if we were near the bottom (indicating user wants to follow new messages)
+      if (this.scrollContainerRef.value) {
+        const scrollTop = this.scrollContainerRef.value.scrollTop;
+        const scrollHeight = this.scrollContainerRef.value.scrollHeight;
+        const clientHeight = this.scrollContainerRef.value.clientHeight;
+        const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px tolerance
+
+        if (isNearBottom) {
+          this._chatScrollPosition = 0; // Reset stored position to allow auto-scroll
+        }
+      }
+    }
+
+    // Process new messages to find commit messages
+    // Update last commit info via container status component
+    if (this.containerStatusElement) {
+      this.containerStatusElement.updateLastCommitInfo(newMessages);
+    }
+
+    // Check for agent messages with end_of_turn=true and show notifications
+    if (newMessages && newMessages.length > 0) {
+      for (const message of newMessages) {
+        if (
+          message.type === "agent" &&
+          message.end_of_turn &&
+          !message.parent_conversation_id
+        ) {
+          this.showEndOfTurnNotification(message);
+          break; // Only show one notification per batch of messages
+        }
+      }
+    }
+
+    // Check if todo panel should be visible after agent loop iteration
+    this.checkTodoPanelVisibility();
+
+    // Ensure chat input observer is set up when new data comes in
+    if (!this.chatInputResizeObserver) {
+      this.setupChatInputObserver();
+    }
+  }
+
+  private handleConnectionStatusChanged(
+    status: ConnectionStatus,
+    errorMessage?: string,
+  ): void {
+    this.connectionStatus = status;
+    this.connectionErrorMessage = errorMessage || "";
+
+    // Update document title when connection status changes
+    this.updateDocumentTitle();
+  }
+
+  private async _handleStopClick(): Promise<void> {
+    try {
+      const response = await fetch("cancel", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({ reason: "user requested cancellation" }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.text();
+        throw new Error(
+          `Failed to stop operation: ${response.status} - ${errorData}`,
+        );
+      }
+
+      // Stop request sent
+    } catch (error) {
+      console.error("Error stopping operation:", error);
+    }
+  }
+
+  private async _handleEndClick(event?: Event): Promise<void> {
+    if (event) {
+      event.preventDefault();
+      event.stopPropagation();
+    }
+
+    // Show confirmation dialog
+    const confirmed = window.confirm(
+      "Ending the session will shut down the underlying container. Are you sure?",
+    );
+    if (!confirmed) return;
+
+    try {
+      const response = await fetch("end", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({ reason: "user requested end of session" }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.text();
+        throw new Error(
+          `Failed to end session: ${response.status} - ${errorData}`,
+        );
+      }
+
+      // After successful response, redirect to messages view
+      // Extract the session ID from the URL
+      const currentUrl = window.location.href;
+      // The URL pattern should be like https://sketch.dev/s/cs71-8qa6-1124-aw79/
+      const urlParts = currentUrl.split("/");
+      let sessionId = "";
+
+      // Find the session ID in the URL (should be after /s/)
+      for (let i = 0; i < urlParts.length; i++) {
+        if (urlParts[i] === "s" && i + 1 < urlParts.length) {
+          sessionId = urlParts[i + 1];
+          break;
+        }
+      }
+
+      if (sessionId) {
+        // Create the messages URL
+        const messagesUrl = `/messages/${sessionId}`;
+        // Redirect to messages view
+        window.location.href = messagesUrl;
+      }
+
+      // End request sent - connection will be closed by server
+    } catch (error) {
+      console.error("Error ending session:", error);
+    }
+  }
+
+  async _handleMutlipleChoiceSelected(e: CustomEvent) {
+    const chatInput = this.shadowRoot?.querySelector(
+      "sketch-chat-input",
+    ) as SketchChatInput;
+    if (chatInput) {
+      if (chatInput.content && chatInput.content.trim() !== "") {
+        chatInput.content += "\n\n";
+      }
+      chatInput.content += e.detail.responseText;
+      chatInput.focus();
+      // Adjust textarea height to accommodate new content
+      requestAnimationFrame(() => {
+        if (chatInput.adjustChatSpacing) {
+          chatInput.adjustChatSpacing();
+        }
+      });
+    }
+  }
+
+  async _sendChat(e: CustomEvent) {
+    console.log("app shell: _sendChat", e);
+    e.preventDefault();
+    e.stopPropagation();
+    const message = e.detail.message?.trim();
+    if (message == "") {
+      return;
+    }
+    try {
+      // Always switch to chat view when sending a message so user can see processing
+      if (this.viewMode !== "chat") {
+        this.toggleViewMode("chat", true);
+      }
+
+      // Send the message to the server
+      const response = await fetch("chat", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({ message }),
+      });
+
+      if (!response.ok) {
+        const errorData = await response.text();
+        throw new Error(`Server error: ${response.status} - ${errorData}`);
+      }
+    } catch (error) {
+      console.error("Error sending chat message:", error);
+      const statusText = document.getElementById("statusText");
+      if (statusText) {
+        statusText.textContent = "Error sending message";
+      }
+    }
+  }
+
+  protected scrollContainerRef = createRef<HTMLElement>();
+
+  /**
+   * Set up ResizeObserver to monitor chat input height changes
+   */
+  private setupChatInputObserver(): void {
+    // Wait for DOM to be ready
+    this.updateComplete.then(() => {
+      const chatInputElement = this.shadowRoot?.querySelector("#chat-input");
+      if (chatInputElement && !this.chatInputResizeObserver) {
+        this.chatInputResizeObserver = new ResizeObserver((entries) => {
+          for (const entry of entries) {
+            this.updateTodoPanelHeight(entry.contentRect.height);
+          }
+        });
+
+        this.chatInputResizeObserver.observe(chatInputElement);
+
+        // Initial height calculation
+        const rect = chatInputElement.getBoundingClientRect();
+        this.updateTodoPanelHeight(rect.height);
+      }
+    });
+  }
+
+  /**
+   * Update the CSS custom property that controls todo panel bottom position
+   */
+  private updateTodoPanelHeight(chatInputHeight: number): void {
+    // Add some padding (20px) between todo panel and chat input
+    const bottomOffset = chatInputHeight;
+
+    // Update the CSS custom property on the host element
+    this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
+  }
+
+  // Abstract method to be implemented by subclasses
+  abstract render(): any;
+
+  // Protected helper methods for subclasses to render common UI elements
+  protected renderTopBanner() {
+    return html`
+      <!-- Top banner: flex row, space between, border bottom, shadow -->
+      <div
+        id="top-banner"
+        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"
+      >
+        <!-- Title container -->
+        <div
+          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"
+        >
+          <h1
+            class="text-lg md:text-base sm:text-sm font-semibold m-0 min-w-24 whitespace-nowrap overflow-hidden text-ellipsis"
+          >
+            ${this.containerState?.skaband_addr
+              ? html`<a
+                  href="${this.containerState.skaband_addr}"
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  class="text-inherit no-underline transition-opacity duration-200 ease-in-out flex items-center gap-2 hover:opacity-80 hover:underline"
+                >
+                  <img
+                    src="${this.containerState.skaband_addr}/sketch.dev.png"
+                    alt="sketch"
+                    class="w-5 h-5 md:w-[18px] md:h-[18px] sm:w-4 sm:h-4 rounded-sm"
+                  />
+                  sketch
+                </a>`
+              : html`sketch`}
+          </h1>
+          <h2
+            class="m-0 p-0 text-gray-600 text-sm font-normal italic whitespace-nowrap overflow-hidden text-ellipsis"
+          >
+            ${this.slug}
+          </h2>
+        </div>
+
+        <!-- Container status info moved above tabs -->
+        <sketch-container-status
+          .state=${this.containerState}
+          id="container-status"
+        ></sketch-container-status>
+
+        <!-- Last Commit section moved to sketch-container-status -->
+
+        <!-- Views section with tabs -->
+        <sketch-view-mode-select
+          .diffLinesAdded=${this.containerState?.diff_lines_added || 0}
+          .diffLinesRemoved=${this.containerState?.diff_lines_removed || 0}
+        ></sketch-view-mode-select>
+
+        <!-- Control buttons and status -->
+        <div
+          class="flex items-center mb-0 flex-nowrap whitespace-nowrap flex-shrink-0 gap-4 pl-4 mr-12"
+        >
+          <button
+            id="stopButton"
+            class="bg-red-600 hover:bg-red-700 disabled:bg-red-300 disabled:cursor-not-allowed disabled:opacity-70 text-white border-none px-2.5 py-1 xl:px-1.5 rounded cursor-pointer text-xs mr-1.5 flex items-center gap-1.5 transition-colors"
+            ?disabled=${(this.containerState?.outstanding_llm_calls || 0) ===
+              0 &&
+            (this.containerState?.outstanding_tool_calls || []).length === 0}
+          >
+            <svg
+              class="w-4 h-4"
+              xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2"
+              stroke-linecap="round"
+              stroke-linejoin="round"
+            >
+              <rect x="6" y="6" width="12" height="12" />
+            </svg>
+            <span class="xl:hidden">Stop</span>
+          </button>
+          <button
+            id="endButton"
+            class="bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 text-white border-none px-2.5 py-1 xl:px-1.5 rounded cursor-pointer text-xs mr-1.5 flex items-center gap-1.5 transition-colors"
+            @click=${this._handleEndClick}
+          >
+            <svg
+              class="w-4 h-4"
+              xmlns="http://www.w3.org/2000/svg"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2"
+              stroke-linecap="round"
+              stroke-linejoin="round"
+            >
+              <path d="M18 6L6 18" />
+              <path d="M6 6l12 12" />
+            </svg>
+            <span class="xl:hidden">End</span>
+          </button>
+
+          <div
+            class="flex items-center text-xs mr-2.5 cursor-pointer"
+            @click=${this._handleNotificationsToggle}
+            title="${this.notificationsEnabled
+              ? "Disable"
+              : "Enable"} notifications when the agent completes its turn"
+          >
+            <div
+              class="w-5 h-5 relative inline-flex items-center justify-center"
+            >
+              <!-- Bell SVG icon -->
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                width="16"
+                height="16"
+                fill="currentColor"
+                viewBox="0 0 16 16"
+                class="${!this.notificationsEnabled ? "relative z-10" : ""}"
+              >
+                <path
+                  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"
+                />
+              </svg>
+              ${!this.notificationsEnabled
+                ? html`<div
+                    class="absolute w-0.5 h-6 bg-red-600 rotate-45 origin-center"
+                  ></div>`
+                : ""}
+            </div>
+          </div>
+
+          <sketch-call-status
+            .agentState=${this.containerState?.agent_state}
+            .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
+            .toolCalls=${this.containerState?.outstanding_tool_calls || []}
+            .isIdle=${(() => {
+              const lastUserOrAgentMessage = this.getLastUserOrAgentMessage();
+              return lastUserOrAgentMessage
+                ? lastUserOrAgentMessage.end_of_turn &&
+                    !lastUserOrAgentMessage.parent_conversation_id
+                : true;
+            })()}
+            .isDisconnected=${this.connectionStatus === "disconnected"}
+          ></sketch-call-status>
+
+          <sketch-network-status
+            connection=${this.connectionStatus}
+            error=${this.connectionErrorMessage}
+          ></sketch-network-status>
+        </div>
+      </div>
+    `;
+  }
+
+  protected renderMainViews() {
+    return html`
+      <!-- Chat View -->
+      <div
+        class="chat-view ${this.viewMode === "chat"
+          ? "view-active flex flex-col"
+          : "hidden"} w-full h-full"
+      >
+        <div
+          class="${this._todoPanelVisible && this.viewMode === "chat"
+            ? "mr-[400px] xl:mr-[350px] lg:mr-[300px] md:mr-0 w-[calc(100%-400px)] xl:w-[calc(100%-350px)] lg:w-[calc(100%-300px)] md:w-full"
+            : "mr-0"} flex-1 flex flex-col w-full h-full transition-[margin-right] duration-200 ease-in-out"
+        >
+          <sketch-timeline
+            .messages=${this.messages}
+            .scrollContainer=${this.scrollContainerRef}
+            .agentState=${this.containerState?.agent_state}
+            .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
+            .toolCalls=${this.containerState?.outstanding_tool_calls || []}
+            .firstMessageIndex=${this.containerState?.first_message_index || 0}
+            .state=${this.containerState}
+            .dataManager=${this.dataManager}
+          ></sketch-timeline>
+        </div>
+      </div>
+
+      <!-- Todo panel positioned outside the main flow - only visible in chat view -->
+      <div
+        class="${this._todoPanelVisible && this.viewMode === "chat"
+          ? "block"
+          : "hidden"} fixed top-12 right-4 w-[400px] xl:w-[350px] lg:w-[300px] md:hidden z-[100] transition-[bottom] duration-200 ease-in-out"
+        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;"
+      >
+        <sketch-todo-panel
+          .visible=${this._todoPanelVisible && this.viewMode === "chat"}
+        ></sketch-todo-panel>
+      </div>
+      <!-- Diff2 View -->
+      <div
+        class="diff2-view ${this.viewMode === "diff2"
+          ? "view-active flex-1 overflow-hidden min-h-0 flex flex-col h-full"
+          : "hidden"} w-full h-full"
+      >
+        <sketch-diff2-view
+          .commit=${this.currentCommitHash}
+          .gitService=${new DefaultGitDataService()}
+          @diff-comment="${this._handleDiffComment}"
+        ></sketch-diff2-view>
+      </div>
+
+      <!-- Terminal View -->
+      <div
+        class="terminal-view ${this.viewMode === "terminal"
+          ? "view-active flex flex-col"
+          : "hidden"} w-full h-full"
+      >
+        <sketch-terminal></sketch-terminal>
+      </div>
+    `;
+  }
+
+  protected renderChatInput() {
+    return html`
+      <!-- Chat input fixed at bottom -->
+      <div
+        id="chat-input"
+        class="self-end w-full shadow-[0_-2px_10px_rgba(0,0,0,0.1)]"
+      >
+        <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
+      </div>
+    `;
+  }
+
+  /**
+   * Lifecycle callback when component is first connected to DOM
+   */
+  firstUpdated(): void {
+    if (this.viewMode !== "chat") {
+      return;
+    }
+
+    // Initial scroll to bottom when component is first rendered
+    setTimeout(
+      () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
+      50,
+    );
+
+    // Setup stop button
+    const stopButton = this.renderRoot?.querySelector(
+      "#stopButton",
+    ) as HTMLButtonElement;
+    stopButton?.addEventListener("click", async () => {
+      try {
+        const response = await fetch("cancel", {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({ reason: "User clicked stop button" }),
+        });
+        if (!response.ok) {
+          console.error("Failed to cancel:", await response.text());
+        }
+      } catch (error) {
+        console.error("Error cancelling operation:", error);
+      }
+    });
+
+    // Setup end button
+    const endButton = this.renderRoot?.querySelector(
+      "#endButton",
+    ) as HTMLButtonElement;
+    // We're already using the @click binding in the HTML, so manual event listener not needed here
+
+    // Process any existing messages to find commit information
+    if (this.messages && this.messages.length > 0) {
+      // Update last commit info via container status component
+      if (this.containerStatusElement) {
+        this.containerStatusElement.updateLastCommitInfo(this.messages);
+      }
+    }
+
+    // Set up chat input height observer for todo panel
+    this.setupChatInputObserver();
+  }
+}
+
+// Export the ViewMode type for use in subclasses
+export type { ViewMode };
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
index f37d9c9..9d0f2f6 100644
--- a/webui/src/web-components/sketch-app-shell.ts
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -1,999 +1,17 @@
-import { css, html, LitElement } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { ConnectionStatus, DataManager } from "../data";
-import { AgentMessage, GitLogEntry, State } from "../types";
-import { aggregateAgentMessages } from "./aggregateAgentMessages";
-import { SketchTailwindElement } from "./sketch-tailwind-element";
-
-import "./sketch-chat-input";
-import "./sketch-container-status";
-
-import "./sketch-diff2-view";
-import { SketchDiff2View } from "./sketch-diff2-view";
-import { DefaultGitDataService } from "./git-data-service";
-import "./sketch-monaco-view";
-import "./sketch-network-status";
-import "./sketch-call-status";
-import "./sketch-terminal";
-import "./sketch-timeline";
-import "./sketch-view-mode-select";
-import "./sketch-todo-panel";
-
-import { createRef, ref } from "lit/directives/ref.js";
-import { SketchChatInput } from "./sketch-chat-input";
-
-type ViewMode = "chat" | "diff2" | "terminal";
+import { html } from "lit";
+import { customElement } from "lit/decorators.js";
+import { ref } from "lit/directives/ref.js";
+import { SketchAppShellBase } from "./sketch-app-shell-base";
 
 @customElement("sketch-app-shell")
-export class SketchAppShell extends SketchTailwindElement {
-  // Current view mode (chat, diff, terminal)
-  @state()
-  viewMode: ViewMode = "chat";
-
-  // Current commit hash for diff view
-  @state()
-  currentCommitHash: string = "";
-
-  // Last commit information
-  @state()
-
-  // Reference to the container status element
-  containerStatusElement: any = null;
-
-  // Note: CSS styles have been converted to Tailwind classes applied directly to HTML elements
-  // since this component now extends SketchTailwindElement which disables shadow DOM
-
-  // Override createRenderRoot to apply host styles for proper sizing while still using light DOM
-  createRenderRoot() {
-    // Use light DOM like SketchTailwindElement but still apply host styles
-    const style = document.createElement("style");
-    style.textContent = `
-      sketch-app-shell {
-        display: block;
-        width: 100%;
-        height: 100vh;
-        max-width: 100%;
-        box-sizing: border-box;
-        overflow: hidden;
-      }
-    `;
-
-    // Add the style to the document head if not already present
-    if (!document.head.querySelector("style[data-sketch-app-shell]")) {
-      style.setAttribute("data-sketch-app-shell", "");
-      document.head.appendChild(style);
-    }
-
-    return this;
-  }
-
-  // Header bar: Network connection status details
-  @property()
-  connectionStatus: ConnectionStatus = "disconnected";
-
-  // Track if the last commit info has been copied
-  @state()
-  // lastCommitCopied moved to sketch-container-status
-
-  // Track notification preferences
-  @state()
-  notificationsEnabled: boolean = false;
-
-  // Track if the window is focused to control notifications
-  @state()
-  private _windowFocused: boolean = document.hasFocus();
-
-  // Track if the todo panel should be visible
-  @state()
-  private _todoPanelVisible: boolean = false;
-
-  // Store scroll position for the chat view to preserve it when switching tabs
-  @state()
-  private _chatScrollPosition: number = 0;
-
-  // ResizeObserver for tracking chat input height changes
-  private chatInputResizeObserver: ResizeObserver | null = null;
-
-  @property()
-  connectionErrorMessage: string = "";
-
-  // Chat messages
-  @property({ attribute: false })
-  messages: AgentMessage[] = [];
-
-  @property()
-  set slug(value: string) {
-    const oldValue = this._slug;
-    this._slug = value;
-    this.requestUpdate("slug", oldValue);
-    // Update document title when slug property changes
-    this.updateDocumentTitle();
-  }
-
-  get slug(): string {
-    return this._slug;
-  }
-
-  private _slug: string = "";
-
-  private dataManager = new DataManager();
-
-  @property({ attribute: false })
-  containerState: State = {
-    state_version: 2,
-    slug: "",
-    os: "",
-    message_count: 0,
-    hostname: "",
-    working_dir: "",
-    initial_commit: "",
-    outstanding_llm_calls: 0,
-    outstanding_tool_calls: [],
-    session_id: "",
-    ssh_available: false,
-    ssh_error: "",
-    in_container: false,
-    first_message_index: 0,
-    diff_lines_added: 0,
-    diff_lines_removed: 0,
-  };
-
-  // Mutation observer to detect when new messages are added
-  private mutationObserver: MutationObserver | null = null;
-
-  constructor() {
-    super();
-
-    // Reference to the container status element
-    this.containerStatusElement = null;
-
-    // Binding methods to this
-    this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
-    this._handlePopState = this._handlePopState.bind(this);
-    this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
-    this._handleMutlipleChoiceSelected =
-      this._handleMutlipleChoiceSelected.bind(this);
-    this._handleStopClick = this._handleStopClick.bind(this);
-    this._handleEndClick = this._handleEndClick.bind(this);
-    this._handleNotificationsToggle =
-      this._handleNotificationsToggle.bind(this);
-    this._handleWindowFocus = this._handleWindowFocus.bind(this);
-    this._handleWindowBlur = this._handleWindowBlur.bind(this);
-
-    // Load notification preference from localStorage
-    try {
-      const savedPref = localStorage.getItem("sketch-notifications-enabled");
-      if (savedPref !== null) {
-        this.notificationsEnabled = savedPref === "true";
-      }
-    } catch (error) {
-      console.error("Error loading notification preference:", error);
-    }
-  }
-
-  // See https://lit.dev/docs/components/lifecycle/
-  connectedCallback() {
-    super.connectedCallback();
-
-    // Get reference to the container status element
-    setTimeout(() => {
-      this.containerStatusElement =
-        this.shadowRoot?.getElementById("container-status");
-    }, 0);
-
-    // Initialize client-side nav history.
-    const url = new URL(window.location.href);
-    const mode = url.searchParams.get("view") || "chat";
-    window.history.replaceState({ mode }, "", url.toString());
-
-    this.toggleViewMode(mode as ViewMode, false);
-    // Add popstate event listener to handle browser back/forward navigation
-    window.addEventListener("popstate", this._handlePopState);
-
-    // Add event listeners
-    window.addEventListener("view-mode-select", this._handleViewModeSelect);
-    window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
-
-    // Add window focus/blur listeners for controlling notifications
-    window.addEventListener("focus", this._handleWindowFocus);
-    window.addEventListener("blur", this._handleWindowBlur);
-    window.addEventListener(
-      "multiple-choice-selected",
-      this._handleMutlipleChoiceSelected,
-    );
-
-    // register event listeners
-    this.dataManager.addEventListener(
-      "dataChanged",
-      this.handleDataChanged.bind(this),
-    );
-    this.dataManager.addEventListener(
-      "connectionStatusChanged",
-      this.handleConnectionStatusChanged.bind(this),
-    );
-
-    // Set initial document title
-    this.updateDocumentTitle();
-
-    // Initialize the data manager
-    this.dataManager.initialize();
-
-    // Process existing messages for commit info
-    if (this.messages && this.messages.length > 0) {
-      // Update last commit info via container status component
-      setTimeout(() => {
-        if (this.containerStatusElement) {
-          this.containerStatusElement.updateLastCommitInfo(this.messages);
-        }
-      }, 100);
-    }
-
-    // Check if todo panel should be visible on initial load
-    this.checkTodoPanelVisibility();
-
-    // Set up ResizeObserver for chat input to update todo panel height
-    this.setupChatInputObserver();
-  }
-
-  // See https://lit.dev/docs/components/lifecycle/
-  disconnectedCallback() {
-    super.disconnectedCallback();
-    window.removeEventListener("popstate", this._handlePopState);
-
-    // Remove event listeners
-    window.removeEventListener("view-mode-select", this._handleViewModeSelect);
-    window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
-    window.removeEventListener("focus", this._handleWindowFocus);
-    window.removeEventListener("blur", this._handleWindowBlur);
-    window.removeEventListener(
-      "multiple-choice-selected",
-      this._handleMutlipleChoiceSelected,
-    );
-
-    // unregister data manager event listeners
-    this.dataManager.removeEventListener(
-      "dataChanged",
-      this.handleDataChanged.bind(this),
-    );
-    this.dataManager.removeEventListener(
-      "connectionStatusChanged",
-      this.handleConnectionStatusChanged.bind(this),
-    );
-
-    // Disconnect mutation observer if it exists
-    if (this.mutationObserver) {
-      this.mutationObserver.disconnect();
-      this.mutationObserver = null;
-    }
-
-    // Disconnect chat input resize observer if it exists
-    if (this.chatInputResizeObserver) {
-      this.chatInputResizeObserver.disconnect();
-      this.chatInputResizeObserver = null;
-    }
-  }
-
-  updateUrlForViewMode(mode: ViewMode): void {
-    // Get the current URL without search parameters
-    const url = new URL(window.location.href);
-
-    // Clear existing parameters
-    url.search = "";
-
-    // Only add view parameter if not in default chat view
-    if (mode !== "chat") {
-      url.searchParams.set("view", mode);
-      const diff2View = this.shadowRoot?.querySelector(
-        "sketch-diff2-view",
-      ) as SketchDiff2View;
-
-      // If in diff2 view and there's a commit hash, include that too
-      if (mode === "diff2" && diff2View?.commit) {
-        url.searchParams.set("commit", diff2View.commit);
-      }
-    }
-
-    // Update the browser history without reloading the page
-    window.history.pushState({ mode }, "", url.toString());
-  }
-
-  private _handlePopState(event: PopStateEvent) {
-    if (event.state && event.state.mode) {
-      this.toggleViewMode(event.state.mode, false);
-    } else {
-      this.toggleViewMode("chat", false);
-    }
-  }
-
-  /**
-   * Handle view mode selection event
-   */
-  private _handleViewModeSelect(event: CustomEvent) {
-    const mode = event.detail.mode as "chat" | "diff2" | "terminal";
-    this.toggleViewMode(mode, true);
-  }
-
-  /**
-   * Handle show commit diff event
-   */
-  private _handleShowCommitDiff(event: CustomEvent) {
-    const { commitHash } = event.detail;
-    if (commitHash) {
-      this.showCommitDiff(commitHash);
-    }
-  }
-
-  private _handleMultipleChoice(event: CustomEvent) {
-    window.console.log("_handleMultipleChoice", event);
-    this._sendChat;
-  }
-
-  private _handleDiffComment(event: CustomEvent) {
-    // Empty stub required by the event binding in the template
-    // Actual handling occurs at global level in sketch-chat-input component
-  }
-  /**
-   * Listen for commit diff event
-   * @param commitHash The commit hash to show diff for
-   */
-  private showCommitDiff(commitHash: string): void {
-    // Store the commit hash
-    this.currentCommitHash = commitHash;
-
-    this.toggleViewMode("diff2", true);
-
-    this.updateComplete.then(() => {
-      const diff2View = this.shadowRoot?.querySelector("sketch-diff2-view");
-      if (diff2View) {
-        (diff2View as SketchDiff2View).refreshDiffView();
-      }
-    });
-  }
-
-  /**
-   * Toggle between different view modes: chat, diff2, terminal
-   */
-  private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
-    // Don't do anything if the mode is already active
-    if (this.viewMode === mode) return;
-
-    // Store scroll position if we're leaving the chat view
-    if (this.viewMode === "chat" && this.scrollContainerRef.value) {
-      // Only store scroll position if we actually have meaningful content
-      const scrollTop = this.scrollContainerRef.value.scrollTop;
-      const scrollHeight = this.scrollContainerRef.value.scrollHeight;
-      const clientHeight = this.scrollContainerRef.value.clientHeight;
-
-      // Store position only if we have scrollable content and have actually scrolled
-      if (scrollHeight > clientHeight && scrollTop > 0) {
-        this._chatScrollPosition = scrollTop;
-      }
-    }
-
-    // Update the view mode
-    this.viewMode = mode;
-
-    if (updateHistory) {
-      // Update URL with the current view mode
-      this.updateUrlForViewMode(mode);
-    }
-
-    // Wait for DOM update to complete
-    this.updateComplete.then(() => {
-      // Handle scroll position restoration for chat view
-      if (
-        mode === "chat" &&
-        this.scrollContainerRef.value &&
-        this._chatScrollPosition > 0
-      ) {
-        // Use requestAnimationFrame to ensure DOM is ready
-        requestAnimationFrame(() => {
-          if (this.scrollContainerRef.value) {
-            // Double-check that we're still in chat mode and the container is available
-            if (
-              this.viewMode === "chat" &&
-              this.scrollContainerRef.value.isConnected
-            ) {
-              this.scrollContainerRef.value.scrollTop =
-                this._chatScrollPosition;
-            }
-          }
-        });
-      }
-
-      // Handle diff2 view specific logic
-      if (mode === "diff2") {
-        // Refresh git/recentlog when Monaco diff view is opened
-        // This ensures branch information is always up-to-date, as branches can change frequently
-        const diff2ViewComp = this.querySelector("sketch-diff2-view");
-        if (diff2ViewComp) {
-          (diff2ViewComp as SketchDiff2View).refreshDiffView();
-        }
-      }
-
-      // Update view mode buttons
-      const viewModeSelect = this.querySelector("sketch-view-mode-select");
-      if (viewModeSelect) {
-        const event = new CustomEvent("update-active-mode", {
-          detail: { mode },
-          bubbles: true,
-          composed: true,
-        });
-        viewModeSelect.dispatchEvent(event);
-      }
-    });
-  }
-
-  /**
-   * Updates the document title based on current slug and connection status
-   */
-  private updateDocumentTitle(): void {
-    let docTitle = `sk: ${this.slug || "untitled"}`;
-
-    // Add red circle emoji if disconnected
-    if (this.connectionStatus === "disconnected") {
-      docTitle += " 🔴";
-    }
-
-    document.title = docTitle;
-  }
-
-  // Check and request notification permission if needed
-  private async checkNotificationPermission(): Promise<boolean> {
-    // Check if the Notification API is supported
-    if (!("Notification" in window)) {
-      console.log("This browser does not support notifications");
-      return false;
-    }
-
-    // Check if permission is already granted
-    if (Notification.permission === "granted") {
-      return true;
-    }
-
-    // If permission is not denied, request it
-    if (Notification.permission !== "denied") {
-      const permission = await Notification.requestPermission();
-      return permission === "granted";
-    }
-
-    return false;
-  }
-
-  // Handle notifications toggle click
-  private _handleNotificationsToggle(): void {
-    this.notificationsEnabled = !this.notificationsEnabled;
-
-    // If enabling notifications, check permissions
-    if (this.notificationsEnabled) {
-      this.checkNotificationPermission();
-    }
-
-    // Save preference to localStorage
-    try {
-      localStorage.setItem(
-        "sketch-notifications-enabled",
-        String(this.notificationsEnabled),
-      );
-    } catch (error) {
-      console.error("Error saving notification preference:", error);
-    }
-  }
-
-  // Handle window focus event
-  private _handleWindowFocus(): void {
-    this._windowFocused = true;
-  }
-
-  // Handle window blur event
-  private _handleWindowBlur(): void {
-    this._windowFocused = false;
-  }
-
-  // Get the last user or agent message (ignore system messages like commit, error, etc.)
-  // For example, when Sketch notices a new commit, it'll send a message,
-  // but it's still idle!
-  private getLastUserOrAgentMessage(): AgentMessage | null {
-    for (let i = this.messages.length - 1; i >= 0; i--) {
-      const message = this.messages[i];
-      if (message.type === "user" || message.type === "agent") {
-        return message;
-      }
-    }
-    return null;
-  }
-
-  // Show notification for message with EndOfTurn=true
-  private async showEndOfTurnNotification(
-    message: AgentMessage,
-  ): Promise<void> {
-    // Don't show notifications if they're disabled
-    if (!this.notificationsEnabled) return;
-
-    // Don't show notifications if the window is focused
-    if (this._windowFocused) return;
-
-    // Check if we have permission to show notifications
-    const hasPermission = await this.checkNotificationPermission();
-    if (!hasPermission) return;
-
-    // Only show notifications for agent messages with end_of_turn=true and no parent_conversation_id
-    if (
-      message.type !== "agent" ||
-      !message.end_of_turn ||
-      message.parent_conversation_id
-    )
-      return;
-
-    // Create a title that includes the sketch slug
-    const notificationTitle = `Sketch: ${this.slug || "untitled"}`;
-
-    // Extract the beginning of the message content (first 100 chars)
-    const messagePreview = message.content
-      ? message.content.substring(0, 100) +
-        (message.content.length > 100 ? "..." : "")
-      : "Agent has completed its turn";
-
-    // Create and show the notification
-    try {
-      new Notification(notificationTitle, {
-        body: messagePreview,
-        icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
-      });
-    } catch (error) {
-      console.error("Error showing notification:", error);
-    }
-  }
-
-  // Check if todo panel should be visible based on latest todo content from messages or state
-  private checkTodoPanelVisibility(): void {
-    // Find the latest todo content from messages first
-    let latestTodoContent = "";
-    for (let i = this.messages.length - 1; i >= 0; i--) {
-      const message = this.messages[i];
-      if (message.todo_content !== undefined) {
-        latestTodoContent = message.todo_content || "";
-        break;
-      }
-    }
-
-    // If no todo content found in messages, check the current state
-    if (latestTodoContent === "" && this.containerState?.todo_content) {
-      latestTodoContent = this.containerState.todo_content;
-    }
-
-    // Parse the todo data to check if there are any actual todos
-    let hasTodos = false;
-    if (latestTodoContent.trim()) {
-      try {
-        const todoData = JSON.parse(latestTodoContent);
-        hasTodos = todoData.items && todoData.items.length > 0;
-      } catch (error) {
-        // Invalid JSON, treat as no todos
-        hasTodos = false;
-      }
-    }
-
-    this._todoPanelVisible = hasTodos;
-
-    // Update todo panel content if visible
-    if (hasTodos) {
-      const todoPanel = this.shadowRoot?.querySelector(
-        "sketch-todo-panel",
-      ) as any;
-      if (todoPanel && todoPanel.updateTodoContent) {
-        todoPanel.updateTodoContent(latestTodoContent);
-      }
-    }
-  }
-
-  private handleDataChanged(eventData: {
-    state: State;
-    newMessages: AgentMessage[];
-  }): void {
-    const { state, newMessages } = eventData;
-
-    // Update state if we received it
-    if (state) {
-      // Ensure we're using the latest call status to prevent indicators from being stuck
-      if (
-        state.outstanding_llm_calls === 0 &&
-        state.outstanding_tool_calls.length === 0
-      ) {
-        // Force reset containerState calls when nothing is reported as in progress
-        state.outstanding_llm_calls = 0;
-        state.outstanding_tool_calls = [];
-      }
-
-      this.containerState = state;
-      this.slug = state.slug || "";
-
-      // Update document title when sketch slug changes
-      this.updateDocumentTitle();
-    }
-
-    // Update messages
-    const oldMessageCount = this.messages.length;
-    this.messages = aggregateAgentMessages(this.messages, newMessages);
-
-    // If new messages were added and we're in chat view, reset stored scroll position
-    // so the timeline can auto-scroll to bottom for new content
-    if (this.messages.length > oldMessageCount && this.viewMode === "chat") {
-      // Only reset if we were near the bottom (indicating user wants to follow new messages)
-      if (this.scrollContainerRef.value) {
-        const scrollTop = this.scrollContainerRef.value.scrollTop;
-        const scrollHeight = this.scrollContainerRef.value.scrollHeight;
-        const clientHeight = this.scrollContainerRef.value.clientHeight;
-        const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px tolerance
-
-        if (isNearBottom) {
-          this._chatScrollPosition = 0; // Reset stored position to allow auto-scroll
-        }
-      }
-    }
-
-    // Process new messages to find commit messages
-    // Update last commit info via container status component
-    if (this.containerStatusElement) {
-      this.containerStatusElement.updateLastCommitInfo(newMessages);
-    }
-
-    // Check for agent messages with end_of_turn=true and show notifications
-    if (newMessages && newMessages.length > 0) {
-      for (const message of newMessages) {
-        if (
-          message.type === "agent" &&
-          message.end_of_turn &&
-          !message.parent_conversation_id
-        ) {
-          this.showEndOfTurnNotification(message);
-          break; // Only show one notification per batch of messages
-        }
-      }
-    }
-
-    // Check if todo panel should be visible after agent loop iteration
-    this.checkTodoPanelVisibility();
-
-    // Ensure chat input observer is set up when new data comes in
-    if (!this.chatInputResizeObserver) {
-      this.setupChatInputObserver();
-    }
-  }
-
-  private handleConnectionStatusChanged(
-    status: ConnectionStatus,
-    errorMessage?: string,
-  ): void {
-    this.connectionStatus = status;
-    this.connectionErrorMessage = errorMessage || "";
-
-    // Update document title when connection status changes
-    this.updateDocumentTitle();
-  }
-
-  private async _handleStopClick(): Promise<void> {
-    try {
-      const response = await fetch("cancel", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ reason: "user requested cancellation" }),
-      });
-
-      if (!response.ok) {
-        const errorData = await response.text();
-        throw new Error(
-          `Failed to stop operation: ${response.status} - ${errorData}`,
-        );
-      }
-
-      // Stop request sent
-    } catch (error) {
-      console.error("Error stopping operation:", error);
-    }
-  }
-
-  private async _handleEndClick(event?: Event): Promise<void> {
-    if (event) {
-      event.preventDefault();
-      event.stopPropagation();
-    }
-
-    // Show confirmation dialog
-    const confirmed = window.confirm(
-      "Ending the session will shut down the underlying container. Are you sure?",
-    );
-    if (!confirmed) return;
-
-    try {
-      const response = await fetch("end", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ reason: "user requested end of session" }),
-      });
-
-      if (!response.ok) {
-        const errorData = await response.text();
-        throw new Error(
-          `Failed to end session: ${response.status} - ${errorData}`,
-        );
-      }
-
-      // After successful response, redirect to messages view
-      // Extract the session ID from the URL
-      const currentUrl = window.location.href;
-      // The URL pattern should be like https://sketch.dev/s/cs71-8qa6-1124-aw79/
-      const urlParts = currentUrl.split("/");
-      let sessionId = "";
-
-      // Find the session ID in the URL (should be after /s/)
-      for (let i = 0; i < urlParts.length; i++) {
-        if (urlParts[i] === "s" && i + 1 < urlParts.length) {
-          sessionId = urlParts[i + 1];
-          break;
-        }
-      }
-
-      if (sessionId) {
-        // Create the messages URL
-        const messagesUrl = `/messages/${sessionId}`;
-        // Redirect to messages view
-        window.location.href = messagesUrl;
-      }
-
-      // End request sent - connection will be closed by server
-    } catch (error) {
-      console.error("Error ending session:", error);
-    }
-  }
-
-  async _handleMutlipleChoiceSelected(e: CustomEvent) {
-    const chatInput = this.shadowRoot?.querySelector(
-      "sketch-chat-input",
-    ) as SketchChatInput;
-    if (chatInput) {
-      if (chatInput.content && chatInput.content.trim() !== "") {
-        chatInput.content += "\n\n";
-      }
-      chatInput.content += e.detail.responseText;
-      chatInput.focus();
-      // Adjust textarea height to accommodate new content
-      requestAnimationFrame(() => {
-        if (chatInput.adjustChatSpacing) {
-          chatInput.adjustChatSpacing();
-        }
-      });
-    }
-  }
-
-  async _sendChat(e: CustomEvent) {
-    console.log("app shell: _sendChat", e);
-    e.preventDefault();
-    e.stopPropagation();
-    const message = e.detail.message?.trim();
-    if (message == "") {
-      return;
-    }
-    try {
-      // Always switch to chat view when sending a message so user can see processing
-      if (this.viewMode !== "chat") {
-        this.toggleViewMode("chat", true);
-      }
-
-      // Send the message to the server
-      const response = await fetch("chat", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({ message }),
-      });
-
-      if (!response.ok) {
-        const errorData = await response.text();
-        throw new Error(`Server error: ${response.status} - ${errorData}`);
-      }
-    } catch (error) {
-      console.error("Error sending chat message:", error);
-      const statusText = document.getElementById("statusText");
-      if (statusText) {
-        statusText.textContent = "Error sending message";
-      }
-    }
-  }
-
-  private scrollContainerRef = createRef<HTMLElement>();
-
-  /**
-   * Set up ResizeObserver to monitor chat input height changes
-   */
-  private setupChatInputObserver(): void {
-    // Wait for DOM to be ready
-    this.updateComplete.then(() => {
-      const chatInputElement = this.shadowRoot?.querySelector("#chat-input");
-      if (chatInputElement && !this.chatInputResizeObserver) {
-        this.chatInputResizeObserver = new ResizeObserver((entries) => {
-          for (const entry of entries) {
-            this.updateTodoPanelHeight(entry.contentRect.height);
-          }
-        });
-
-        this.chatInputResizeObserver.observe(chatInputElement);
-
-        // Initial height calculation
-        const rect = chatInputElement.getBoundingClientRect();
-        this.updateTodoPanelHeight(rect.height);
-      }
-    });
-  }
-
-  /**
-   * Update the CSS custom property that controls todo panel bottom position
-   */
-  private updateTodoPanelHeight(chatInputHeight: number): void {
-    // Add some padding (20px) between todo panel and chat input
-    const bottomOffset = chatInputHeight;
-
-    // Update the CSS custom property on the host element
-    this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
-  }
-
+export class SketchAppShell extends SketchAppShellBase {
   render() {
     return html`
       <!-- Main container: flex column, full height, system font, hidden overflow-x -->
       <div
         class="block font-sans text-gray-800 leading-relaxed h-screen w-full relative overflow-x-hidden flex flex-col"
       >
-        <!-- Top banner: flex row, space between, border bottom, shadow -->
-        <div
-          id="top-banner"
-          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"
-        >
-          <!-- Title container -->
-          <div
-            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"
-          >
-            <h1
-              class="text-lg md:text-base sm:text-sm font-semibold m-0 min-w-24 whitespace-nowrap overflow-hidden text-ellipsis"
-            >
-              ${this.containerState?.skaband_addr
-                ? html`<a
-                    href="${this.containerState.skaband_addr}"
-                    target="_blank"
-                    rel="noopener noreferrer"
-                    class="text-inherit no-underline transition-opacity duration-200 ease-in-out flex items-center gap-2 hover:opacity-80 hover:underline"
-                  >
-                    <img
-                      src="${this.containerState.skaband_addr}/sketch.dev.png"
-                      alt="sketch"
-                      class="w-5 h-5 md:w-[18px] md:h-[18px] sm:w-4 sm:h-4 rounded-sm"
-                    />
-                    sketch
-                  </a>`
-                : html`sketch`}
-            </h1>
-            <h2
-              class="m-0 p-0 text-gray-600 text-sm font-normal italic whitespace-nowrap overflow-hidden text-ellipsis"
-            >
-              ${this.slug}
-            </h2>
-          </div>
-
-          <!-- Container status info moved above tabs -->
-          <sketch-container-status
-            .state=${this.containerState}
-            id="container-status"
-          ></sketch-container-status>
-
-          <!-- Last Commit section moved to sketch-container-status -->
-
-          <!-- Views section with tabs -->
-          <sketch-view-mode-select
-            .diffLinesAdded=${this.containerState?.diff_lines_added || 0}
-            .diffLinesRemoved=${this.containerState?.diff_lines_removed || 0}
-          ></sketch-view-mode-select>
-
-          <!-- Control buttons and status -->
-          <div
-            class="flex items-center mb-0 flex-nowrap whitespace-nowrap flex-shrink-0 gap-4 pl-4 mr-12"
-          >
-            <button
-              id="stopButton"
-              class="bg-red-600 hover:bg-red-700 disabled:bg-red-300 disabled:cursor-not-allowed disabled:opacity-70 text-white border-none px-2.5 py-1 xl:px-1.5 rounded cursor-pointer text-xs mr-1.5 flex items-center gap-1.5 transition-colors"
-              ?disabled=${(this.containerState?.outstanding_llm_calls || 0) ===
-                0 &&
-              (this.containerState?.outstanding_tool_calls || []).length === 0}
-            >
-              <svg
-                class="w-4 h-4"
-                xmlns="http://www.w3.org/2000/svg"
-                viewBox="0 0 24 24"
-                fill="none"
-                stroke="currentColor"
-                stroke-width="2"
-                stroke-linecap="round"
-                stroke-linejoin="round"
-              >
-                <rect x="6" y="6" width="12" height="12" />
-              </svg>
-              <span class="xl:hidden">Stop</span>
-            </button>
-            <button
-              id="endButton"
-              class="bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 text-white border-none px-2.5 py-1 xl:px-1.5 rounded cursor-pointer text-xs mr-1.5 flex items-center gap-1.5 transition-colors"
-              @click=${this._handleEndClick}
-            >
-              <svg
-                class="w-4 h-4"
-                xmlns="http://www.w3.org/2000/svg"
-                viewBox="0 0 24 24"
-                fill="none"
-                stroke="currentColor"
-                stroke-width="2"
-                stroke-linecap="round"
-                stroke-linejoin="round"
-              >
-                <path d="M18 6L6 18" />
-                <path d="M6 6l12 12" />
-              </svg>
-              <span class="xl:hidden">End</span>
-            </button>
-
-            <div
-              class="flex items-center text-xs mr-2.5 cursor-pointer"
-              @click=${this._handleNotificationsToggle}
-              title="${this.notificationsEnabled
-                ? "Disable"
-                : "Enable"} notifications when the agent completes its turn"
-            >
-              <div
-                class="w-5 h-5 relative inline-flex items-center justify-center"
-              >
-                <!-- Bell SVG icon -->
-                <svg
-                  xmlns="http://www.w3.org/2000/svg"
-                  width="16"
-                  height="16"
-                  fill="currentColor"
-                  viewBox="0 0 16 16"
-                  class="${!this.notificationsEnabled ? "relative z-10" : ""}"
-                >
-                  <path
-                    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"
-                  />
-                </svg>
-                ${!this.notificationsEnabled
-                  ? html`<div
-                      class="absolute w-0.5 h-6 bg-red-600 rotate-45 origin-center"
-                    ></div>`
-                  : ""}
-              </div>
-            </div>
-
-            <sketch-call-status
-              .agentState=${this.containerState?.agent_state}
-              .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
-              .toolCalls=${this.containerState?.outstanding_tool_calls || []}
-              .isIdle=${(() => {
-                const lastUserOrAgentMessage = this.getLastUserOrAgentMessage();
-                return lastUserOrAgentMessage
-                  ? lastUserOrAgentMessage.end_of_turn &&
-                      !lastUserOrAgentMessage.parent_conversation_id
-                  : true;
-              })()}
-              .isDisconnected=${this.connectionStatus === "disconnected"}
-            ></sketch-call-status>
-
-            <sketch-network-status
-              connection=${this.connectionStatus}
-              error=${this.connectionErrorMessage}
-            ></sketch-network-status>
-          </div>
-        </div>
+        ${this.renderTopBanner()}
 
         <!-- Main content area: scrollable, flex-1 -->
         <div
@@ -1009,130 +27,14 @@
                 ? "max-w-none w-full m-0 px-5"
                 : "max-w-6xl w-[calc(100%-40px)] mx-auto"} relative pb-2.5 pt-2.5 flex flex-col h-full"
           >
-            <!-- Chat View -->
-            <div
-              class="chat-view ${this.viewMode === "chat"
-                ? "view-active flex flex-col"
-                : "hidden"} w-full h-full"
-            >
-              <div
-                class="${this._todoPanelVisible && this.viewMode === "chat"
-                  ? "mr-[400px] xl:mr-[350px] lg:mr-[300px] md:mr-0 w-[calc(100%-400px)] xl:w-[calc(100%-350px)] lg:w-[calc(100%-300px)] md:w-full"
-                  : "mr-0"} flex-1 flex flex-col w-full h-full transition-[margin-right] duration-200 ease-in-out"
-              >
-                <sketch-timeline
-                  .messages=${this.messages}
-                  .scrollContainer=${this.scrollContainerRef}
-                  .agentState=${this.containerState?.agent_state}
-                  .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
-                  .toolCalls=${this.containerState?.outstanding_tool_calls ||
-                  []}
-                  .firstMessageIndex=${this.containerState
-                    ?.first_message_index || 0}
-                  .state=${this.containerState}
-                  .dataManager=${this.dataManager}
-                ></sketch-timeline>
-              </div>
-            </div>
-
-            <!-- Todo panel positioned outside the main flow - only visible in chat view -->
-            <div
-              class="${this._todoPanelVisible && this.viewMode === "chat"
-                ? "block"
-                : "hidden"} fixed top-12 right-4 w-[400px] xl:w-[350px] lg:w-[300px] md:hidden z-[100] transition-[bottom] duration-200 ease-in-out"
-              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;"
-            >
-              <sketch-todo-panel
-                .visible=${this._todoPanelVisible && this.viewMode === "chat"}
-              ></sketch-todo-panel>
-            </div>
-            <!-- Diff2 View -->
-            <div
-              class="diff2-view ${this.viewMode === "diff2"
-                ? "view-active flex-1 overflow-hidden min-h-0 flex flex-col h-full"
-                : "hidden"} w-full h-full"
-            >
-              <sketch-diff2-view
-                .commit=${this.currentCommitHash}
-                .gitService=${new DefaultGitDataService()}
-                @diff-comment="${this._handleDiffComment}"
-              ></sketch-diff2-view>
-            </div>
-
-            <!-- Terminal View -->
-            <div
-              class="terminal-view ${this.viewMode === "terminal"
-                ? "view-active flex flex-col"
-                : "hidden"} w-full h-full"
-            >
-              <sketch-terminal></sketch-terminal>
-            </div>
+            ${this.renderMainViews()}
           </div>
         </div>
 
-        <!-- Chat input fixed at bottom -->
-        <div
-          id="chat-input"
-          class="self-end w-full shadow-[0_-2px_10px_rgba(0,0,0,0.1)]"
-        >
-          <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
-        </div>
+        ${this.renderChatInput()}
       </div>
     `;
   }
-
-  /**
-   * Lifecycle callback when component is first connected to DOM
-   */
-  firstUpdated(): void {
-    if (this.viewMode !== "chat") {
-      return;
-    }
-
-    // Initial scroll to bottom when component is first rendered
-    setTimeout(
-      () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
-      50,
-    );
-
-    // Setup stop button
-    const stopButton = this.renderRoot?.querySelector(
-      "#stopButton",
-    ) as HTMLButtonElement;
-    stopButton?.addEventListener("click", async () => {
-      try {
-        const response = await fetch("cancel", {
-          method: "POST",
-          headers: {
-            "Content-Type": "application/json",
-          },
-          body: JSON.stringify({ reason: "User clicked stop button" }),
-        });
-        if (!response.ok) {
-          console.error("Failed to cancel:", await response.text());
-        }
-      } catch (error) {
-        console.error("Error cancelling operation:", error);
-      }
-    });
-
-    // Setup end button
-    const endButton = this.renderRoot?.querySelector(
-      "#endButton",
-    ) as HTMLButtonElement;
-    // We're already using the @click binding in the HTML, so manual event listener not needed here
-
-    // Process any existing messages to find commit information
-    if (this.messages && this.messages.length > 0) {
-      // Update last commit info via container status component
-      if (this.containerStatusElement) {
-        this.containerStatusElement.updateLastCommitInfo(this.messages);
-      }
-    }
-
-    // Set up chat input height observer for todo panel
-    this.setupChatInputObserver();
-  }
 }
 
 declare global {