webui: add markdown rendering to mobile chat messages

Add comprehensive markdown support to mobile agent messages matching
desktop functionality with mobile-optimized styling and performance.

Problem Analysis:
Mobile webui was displaying agent messages as plain text while desktop
version rendered rich markdown content including code blocks, headers,
lists, and formatting. This created inconsistent user experience where
mobile users lost important formatting context, making code examples
and structured responses harder to read and understand.

Implementation Changes:

1. Markdown Library Integration:
   - Added marked library import for markdown parsing
   - Integrated DOMPurify for HTML sanitization security
   - Added unsafeHTML directive for rendering parsed content
   - Reused desktop markdown parsing patterns for consistency

2. Mobile-Optimized Rendering:
   - Created simplified renderer without complex features like Mermaid
   - Focused on essential markdown elements: headers, code, lists, links
   - Maintained security through DOMPurify sanitization
   - Preserved plain text rendering for user messages

3. Responsive Typography:
   - Mobile-specific font sizes for headers (1.05em - 1.2em range)
   - Optimized code block styling with horizontal scroll
   - Touch-friendly spacing and line heights
   - Compact margins for small screen readability

4. Content Styling:
   - Subtle code highlighting with background colors
   - Proper blockquote styling with left border
   - List indentation optimized for mobile viewing
   - Link styling that maintains message bubble aesthetics

Technical Details:
- Uses marked.parse() with GFM and breaks enabled for GitHub compatibility
- DOMPurify whitelist approach for security (HTML tags and attributes)
- Conditional rendering: markdown for assistant, plain text for user
- CSS optimized for mobile viewport constraints and touch interaction

Benefits:
- Consistent markdown experience across desktop and mobile
- Improved readability of code examples and structured content
- Maintained security through proper HTML sanitization
- Mobile-optimized styling for touch interfaces
- Better user experience for technical discussions

Testing:
- Verified markdown parsing works with common elements
- Confirmed code blocks render properly with scroll
- Tested security sanitization prevents XSS
- Validated mobile typography and spacing

This enhancement brings mobile webui markdown rendering to feature
parity with desktop while maintaining mobile-specific optimizations.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s99e90082addd4eadk
diff --git a/webui/src/web-components/mobile-chat.ts b/webui/src/web-components/mobile-chat.ts
index d634da5..57ad449 100644
--- a/webui/src/web-components/mobile-chat.ts
+++ b/webui/src/web-components/mobile-chat.ts
@@ -1,7 +1,10 @@
 import { css, html, LitElement } from "lit";
 import { customElement, property, state } from "lit/decorators.js";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
 import { AgentMessage } from "../types";
 import { createRef, ref } from "lit/directives/ref.js";
+import { marked, MarkedOptions, Renderer } from "marked";
+import DOMPurify from "dompurify";
 
 @customElement("mobile-chat")
 export class MobileChat extends LitElement {
@@ -67,6 +70,8 @@
       border-bottom-left-radius: 6px;
     }
 
+
+
     .thinking-message {
       align-self: flex-start;
       align-items: flex-start;
@@ -101,20 +106,12 @@
       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;
-    }
+    .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% {
+      0%, 80%, 100% {
         transform: scale(0.8);
         opacity: 0.5;
       }
@@ -134,14 +131,101 @@
       text-align: center;
       padding: 32px;
     }
+
+    /* Markdown content styling for mobile */
+    .markdown-content {
+      line-height: 1.5;
+      word-wrap: break-word;
+      overflow-wrap: break-word;
+    }
+
+    .markdown-content p {
+      margin: 0.3em 0;
+    }
+
+    .markdown-content p:first-child {
+      margin-top: 0;
+    }
+
+    .markdown-content p:last-child {
+      margin-bottom: 0;
+    }
+
+    .markdown-content h1,
+    .markdown-content h2,
+    .markdown-content h3,
+    .markdown-content h4,
+    .markdown-content h5,
+    .markdown-content h6 {
+      margin: 0.5em 0 0.3em 0;
+      font-weight: bold;
+    }
+
+    .markdown-content h1 { font-size: 1.2em; }
+    .markdown-content h2 { font-size: 1.15em; }
+    .markdown-content h3 { font-size: 1.1em; }
+    .markdown-content h4,
+    .markdown-content h5,
+    .markdown-content h6 { font-size: 1.05em; }
+
+    .markdown-content code {
+      background-color: rgba(0, 0, 0, 0.08);
+      padding: 2px 4px;
+      border-radius: 3px;
+      font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+      font-size: 0.9em;
+    }
+
+    .markdown-content pre {
+      background-color: rgba(0, 0, 0, 0.08);
+      padding: 8px;
+      border-radius: 6px;
+      margin: 0.5em 0;
+      overflow-x: auto;
+      font-size: 0.9em;
+    }
+
+    .markdown-content pre code {
+      background: none;
+      padding: 0;
+    }
+
+    .markdown-content ul,
+    .markdown-content ol {
+      margin: 0.5em 0;
+      padding-left: 1.2em;
+    }
+
+    .markdown-content li {
+      margin: 0.2em 0;
+    }
+
+    .markdown-content blockquote {
+      border-left: 3px solid rgba(0, 0, 0, 0.2);
+      margin: 0.5em 0;
+      padding-left: 0.8em;
+      font-style: italic;
+    }
+
+    .markdown-content a {
+      color: inherit;
+      text-decoration: underline;
+    }
+
+    .markdown-content strong,
+    .markdown-content b {
+      font-weight: bold;
+    }
+
+    .markdown-content em,
+    .markdown-content i {
+      font-style: italic;
+    }
   `;
 
   updated(changedProperties: Map<string, any>) {
     super.updated(changedProperties);
-    if (
-      changedProperties.has("messages") ||
-      changedProperties.has("isThinking")
-    ) {
+    if (changedProperties.has('messages') || changedProperties.has('isThinking')) {
       this.scrollToBottom();
     }
   }
@@ -150,75 +234,107 @@
     // Use requestAnimationFrame to ensure DOM is updated
     requestAnimationFrame(() => {
       if (this.scrollContainer.value) {
-        this.scrollContainer.value.scrollTop =
-          this.scrollContainer.value.scrollHeight;
+        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" });
+    return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
   }
 
   private getMessageRole(message: AgentMessage): string {
-    if (message.type === "user") {
-      return "user";
+    if (message.type === 'user') {
+      return 'user';
     }
-    return "assistant";
+    return 'assistant';
   }
 
   private getMessageText(message: AgentMessage): string {
-    return message.content || "";
+    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
-    );
+    return (message.type === 'user' || message.type === 'agent' || message.type === 'error') && 
+           message.content && message.content.trim().length > 0;
+  }
+
+  private renderMarkdown(markdownContent: string): string {
+    try {
+      // Create a custom renderer for mobile-optimized rendering
+      const renderer = new Renderer();
+      
+      // Override code renderer to simplify for mobile
+      renderer.code = function ({ text, lang }: { text: string; lang?: string }): string {
+        const langClass = lang ? ` class="language-${lang}"` : "";
+        return `<pre><code${langClass}>${text}</code></pre>`;
+      };
+
+      // Set markdown options for mobile
+      const markedOptions: MarkedOptions = {
+        gfm: true, // GitHub Flavored Markdown
+        breaks: true, // Convert newlines to <br>
+        async: false,
+        renderer: renderer,
+      };
+
+      // Parse markdown and sanitize the output HTML
+      const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
+      return DOMPurify.sanitize(htmlOutput, {
+        ALLOWED_TAGS: [
+          "p", "br", "strong", "em", "b", "i", "u", "s", "code", "pre",
+          "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "a"
+        ],
+        ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
+        KEEP_CONTENT: true,
+      });
+    } catch (error) {
+      console.error("Error rendering markdown:", error);
+      // Fallback to sanitized plain text if markdown parsing fails
+      return DOMPurify.sanitize(markdownContent);
+    }
   }
 
   render() {
-    const displayMessages = this.messages.filter((msg) =>
-      this.shouldShowMessage(msg),
-    );
-
+    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>
+        ${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">
+                ${role === 'assistant' 
+                  ? html`<div class="markdown-content">${unsafeHTML(this.renderMarkdown(text))}</div>`
+                  : 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>
     `;
   }