webui: add mobile interface with URL parameter switching

TL;DR: ?m and ?d should load mobile and desktop versions respectively.
It should auto-detect to the right onen and redirect. Server chooses
what to serve.

This took a few tries. Re-using the existing components didn't work,
despite repeated attempts. The prompt that eventually worked was:

	Sketch's webui uses lit and webcomponents. I want to create an alternate
	web ui, started with "/m" for mobile. Don't use the same components, but
	create a new mobile-shell component and a mobile-title, mobile-chat, and
	mobile-chat-input components. The design should be a title at the top, a
	simplified chat (that doesn't display tool cards or anything; just the
	messages, with user messages right-aligned and other messages left
	aligned), and an input for new messages. Do include an indicator for
	whether or not the agent is thinking. Again: this is a parallel
	implementation, intended for mobile screens. Use the "npm run dev"
	server to test it out and show me some screenshots. Use mobile browser
	sizes. Focus on simplicity in the CSS

It had some trouble with the data loading that took some iterations, and
it kept saying, "We're almost done" and giving up, but I coaxed it
through.

I'm not too sad right now about the duplication. We can see if there's
any traction.

~~~~

Add complete mobile-optimized interface for Sketch accessible via URL
parameters, providing seamless device-specific user experience.

Problem Analysis:
Sketch's existing web interface was designed for desktop use with complex
layouts, detailed toolbars, and mouse-focused interactions that don't
translate well to mobile devices. Mobile users needed a simplified,
touch-friendly interface optimized for smaller screens while maintaining
core functionality for coding assistance on mobile devices.

Implementation Changes:

1. Mobile-Specific Components:
   - Created mobile-shell as main container with mobile-optimized layout
   - Built mobile-title with connection status and clean header design
   - Implemented mobile-chat with simplified message bubbles and alignment
   - Developed mobile-chat-input with touch-friendly controls and auto-resize

2. URL Parameter-Based Interface Selection:
   - Added server-side parameter detection (?m for mobile, ?d for desktop)
   - Consolidated routing logic into main / handler with parameter checking
   - Eliminated separate URL paths in favor of clean parameter approach
   - Removed interface switching buttons for minimal UI approach

3. Intelligent Auto-Detection:
   - Implemented client-side device detection using screen size and touch capability
   - Automatic URL parameter addition for detected mobile devices
   - Graceful parameter-based overrides for user preference
   - Seamless redirection maintaining all URL context and query parameters

4. Mobile-Optimized Design:
   - iOS-style message bubbles with proper user/assistant alignment
   - Touch-friendly input controls with appropriate sizing
   - Responsive design scaling across mobile screen sizes
   - Animated thinking indicator with smooth dot animations
   - Clean, distraction-free interface focused on core chat functionality

5. Data Integration:
   - Reused existing DataManager and SSE infrastructure
   - Maintained real-time message updates via /stream endpoint
   - Implemented message aggregation using existing patterns
   - Preserved connection status and error handling functionality

Technical Details:
- Built using Lit web components for consistency with existing architecture
- Server parameter detection uses URL.Query().Has() for efficient checking
- Auto-detection preserves all existing query parameters during redirection
- Mobile components filter messages to show user, agent, and error types
- Responsive CSS with mobile-first design principles and touch optimization

Benefits:
- Seamless mobile experience with automatic device-appropriate interface
- Clean URL parameter approach (?m/?d) works with any route or session
- No UI clutter from interface switching buttons
- Maintains full real-time functionality on mobile devices
- Easy to bookmark and share mobile-specific URLs
- Preserves session context during interface switching

Testing:
- Verified auto-detection works across different mobile screen sizes
- Confirmed URL parameters function with complex session URLs
- Tested message sending, receiving, and real-time updates
- Validated thinking indicator and connection status display
- Ensured responsive design across portrait and landscape orientations

This implementation provides a complete mobile solution while maintaining
the full-featured desktop interface, enabling Sketch usage across all
device types with appropriate user experiences.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sdbce185f247638c1k
diff --git a/webui/src/interface-detection.js b/webui/src/interface-detection.js
new file mode 100644
index 0000000..528a3b0
--- /dev/null
+++ b/webui/src/interface-detection.js
@@ -0,0 +1,113 @@
+// Shared interface detection and auto-switching logic
+// Used by both desktop and mobile interfaces
+
+(function () {
+  function detectMobile() {
+    const isMobileScreen = window.innerWidth < 768;
+    const isTouchDevice = "ontouchstart" in window;
+    const isMobileUA =
+      /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
+        navigator.userAgent,
+      );
+
+    return isMobileScreen || (isTouchDevice && isMobileUA);
+  }
+
+  function detectDesktop() {
+    const isDesktopScreen = window.innerWidth >= 768;
+    const isNotTouchDevice = !("ontouchstart" in window);
+    const isDesktopUA =
+      !/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
+        navigator.userAgent,
+      );
+
+    return isDesktopScreen && (isNotTouchDevice || isDesktopUA);
+  }
+
+  function autoRedirectToMobile() {
+    const urlParams = new URLSearchParams(window.location.search);
+    const hasDesktopParam = urlParams.has("d");
+
+    // Respect manual overrides - if ?d is present, stay on desktop
+    if (hasDesktopParam) return;
+
+    // If mobile is detected and no explicit desktop request
+    if (detectMobile() && !hasDesktopParam) {
+      // Add ?m parameter to current URL
+      const url = new URL(window.location);
+      url.searchParams.set("m", "");
+      // Remove any conflicting parameters
+      url.searchParams.delete("d");
+      window.location.href = url.toString();
+    }
+  }
+
+  function autoRedirectToDesktop() {
+    const urlParams = new URLSearchParams(window.location.search);
+    const hasMobileParam = urlParams.has("m");
+
+    // Respect manual overrides - if ?m is present, stay on mobile
+    if (hasMobileParam) return;
+
+    // If we detect desktop conditions
+    if (detectDesktop() && !hasMobileParam) {
+      // Add ?d parameter to current URL
+      const url = new URL(window.location);
+      url.searchParams.set("d", "");
+      // Remove any conflicting parameters
+      url.searchParams.delete("m");
+      window.location.href = url.toString();
+    }
+  }
+
+  function runAutoDetection() {
+    // Determine which detection to run based on current interface
+    // This is determined by checking if we're serving mobile or desktop HTML
+    const isMobileInterface = document.querySelector("mobile-shell") !== null;
+
+    if (isMobileInterface) {
+      autoRedirectToDesktop();
+    } else {
+      autoRedirectToMobile();
+    }
+  }
+
+  // iOS Safari viewport height fix
+  function fixIOSViewportHeight() {
+    // Only apply on iOS Safari
+    const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
+    const isSafari =
+      /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
+
+    if (isIOS && isSafari) {
+      // Set CSS custom property with actual viewport height
+      const vh = window.innerHeight * 0.01;
+      document.documentElement.style.setProperty("--vh", `${vh}px`);
+
+      // Update on orientation change
+      window.addEventListener("orientationchange", () => {
+        setTimeout(() => {
+          const vh = window.innerHeight * 0.01;
+          document.documentElement.style.setProperty("--vh", `${vh}px`);
+        }, 100);
+      });
+
+      // Update on resize
+      window.addEventListener("resize", () => {
+        const vh = window.innerHeight * 0.01;
+        document.documentElement.style.setProperty("--vh", `${vh}px`);
+      });
+    }
+  }
+
+  // Run detection when DOM is ready
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", () => {
+      runAutoDetection();
+      fixIOSViewportHeight();
+    });
+  } else {
+    runAutoDetection();
+    fixIOSViewportHeight();
+  }
+})();
diff --git a/webui/src/mobile-app-shell.css b/webui/src/mobile-app-shell.css
new file mode 100644
index 0000000..9196b31
--- /dev/null
+++ b/webui/src/mobile-app-shell.css
@@ -0,0 +1,29 @@
+html,
+body {
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  font-family:
+    -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
+  background-color: #ffffff;
+}
+
+/* iOS Safari viewport fix */
+html {
+  height: -webkit-fill-available;
+}
+
+body {
+  display: flex;
+  overflow: hidden;
+  /* Additional iOS Safari height fix */
+  min-height: 100vh;
+  min-height: -webkit-fill-available;
+}
+
+/* Mobile viewport optimizations */
+@media screen and (max-width: 768px) {
+  html {
+    font-size: 16px; /* Prevent iOS zoom */
+  }
+}
diff --git a/webui/src/mobile-app-shell.html b/webui/src/mobile-app-shell.html
new file mode 100644
index 0000000..a610b5d
--- /dev/null
+++ b/webui/src/mobile-app-shell.html
@@ -0,0 +1,17 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no"
+    />
+    <title>sketch mobile</title>
+    <link rel="stylesheet" href="static/mobile-app-shell.css" />
+    <script src="static/mobile-app-shell.js" async type="module"></script>
+    <script src="static/interface-detection.js"></script>
+  </head>
+  <body>
+    <mobile-shell></mobile-shell>
+  </body>
+</html>
diff --git a/webui/src/mobile-dev.html b/webui/src/mobile-dev.html
new file mode 100644
index 0000000..08ec14d
--- /dev/null
+++ b/webui/src/mobile-dev.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>sketch mobile</title>
+    <style>
+      html,
+      body {
+        height: 100%;
+        margin: 0;
+        padding: 0;
+        font-family:
+          -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
+        background-color: #ffffff;
+      }
+      body {
+        display: flex;
+        overflow: hidden;
+      }
+    </style>
+    <script type="module" src="./web-components/mobile-app-shell.ts"></script>
+  </head>
+  <body>
+    <mobile-shell></mobile-shell>
+  </body>
+</html>
diff --git a/webui/src/sketch-app-shell.html b/webui/src/sketch-app-shell.html
index 702b588..8127fa2 100644
--- a/webui/src/sketch-app-shell.html
+++ b/webui/src/sketch-app-shell.html
@@ -6,6 +6,7 @@
     <title>sketch coding assistant</title>
     <link rel="stylesheet" href="static/sketch-app-shell.css" />
     <script src="static/sketch-app-shell.js" async type="module"></script>
+    <script src="static/interface-detection.js"></script>
   </head>
   <body>
     <sketch-app-shell></sketch-app-shell>
diff --git a/webui/src/web-components/mobile-app-shell.ts b/webui/src/web-components/mobile-app-shell.ts
new file mode 100644
index 0000000..ba5b158
--- /dev/null
+++ b/webui/src/web-components/mobile-app-shell.ts
@@ -0,0 +1,2 @@
+// Mobile app shell entry point
+import "./mobile-shell";
diff --git a/webui/src/web-components/mobile-chat-input.ts b/webui/src/web-components/mobile-chat-input.ts
new file mode 100644
index 0000000..cb55c64
--- /dev/null
+++ b/webui/src/web-components/mobile-chat-input.ts
@@ -0,0 +1,200 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { createRef, ref } from "lit/directives/ref.js";
+
+@customElement("mobile-chat-input")
+export class MobileChatInput extends LitElement {
+  @property({ type: Boolean })
+  disabled = false;
+
+  @state()
+  private inputValue = "";
+
+  private textareaRef = createRef<HTMLTextAreaElement>();
+
+  static styles = css`
+    :host {
+      display: block;
+      background-color: #ffffff;
+      border-top: 1px solid #e9ecef;
+      padding: 12px 16px;
+      /* Enhanced iOS safe area support */
+      padding-bottom: max(12px, env(safe-area-inset-bottom));
+      padding-left: max(16px, env(safe-area-inset-left));
+      padding-right: max(16px, env(safe-area-inset-right));
+      /* Prevent iOS Safari from covering the input */
+      position: relative;
+      z-index: 1000;
+    }
+
+    .input-container {
+      display: flex;
+      align-items: flex-end;
+      gap: 12px;
+      max-width: 100%;
+    }
+
+    .input-wrapper {
+      flex: 1;
+      position: relative;
+      min-width: 0;
+    }
+
+    textarea {
+      width: 100%;
+      min-height: 40px;
+      max-height: 120px;
+      padding: 12px 16px;
+      border: 1px solid #ddd;
+      border-radius: 20px;
+      font-size: 16px;
+      font-family: inherit;
+      line-height: 1.4;
+      resize: none;
+      outline: none;
+      background-color: #f8f9fa;
+      transition:
+        border-color 0.2s,
+        background-color 0.2s;
+      box-sizing: border-box;
+    }
+
+    textarea:focus {
+      border-color: #007bff;
+      background-color: #ffffff;
+    }
+
+    textarea:disabled {
+      background-color: #e9ecef;
+      color: #6c757d;
+      cursor: not-allowed;
+    }
+
+    textarea::placeholder {
+      color: #6c757d;
+    }
+
+    .send-button {
+      flex-shrink: 0;
+      width: 40px;
+      height: 40px;
+      border: none;
+      border-radius: 50%;
+      background-color: #007bff;
+      color: white;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 18px;
+      transition:
+        background-color 0.2s,
+        transform 0.1s;
+      outline: none;
+    }
+
+    .send-button:hover:not(:disabled) {
+      background-color: #0056b3;
+    }
+
+    .send-button:active:not(:disabled) {
+      transform: scale(0.95);
+    }
+
+    .send-button:disabled {
+      background-color: #6c757d;
+      cursor: not-allowed;
+      opacity: 0.6;
+    }
+
+    .send-icon {
+      width: 16px;
+      height: 16px;
+      fill: currentColor;
+    }
+
+    /* iOS specific adjustments */
+    @supports (-webkit-touch-callout: none) {
+      textarea {
+        font-size: 16px; /* Prevent zoom on iOS */
+      }
+    }
+  `;
+
+  private handleInput = (e: Event) => {
+    const target = e.target as HTMLTextAreaElement;
+    this.inputValue = target.value;
+    this.adjustTextareaHeight();
+  };
+
+  private handleKeyDown = (e: KeyboardEvent) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      e.preventDefault();
+      this.sendMessage();
+    }
+  };
+
+  private adjustTextareaHeight() {
+    if (this.textareaRef.value) {
+      const textarea = this.textareaRef.value;
+      textarea.style.height = "auto";
+      textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
+    }
+  }
+
+  private sendMessage = () => {
+    const message = this.inputValue.trim();
+    if (message && !this.disabled) {
+      this.dispatchEvent(
+        new CustomEvent("send-message", {
+          detail: { message },
+          bubbles: true,
+          composed: true,
+        }),
+      );
+
+      this.inputValue = "";
+      if (this.textareaRef.value) {
+        this.textareaRef.value.value = "";
+        this.adjustTextareaHeight();
+        this.textareaRef.value.focus();
+      }
+    }
+  };
+
+  updated(changedProperties: Map<string, any>) {
+    super.updated(changedProperties);
+    this.adjustTextareaHeight();
+  }
+
+  render() {
+    const canSend = this.inputValue.trim().length > 0 && !this.disabled;
+
+    return html`
+      <div class="input-container">
+        <div class="input-wrapper">
+          <textarea
+            ${ref(this.textareaRef)}
+            .value=${this.inputValue}
+            @input=${this.handleInput}
+            @keydown=${this.handleKeyDown}
+            placeholder="Message Sketch..."
+            ?disabled=${this.disabled}
+            rows="1"
+          ></textarea>
+        </div>
+
+        <button
+          class="send-button"
+          @click=${this.sendMessage}
+          ?disabled=${!canSend}
+          title="Send message"
+        >
+          <svg class="send-icon" viewBox="0 0 24 24">
+            <path d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
+          </svg>
+        </button>
+      </div>
+    `;
+  }
+}
diff --git a/webui/src/web-components/mobile-chat.ts b/webui/src/web-components/mobile-chat.ts
new file mode 100644
index 0000000..d634da5
--- /dev/null
+++ b/webui/src/web-components/mobile-chat.ts
@@ -0,0 +1,225 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { AgentMessage } from "../types";
+import { createRef, ref } from "lit/directives/ref.js";
+
+@customElement("mobile-chat")
+export class MobileChat extends LitElement {
+  @property({ type: Array })
+  messages: AgentMessage[] = [];
+
+  @property({ type: Boolean })
+  isThinking = false;
+
+  private scrollContainer = createRef<HTMLDivElement>();
+
+  static styles = css`
+    :host {
+      display: block;
+      height: 100%;
+      overflow: hidden;
+    }
+
+    .chat-container {
+      height: 100%;
+      overflow-y: auto;
+      padding: 16px;
+      display: flex;
+      flex-direction: column;
+      gap: 16px;
+      scroll-behavior: smooth;
+      -webkit-overflow-scrolling: touch;
+    }
+
+    .message {
+      display: flex;
+      flex-direction: column;
+      max-width: 85%;
+      word-wrap: break-word;
+    }
+
+    .message.user {
+      align-self: flex-end;
+      align-items: flex-end;
+    }
+
+    .message.assistant {
+      align-self: flex-start;
+      align-items: flex-start;
+    }
+
+    .message-bubble {
+      padding: 8px 12px;
+      border-radius: 18px;
+      font-size: 16px;
+      line-height: 1.4;
+    }
+
+    .message.user .message-bubble {
+      background-color: #007bff;
+      color: white;
+      border-bottom-right-radius: 6px;
+    }
+
+    .message.assistant .message-bubble {
+      background-color: #f1f3f4;
+      color: #333;
+      border-bottom-left-radius: 6px;
+    }
+
+    .thinking-message {
+      align-self: flex-start;
+      align-items: flex-start;
+      max-width: 85%;
+    }
+
+    .thinking-bubble {
+      background-color: #f1f3f4;
+      padding: 16px;
+      border-radius: 18px;
+      border-bottom-left-radius: 6px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    .thinking-text {
+      color: #6c757d;
+      font-style: italic;
+    }
+
+    .thinking-dots {
+      display: flex;
+      gap: 3px;
+    }
+
+    .thinking-dot {
+      width: 6px;
+      height: 6px;
+      border-radius: 50%;
+      background-color: #6c757d;
+      animation: thinking 1.4s ease-in-out infinite both;
+    }
+
+    .thinking-dot:nth-child(1) {
+      animation-delay: -0.32s;
+    }
+    .thinking-dot:nth-child(2) {
+      animation-delay: -0.16s;
+    }
+    .thinking-dot:nth-child(3) {
+      animation-delay: 0;
+    }
+
+    @keyframes thinking {
+      0%,
+      80%,
+      100% {
+        transform: scale(0.8);
+        opacity: 0.5;
+      }
+      40% {
+        transform: scale(1);
+        opacity: 1;
+      }
+    }
+
+    .empty-state {
+      flex: 1;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: #6c757d;
+      font-style: italic;
+      text-align: center;
+      padding: 32px;
+    }
+  `;
+
+  updated(changedProperties: Map<string, any>) {
+    super.updated(changedProperties);
+    if (
+      changedProperties.has("messages") ||
+      changedProperties.has("isThinking")
+    ) {
+      this.scrollToBottom();
+    }
+  }
+
+  private scrollToBottom() {
+    // Use requestAnimationFrame to ensure DOM is updated
+    requestAnimationFrame(() => {
+      if (this.scrollContainer.value) {
+        this.scrollContainer.value.scrollTop =
+          this.scrollContainer.value.scrollHeight;
+      }
+    });
+  }
+
+  private formatTime(timestamp: string): string {
+    const date = new Date(timestamp);
+    return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+  }
+
+  private getMessageRole(message: AgentMessage): string {
+    if (message.type === "user") {
+      return "user";
+    }
+    return "assistant";
+  }
+
+  private getMessageText(message: AgentMessage): string {
+    return message.content || "";
+  }
+
+  private shouldShowMessage(message: AgentMessage): boolean {
+    // Show user, agent, and error messages with content
+    return (
+      (message.type === "user" ||
+        message.type === "agent" ||
+        message.type === "error") &&
+      message.content &&
+      message.content.trim().length > 0
+    );
+  }
+
+  render() {
+    const displayMessages = this.messages.filter((msg) =>
+      this.shouldShowMessage(msg),
+    );
+
+    return html`
+      <div class="chat-container" ${ref(this.scrollContainer)}>
+        ${displayMessages.length === 0
+          ? html`
+              <div class="empty-state">Start a conversation with Sketch...</div>
+            `
+          : displayMessages.map((message) => {
+              const role = this.getMessageRole(message);
+              const text = this.getMessageText(message);
+              const timestamp = message.timestamp;
+
+              return html`
+                <div class="message ${role}">
+                  <div class="message-bubble">${text}</div>
+                </div>
+              `;
+            })}
+        ${this.isThinking
+          ? html`
+              <div class="thinking-message">
+                <div class="thinking-bubble">
+                  <span class="thinking-text">Sketch is thinking</span>
+                  <div class="thinking-dots">
+                    <div class="thinking-dot"></div>
+                    <div class="thinking-dot"></div>
+                    <div class="thinking-dot"></div>
+                  </div>
+                </div>
+              </div>
+            `
+          : ""}
+      </div>
+    `;
+  }
+}
diff --git a/webui/src/web-components/mobile-shell.ts b/webui/src/web-components/mobile-shell.ts
new file mode 100644
index 0000000..583549d
--- /dev/null
+++ b/webui/src/web-components/mobile-shell.ts
@@ -0,0 +1,168 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { ConnectionStatus, DataManager } from "../data";
+import { AgentMessage, State } from "../types";
+import { aggregateAgentMessages } from "./aggregateAgentMessages";
+
+import "./mobile-title";
+import "./mobile-chat";
+import "./mobile-chat-input";
+
+@customElement("mobile-shell")
+export class MobileShell extends LitElement {
+  private dataManager = new DataManager();
+
+  @state()
+  state: State | null = null;
+
+  @property({ attribute: false })
+  messages: AgentMessage[] = [];
+
+  @state()
+  connectionStatus: ConnectionStatus = "disconnected";
+
+  static styles = css`
+    :host {
+      display: flex;
+      flex-direction: column;
+      /* Use dynamic viewport height for better iOS support */
+      height: 100dvh;
+      /* Fallback for browsers that don't support dvh */
+      height: 100vh;
+      /* iOS Safari custom property fallback */
+      height: calc(var(--vh, 1vh) * 100);
+      /* Additional iOS Safari fix */
+      min-height: 100vh;
+      min-height: -webkit-fill-available;
+      width: 100vw;
+      background-color: #ffffff;
+      font-family:
+        -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif;
+    }
+
+    .mobile-container {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      overflow: hidden;
+    }
+
+    mobile-title {
+      flex-shrink: 0;
+    }
+
+    mobile-chat {
+      flex: 1;
+      overflow: hidden;
+    }
+
+    mobile-chat-input {
+      flex-shrink: 0;
+    }
+  `;
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.setupDataManager();
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    // Remove event listeners
+    this.dataManager.removeEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this),
+    );
+    this.dataManager.removeEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this),
+    );
+  }
+
+  private setupDataManager() {
+    // Add event listeners
+    this.dataManager.addEventListener(
+      "dataChanged",
+      this.handleDataChanged.bind(this),
+    );
+    this.dataManager.addEventListener(
+      "connectionStatusChanged",
+      this.handleConnectionStatusChanged.bind(this),
+    );
+
+    // Initialize the data manager - it will automatically connect to /stream?from=0
+    this.dataManager.initialize();
+  }
+
+  private handleDataChanged(eventData: {
+    state: State;
+    newMessages: AgentMessage[];
+  }) {
+    const { state, newMessages } = eventData;
+
+    if (state) {
+      this.state = state;
+    }
+
+    // Update messages using the same pattern as main app shell
+    this.messages = aggregateAgentMessages(this.messages, newMessages);
+  }
+
+  private handleConnectionStatusChanged(
+    status: ConnectionStatus,
+    errorMessage?: string,
+  ) {
+    this.connectionStatus = status;
+  }
+
+  private handleSendMessage = async (
+    event: CustomEvent<{ message: string }>,
+  ) => {
+    const message = event.detail.message.trim();
+    if (!message) {
+      return;
+    }
+
+    try {
+      // 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) {
+        console.error("Failed to send message:", response.statusText);
+      }
+    } catch (error) {
+      console.error("Error sending message:", error);
+    }
+  };
+
+  render() {
+    const isThinking =
+      this.state?.outstanding_llm_calls > 0 ||
+      (this.state?.outstanding_tool_calls?.length ?? 0) > 0;
+
+    return html`
+      <div class="mobile-container">
+        <mobile-title
+          .connectionStatus=${this.connectionStatus}
+          .isThinking=${isThinking}
+        ></mobile-title>
+
+        <mobile-chat
+          .messages=${this.messages}
+          .isThinking=${isThinking}
+        ></mobile-chat>
+
+        <mobile-chat-input
+          .disabled=${this.connectionStatus !== "connected"}
+          @send-message=${this.handleSendMessage}
+        ></mobile-chat-input>
+      </div>
+    `;
+  }
+}
diff --git a/webui/src/web-components/mobile-title.ts b/webui/src/web-components/mobile-title.ts
new file mode 100644
index 0000000..95198fa
--- /dev/null
+++ b/webui/src/web-components/mobile-title.ts
@@ -0,0 +1,152 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { ConnectionStatus } from "../data";
+
+@customElement("mobile-title")
+export class MobileTitle extends LitElement {
+  @property({ type: String })
+  connectionStatus: ConnectionStatus = "disconnected";
+
+  @property({ type: Boolean })
+  isThinking = false;
+
+  static styles = css`
+    :host {
+      display: block;
+      background-color: #f8f9fa;
+      border-bottom: 1px solid #e9ecef;
+      padding: 12px 16px;
+    }
+
+    .title-container {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #212529;
+      margin: 0;
+    }
+
+    .status-indicator {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 14px;
+    }
+
+    .status-dot {
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      flex-shrink: 0;
+    }
+
+    .status-dot.connected {
+      background-color: #28a745;
+    }
+
+    .status-dot.connecting {
+      background-color: #ffc107;
+      animation: pulse 1.5s ease-in-out infinite;
+    }
+
+    .status-dot.disconnected {
+      background-color: #dc3545;
+    }
+
+    .thinking-indicator {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      color: #6c757d;
+      font-size: 13px;
+    }
+
+    .thinking-dots {
+      display: flex;
+      gap: 2px;
+    }
+
+    .thinking-dot {
+      width: 4px;
+      height: 4px;
+      border-radius: 50%;
+      background-color: #6c757d;
+      animation: thinking 1.4s ease-in-out infinite both;
+    }
+
+    .thinking-dot:nth-child(1) {
+      animation-delay: -0.32s;
+    }
+    .thinking-dot:nth-child(2) {
+      animation-delay: -0.16s;
+    }
+    .thinking-dot:nth-child(3) {
+      animation-delay: 0;
+    }
+
+    @keyframes pulse {
+      0%,
+      100% {
+        opacity: 1;
+      }
+      50% {
+        opacity: 0.5;
+      }
+    }
+
+    @keyframes thinking {
+      0%,
+      80%,
+      100% {
+        transform: scale(0);
+      }
+      40% {
+        transform: scale(1);
+      }
+    }
+  `;
+
+  private getStatusText() {
+    switch (this.connectionStatus) {
+      case "connected":
+        return "Connected";
+      case "connecting":
+        return "Connecting...";
+      case "disconnected":
+        return "Disconnected";
+      default:
+        return "Unknown";
+    }
+  }
+
+  render() {
+    return html`
+      <div class="title-container">
+        <h1 class="title">Sketch</h1>
+
+        <div class="status-indicator">
+          ${this.isThinking
+            ? html`
+                <div class="thinking-indicator">
+                  <span>thinking</span>
+                  <div class="thinking-dots">
+                    <div class="thinking-dot"></div>
+                    <div class="thinking-dot"></div>
+                    <div class="thinking-dot"></div>
+                  </div>
+                </div>
+              `
+            : html`
+                <span class="status-dot ${this.connectionStatus}"></span>
+                <span>${this.getStatusText()}</span>
+              `}
+        </div>
+      </div>
+    `;
+  }
+}