webui: add mobile diff view with Monaco inline diffing

Thanks, Sketch. There are still some rough edges, but it's not bad.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: se4f6567dc0dabd31k
diff --git a/webui/src/web-components/mobile-diff.ts b/webui/src/web-components/mobile-diff.ts
new file mode 100644
index 0000000..92d0f95
--- /dev/null
+++ b/webui/src/web-components/mobile-diff.ts
@@ -0,0 +1,423 @@
+import { css, html, LitElement } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { GitDiffFile, GitDataService, DefaultGitDataService } from "./git-data-service";
+import "./sketch-monaco-view";
+
+@customElement("mobile-diff")
+export class MobileDiff extends LitElement {
+  private gitService: GitDataService = new DefaultGitDataService();
+
+  @state()
+  private files: GitDiffFile[] = [];
+
+  @state()
+  private fileContents: Map<string, { original: string; modified: string }> = new Map();
+
+  @state()
+  private loading: boolean = false;
+
+  @state()
+  private error: string | null = null;
+
+  @state()
+  private baseCommit: string = "";
+
+  @state()
+  private fileExpandStates: Map<string, boolean> = new Map();
+
+  static styles = css`
+    :host {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      min-height: 0;
+      overflow: hidden;
+      background-color: #ffffff;
+    }
+
+
+
+    .diff-container {
+      flex: 1;
+      overflow: auto;
+      min-height: 0;
+      /* Ensure proper scrolling behavior */
+      -webkit-overflow-scrolling: touch;
+    }
+
+    .loading,
+    .error,
+    .empty {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+      font-size: 16px;
+      color: #6c757d;
+      text-align: center;
+      padding: 20px;
+    }
+
+    .error {
+      color: #dc3545;
+    }
+
+    .file-diff {
+      margin-bottom: 16px;
+    }
+
+    .file-diff:last-child {
+      margin-bottom: 0;
+    }
+
+    .file-header {
+      background-color: #f8f9fa;
+      border: 1px solid #e9ecef;
+      border-bottom: none;
+      padding: 12px 16px;
+      font-family: monospace;
+      font-size: 14px;
+      font-weight: 500;
+      color: #495057;
+      position: sticky;
+      top: 0;
+      z-index: 10;
+    }
+
+    .file-status {
+      display: inline-block;
+      padding: 2px 6px;
+      border-radius: 3px;
+      font-size: 12px;
+      font-weight: bold;
+      margin-right: 8px;
+      font-family: sans-serif;
+    }
+
+    .file-status.added {
+      background-color: #d4edda;
+      color: #155724;
+    }
+
+    .file-status.modified {
+      background-color: #fff3cd;
+      color: #856404;
+    }
+
+    .file-status.deleted {
+      background-color: #f8d7da;
+      color: #721c24;
+    }
+
+    .file-status.renamed {
+      background-color: #d1ecf1;
+      color: #0c5460;
+    }
+
+    .file-changes {
+      margin-left: 8px;
+      font-size: 12px;
+      color: #6c757d;
+    }
+
+    .monaco-container {
+      border: 1px solid #e9ecef;
+      border-top: none;
+      min-height: 200px;
+      /* Prevent artifacts */
+      overflow: hidden;
+      background-color: #ffffff;
+    }
+
+    sketch-monaco-view {
+      width: 100%;
+      min-height: 200px;
+    }
+  `;
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.loadDiffData();
+  }
+
+  private async loadDiffData() {
+    this.loading = true;
+    this.error = null;
+    this.files = [];
+    this.fileContents.clear();
+
+    try {
+      // Get base commit reference
+      this.baseCommit = await this.gitService.getBaseCommitRef();
+      
+      // Get diff from base commit to untracked changes (empty string for working directory)
+      this.files = await this.gitService.getDiff(this.baseCommit, "");
+
+      // Ensure files is always an array
+      if (!this.files) {
+        this.files = [];
+      }
+
+      if (this.files.length > 0) {
+        await this.loadAllFileContents();
+      }
+    } catch (error) {
+      console.error("Error loading diff data:", error);
+      this.error = `Error loading diff: ${error instanceof Error ? error.message : String(error)}`;
+      // Ensure files is always an array even on error
+      this.files = [];
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private async loadAllFileContents() {
+    try {
+      const promises = this.files.map(async (file) => {
+        try {
+          let originalCode = "";
+          let modifiedCode = "";
+
+          // Load original content (from the base commit)
+          if (file.status !== "A") {
+            // For modified, renamed, or deleted files: load original content
+            originalCode = await this.gitService.getFileContent(file.old_hash || "");
+          }
+
+          // Load modified content (from working directory)
+          if (file.status === "D") {
+            // Deleted file: empty modified content
+            modifiedCode = "";
+          } else {
+            // Added/modified/renamed: use working copy content
+            try {
+              modifiedCode = await this.gitService.getWorkingCopyContent(file.path);
+            } catch (error) {
+              console.warn(`Could not get working copy for ${file.path}:`, error);
+              modifiedCode = "";
+            }
+          }
+
+          this.fileContents.set(file.path, {
+            original: originalCode,
+            modified: modifiedCode,
+          });
+        } catch (error) {
+          console.error(`Error loading content for file ${file.path}:`, error);
+          // Store empty content for failed files
+          this.fileContents.set(file.path, {
+            original: "",
+            modified: "",
+          });
+        }
+      });
+
+      await Promise.all(promises);
+    } catch (error) {
+      console.error("Error loading file contents:", error);
+      throw error;
+    }
+  }
+
+  private getFileStatusClass(status: string): string {
+    switch (status.toUpperCase()) {
+      case "A":
+        return "added";
+      case "M":
+        return "modified";
+      case "D":
+        return "deleted";
+      case "R":
+      default:
+        if (status.toUpperCase().startsWith("R")) {
+          return "renamed";
+        }
+        return "modified";
+    }
+  }
+
+  private getFileStatusText(status: string): string {
+    switch (status.toUpperCase()) {
+      case "A":
+        return "Added";
+      case "M":
+        return "Modified";
+      case "D":
+        return "Deleted";
+      case "R":
+      default:
+        if (status.toUpperCase().startsWith("R")) {
+          return "Renamed";
+        }
+        return "Modified";
+    }
+  }
+
+  private getChangesInfo(file: GitDiffFile): string {
+    const additions = file.additions || 0;
+    const deletions = file.deletions || 0;
+
+    if (additions === 0 && deletions === 0) {
+      return "";
+    }
+
+    const parts = [];
+    if (additions > 0) {
+      parts.push(`+${additions}`);
+    }
+    if (deletions > 0) {
+      parts.push(`-${deletions}`);
+    }
+
+    return `(${parts.join(", ")})`;
+  }
+
+  private getPathInfo(file: GitDiffFile): string {
+    if (file.old_path && file.old_path !== "") {
+      // For renames, show old_path → new_path
+      return `${file.old_path} → ${file.path}`;
+    }
+    // For regular files, just show the path
+    return file.path;
+  }
+
+  private toggleFileExpansion(filePath: string) {
+    const currentState = this.fileExpandStates.get(filePath) ?? false;
+    const newState = !currentState;
+    this.fileExpandStates.set(filePath, newState);
+
+    // Apply to the specific Monaco view component for this file
+    const monacoView = this.shadowRoot?.querySelector(
+      `sketch-monaco-view[data-file-path="${filePath}"]`,
+    );
+    if (monacoView) {
+      (monacoView as any).toggleHideUnchangedRegions(!newState); // inverted because true means "hide unchanged"
+    }
+
+    // Force a re-render to update the button state
+    this.requestUpdate();
+  }
+
+  private renderExpandAllIcon() {
+    return html`
+      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+        <!-- Dotted line in the middle -->
+        <line
+          x1="2"
+          y1="8"
+          x2="14"
+          y2="8"
+          stroke="currentColor"
+          stroke-width="1"
+          stroke-dasharray="2,1"
+        />
+        <!-- Large arrow pointing up -->
+        <path d="M8 2 L5 6 L11 6 Z" fill="currentColor" />
+        <!-- Large arrow pointing down -->
+        <path d="M8 14 L5 10 L11 10 Z" fill="currentColor" />
+      </svg>
+    `;
+  }
+
+  private renderCollapseIcon() {
+    return html`
+      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+        <!-- Dotted line in the middle -->
+        <line
+          x1="2"
+          y1="8"
+          x2="14"
+          y2="8"
+          stroke="currentColor"
+          stroke-width="1"
+          stroke-dasharray="2,1"
+        />
+        <!-- Large arrow pointing down towards line -->
+        <path d="M8 6 L5 2 L11 2 Z" fill="currentColor" />
+        <!-- Large arrow pointing up towards line -->
+        <path d="M8 10 L5 14 L11 14 Z" fill="currentColor" />
+      </svg>
+    `;
+  }
+
+  private renderFileDiff(file: GitDiffFile) {
+    const content = this.fileContents.get(file.path);
+    const isExpanded = this.fileExpandStates.get(file.path) ?? false;
+    
+    if (!content) {
+      return html`
+        <div class="file-diff">
+          <div class="file-header">
+            <div class="file-header-left">
+              <span class="file-status ${this.getFileStatusClass(file.status)}">
+                ${this.getFileStatusText(file.status)}
+              </span>
+              ${this.getPathInfo(file)}
+              ${this.getChangesInfo(file) ? html`<span class="file-changes">${this.getChangesInfo(file)}</span>` : ""}
+            </div>
+            <button class="file-expand-button" disabled>
+              ${this.renderExpandAllIcon()}
+            </button>
+          </div>
+          <div class="monaco-container">
+            <div class="loading">Loading ${file.path}...</div>
+          </div>
+        </div>
+      `;
+    }
+
+    return html`
+      <div class="file-diff">
+        <div class="file-header">
+          <div class="file-header-left">
+            <span class="file-status ${this.getFileStatusClass(file.status)}">
+              ${this.getFileStatusText(file.status)}
+            </span>
+            ${this.getPathInfo(file)}
+            ${this.getChangesInfo(file) ? html`<span class="file-changes">${this.getChangesInfo(file)}</span>` : ""}
+          </div>
+          <button
+            class="file-expand-button"
+            @click="${() => this.toggleFileExpansion(file.path)}"
+            title="${isExpanded
+              ? "Collapse: Hide unchanged regions to focus on changes"
+              : "Expand: Show all lines including unchanged regions"}"
+          >
+            ${isExpanded ? this.renderCollapseIcon() : this.renderExpandAllIcon()}
+          </button>
+        </div>
+        <div class="monaco-container">
+          <sketch-monaco-view
+            .originalCode="${content.original}"
+            .modifiedCode="${content.modified}"
+            .originalFilename="${file.path}"
+            .modifiedFilename="${file.path}"
+            ?readOnly="true"
+            ?inline="true"
+            data-file-path="${file.path}"
+          ></sketch-monaco-view>
+        </div>
+      </div>
+    `;
+  }
+
+  render() {
+    return html`
+      <div class="diff-container">
+        ${this.loading
+          ? html`<div class="loading">Loading diff...</div>`
+          : this.error 
+          ? html`<div class="error">${this.error}</div>`
+          : !this.files || this.files.length === 0
+          ? html`<div class="empty">No changes to show</div>`
+          : this.files.map(file => this.renderFileDiff(file))}
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "mobile-diff": MobileDiff;
+  }
+}
\ No newline at end of file
diff --git a/webui/src/web-components/mobile-shell.ts b/webui/src/web-components/mobile-shell.ts
index ea9c305..65b698b 100644
--- a/webui/src/web-components/mobile-shell.ts
+++ b/webui/src/web-components/mobile-shell.ts
@@ -7,6 +7,7 @@
 import "./mobile-title";
 import "./mobile-chat";
 import "./mobile-chat-input";
+import "./mobile-diff";
 
 @customElement("mobile-shell")
 export class MobileShell extends LitElement {
@@ -21,6 +22,9 @@
   @state()
   connectionStatus: ConnectionStatus = "disconnected";
 
+  @state()
+  currentView: "chat" | "diff" = "chat";
+
   static styles = css`
     :host {
       display: flex;
@@ -54,10 +58,19 @@
     mobile-chat {
       flex: 1;
       overflow: hidden;
+      min-height: 0;
+    }
+
+    mobile-diff {
+      flex: 1;
+      overflow: hidden;
+      min-height: 0;
     }
 
     mobile-chat-input {
       flex-shrink: 0;
+      /* Ensure proper height calculation */
+      min-height: 64px;
     }
   `;
 
@@ -141,6 +154,10 @@
     }
   };
 
+  private handleViewChange = (event: CustomEvent<{ view: "chat" | "diff" }>) => {
+    this.currentView = event.detail.view;
+  };
+
   render() {
     const isThinking =
       this.state?.outstanding_llm_calls > 0 ||
@@ -152,12 +169,21 @@
           .connectionStatus=${this.connectionStatus}
           .isThinking=${isThinking}
           .skabandAddr=${this.state?.skaband_addr}
+          .currentView=${this.currentView}
+          .slug=${this.state?.slug || ""}
+          @view-change=${this.handleViewChange}
         ></mobile-title>
 
-        <mobile-chat
-          .messages=${this.messages}
-          .isThinking=${isThinking}
-        ></mobile-chat>
+        ${this.currentView === "chat"
+          ? html`
+              <mobile-chat
+                .messages=${this.messages}
+                .isThinking=${isThinking}
+              ></mobile-chat>
+            `
+          : html`
+              <mobile-diff></mobile-diff>
+            `}
 
         <mobile-chat-input
           .disabled=${this.connectionStatus !== "connected"}
diff --git a/webui/src/web-components/mobile-title.ts b/webui/src/web-components/mobile-title.ts
index d000a3f..510f60a 100644
--- a/webui/src/web-components/mobile-title.ts
+++ b/webui/src/web-components/mobile-title.ts
@@ -13,6 +13,12 @@
   @property({ type: String })
   skabandAddr?: string;
 
+  @property({ type: String })
+  currentView: "chat" | "diff" = "chat";
+
+  @property({ type: String })
+  slug: string = "";
+
   static styles = css`
     :host {
       display: block;
@@ -23,10 +29,45 @@
 
     .title-container {
       display: flex;
-      align-items: center;
+      align-items: flex-start;
       justify-content: space-between;
     }
 
+    .title-section {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .right-section {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+
+    .view-selector {
+      background: none;
+      border: 1px solid #e9ecef;
+      border-radius: 6px;
+      padding: 6px 8px;
+      font-size: 14px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.2s ease;
+      color: #495057;
+      min-width: 60px;
+    }
+
+    .view-selector:hover {
+      background-color: #f8f9fa;
+      border-color: #dee2e6;
+    }
+
+    .view-selector:focus {
+      outline: none;
+      border-color: #007acc;
+      box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
+    }
+
     .title {
       font-size: 18px;
       font-weight: 600;
@@ -34,6 +75,8 @@
       margin: 0;
     }
 
+
+
     .title a {
       color: inherit;
       text-decoration: none;
@@ -147,23 +190,47 @@
     }
   }
 
+  private handleViewChange(event: Event) {
+    const select = event.target as HTMLSelectElement;
+    const view = select.value as "chat" | "diff";
+    if (view !== this.currentView) {
+      const changeEvent = new CustomEvent("view-change", {
+        detail: { view },
+        bubbles: true,
+        composed: true,
+      });
+      this.dispatchEvent(changeEvent);
+    }
+  }
+
   render() {
     return html`
       <div class="title-container">
-        <h1 class="title">
-          ${this.skabandAddr
-            ? html`<a
-                href="${this.skabandAddr}"
-                target="_blank"
-                rel="noopener noreferrer"
-              >
-                <img src="${this.skabandAddr}/sketch.dev.png" alt="sketch" />
-                Sketch
-              </a>`
-            : html`Sketch`}
-        </h1>
+        <div class="title-section">
+          <h1 class="title">
+            ${this.skabandAddr
+              ? html`<a
+                  href="${this.skabandAddr}"
+                  target="_blank"
+                  rel="noopener noreferrer"
+                >
+                  <img src="${this.skabandAddr}/sketch.dev.png" alt="sketch" />
+                  ${this.slug || "Sketch"}
+                </a>`
+              : html`${this.slug || "Sketch"}`}
+          </h1>
+        </div>
 
-        <div class="status-indicator">
+        <div class="right-section">
+          <select
+            class="view-selector"
+            .value=${this.currentView}
+            @change=${this.handleViewChange}
+          >
+            <option value="chat">Chat</option>
+            <option value="diff">Diff</option>
+          </select>
+
           ${this.isThinking
             ? html`
                 <div class="thinking-indicator">
@@ -177,7 +244,6 @@
               `
             : html`
                 <span class="status-dot ${this.connectionStatus}"></span>
-                <span>${this.getStatusText()}</span>
               `}
         </div>
       </div>
diff --git a/webui/src/web-components/sketch-monaco-view.ts b/webui/src/web-components/sketch-monaco-view.ts
index e15f3c9..6e783fa 100644
--- a/webui/src/web-components/sketch-monaco-view.ts
+++ b/webui/src/web-components/sketch-monaco-view.ts
@@ -135,6 +135,10 @@
   // Editable state
   @property({ type: Boolean, attribute: "editable-right" })
   editableRight?: boolean;
+
+  // Inline diff mode (for mobile)
+  @property({ type: Boolean, attribute: "inline" })
+  inline?: boolean;
   private container: Ref<HTMLElement> = createRef();
   editor?: monaco.editor.IStandaloneDiffEditor;
 
@@ -704,7 +708,7 @@
           automaticLayout: false, // We'll resize manually
           readOnly: true,
           theme: "vs", // Always use light mode
-          renderSideBySide: true,
+          renderSideBySide: !this.inline,
           ignoreTrimWhitespace: false,
           renderOverviewRuler: false, // Disable the overview ruler
           scrollbar: {