loop/webui: swtich to web components impl (#1)

* loop/webui: swtich to web components impl

This change reorganizes the original vibe-coded
frontend code into a structure that's much
easier for a human to read and reason about,
while retaining the user-visible functionality
of its vibe-coded predecessor. Perhaps most
importantly, this change makes the code testable.

Some other notable details:

This does not use any of the popular large web
frameworks, but instead follows more of an
"a la carte" approach: leverage features
that already exist in modern web browsers,
like custom elements and shadow DOM.

Templating and basic component lifecycle
management are provided by lit.

State management is nothing fancy. It
doesn't use any library or framework, just
a basic "Events up, properties down"
approach.

* fix bad esbuild.go merge

* loop/webui: don't bundle src/web-components/demo

* loop/webui: don't 'npm ci' dev deps in the container

* rebase to main, undo README.md changes, add webuil.Build() call to LaunchContainer()
diff --git a/loop/webui/src/web-components/sketch-diff-view.ts b/loop/webui/src/web-components/sketch-diff-view.ts
new file mode 100644
index 0000000..562eb1e
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-diff-view.ts
@@ -0,0 +1,566 @@
+import {css, html, LitElement, unsafeCSS} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import * as Diff2Html from "diff2html";
+
+@customElement('sketch-diff-view')
+export class SketchDiffView extends LitElement {
+  // Current commit hash being viewed
+  @property({type: String})
+  commitHash: string = "";
+
+  // Selected line in the diff for commenting
+  @state()
+  private selectedDiffLine: string | null = null;
+
+  // View format (side-by-side or line-by-line)
+  @state()
+  private viewFormat: "side-by-side" | "line-by-line" = "side-by-side";
+
+  static styles = css`
+    .diff-view {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+      height: 100%;
+    }
+    
+    .diff-container {
+      height: 100%;
+      overflow: auto;
+      flex: 1;
+      padding: 0 1rem;
+    }
+    
+    #diff-view-controls {
+      display: flex;
+      justify-content: flex-end;
+      padding: 10px;
+      background: #f8f8f8;
+      border-bottom: 1px solid #eee;
+    }
+    
+    .diff-view-format {
+      display: flex;
+      gap: 10px;
+    }
+    
+    .diff-view-format label {
+      cursor: pointer;
+    }
+    
+    .diff2html-content {
+      font-family: var(--monospace-font);
+      position: relative;
+    }
+    
+    /* Comment box styles */
+    .diff-comment-box {
+      position: fixed;
+      bottom: 80px;
+      right: 20px;
+      width: 400px;
+      background-color: white;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+      padding: 16px;
+      z-index: 1000;
+    }
+    
+    .diff-comment-box h3 {
+      margin-top: 0;
+      margin-bottom: 10px;
+      font-size: 16px;
+    }
+    
+    .selected-line {
+      margin-bottom: 10px;
+      font-size: 14px;
+    }
+    
+    .selected-line pre {
+      padding: 6px;
+      background: #f5f5f5;
+      border: 1px solid #eee;
+      border-radius: 3px;
+      margin: 5px 0;
+      max-height: 100px;
+      overflow: auto;
+      font-family: var(--monospace-font);
+      font-size: 13px;
+      white-space: pre-wrap;
+    }
+    
+    #diffCommentInput {
+      width: 100%;
+      height: 100px;
+      padding: 8px;
+      border: 1px solid #ddd;
+      border-radius: 4px;
+      resize: vertical;
+      font-family: inherit;
+      margin-bottom: 10px;
+    }
+    
+    .diff-comment-buttons {
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+    }
+    
+    .diff-comment-buttons button {
+      padding: 6px 12px;
+      border-radius: 4px;
+      border: 1px solid #ddd;
+      background: white;
+      cursor: pointer;
+    }
+    
+    .diff-comment-buttons button:hover {
+      background: #f5f5f5;
+    }
+    
+    .diff-comment-buttons button#submitDiffComment {
+      background: #1a73e8;
+      color: white;
+      border-color: #1a73e8;
+    }
+    
+    .diff-comment-buttons button#submitDiffComment:hover {
+      background: #1967d2;
+    }
+    
+    /* Styles for the comment button on diff lines */
+    .d2h-gutter-comment-button {
+      position: absolute;
+      right: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      visibility: hidden;
+      background: rgba(255, 255, 255, 0.8);
+      border-radius: 50%;
+      width: 16px;
+      height: 16px;
+      line-height: 13px;
+      text-align: center;
+      font-size: 14px;
+      cursor: pointer;
+      color: #666;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+    }
+    
+    tr:hover .d2h-gutter-comment-button {
+      visibility: visible;
+    }
+    
+    .d2h-gutter-comment-button:hover {
+      background: white;
+      color: #333;
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+    }
+  `;
+  
+  constructor() {
+    super();
+  }
+  
+  // See https://lit.dev/docs/components/lifecycle/
+  connectedCallback() {
+    super.connectedCallback();
+    
+    // Load the diff2html CSS if needed
+    this.loadDiff2HtmlCSS();
+  }
+  
+  // Load diff2html CSS into the shadow DOM
+  private async loadDiff2HtmlCSS() {
+    try {
+      // Check if diff2html styles are already loaded
+      const styleId = 'diff2html-styles';
+      if (this.shadowRoot?.getElementById(styleId)) {
+        return; // Already loaded
+      }
+      
+      // Fetch the diff2html CSS
+      const response = await fetch('static/diff2html.min.css');
+      
+      if (!response.ok) {
+        console.error(`Failed to load diff2html CSS: ${response.status} ${response.statusText}`);
+        return;
+      }
+      
+      const cssText = await response.text();
+      
+      // Create a style element and append to shadow DOM
+      const style = document.createElement('style');
+      style.id = styleId;
+      style.textContent = cssText;
+      this.shadowRoot?.appendChild(style);
+      
+      console.log('diff2html CSS loaded into shadow DOM');
+    } catch (error) {
+      console.error('Error loading diff2html CSS:', error);
+    }
+  }
+  
+  // See https://lit.dev/docs/components/lifecycle/
+  disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+  
+  // Method called to load diff content
+  async loadDiffContent() {
+    // Wait for the component to be rendered
+    await this.updateComplete;
+    
+    const diff2htmlContent = this.shadowRoot?.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+    
+    try {
+      // Show loading state
+      diff2htmlContent.innerHTML = "Loading enhanced diff...";
+      
+      // Build the diff URL - include commit hash if specified
+      const diffUrl = this.commitHash ? `diff?commit=${this.commitHash}` : "diff";
+      
+      // Fetch the diff from the server
+      const response = await fetch(diffUrl);
+      
+      if (!response.ok) {
+        throw new Error(
+          `Server returned ${response.status}: ${response.statusText}`,
+        );
+      }
+      
+      const diffText = await response.text();
+      
+      if (!diffText || diffText.trim() === "") {
+        diff2htmlContent.innerHTML =
+          "<span style='color: #666; font-style: italic;'>No changes detected since conversation started.</span>";
+        return;
+      }
+      
+      // Render the diff using diff2html
+      const diffHtml = Diff2Html.html(diffText, {
+        outputFormat: this.viewFormat,
+        drawFileList: true,
+        matching: "lines",
+        renderNothingWhenEmpty: false,
+        colorScheme: "light" as any, // Force light mode to match the rest of the UI
+      });
+      
+      // Insert the generated HTML
+      diff2htmlContent.innerHTML = diffHtml;
+      
+      // Add CSS styles to ensure we don't have double scrollbars
+      const d2hFiles = diff2htmlContent.querySelectorAll(".d2h-file-wrapper");
+      d2hFiles.forEach((file) => {
+        const contentElem = file.querySelector(".d2h-files-diff");
+        if (contentElem) {
+          // Remove internal scrollbar - the outer container will handle scrolling
+          (contentElem as HTMLElement).style.overflow = "visible";
+          (contentElem as HTMLElement).style.maxHeight = "none";
+        }
+      });
+      
+      // Add click event handlers to each code line for commenting
+      this.setupDiffLineComments();
+      
+    } catch (error) {
+      console.error("Error loading diff2html content:", error);
+      const errorMessage =
+        error instanceof Error ? error.message : "Unknown error";
+      diff2htmlContent.innerHTML = `<span style='color: #dc3545;'>Error loading enhanced diff: ${errorMessage}</span>`;
+    }
+  }
+  
+  // Handle view format changes
+  private handleViewFormatChange(event: Event) {
+    const input = event.target as HTMLInputElement;
+    if (input.checked) {
+      this.viewFormat = input.value as "side-by-side" | "line-by-line";
+      this.loadDiffContent();
+    }
+  }
+  
+  /**
+   * Setup handlers for diff code lines to enable commenting
+   */
+  private setupDiffLineComments(): void {
+    const diff2htmlContent = this.shadowRoot?.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+    
+    console.log("Setting up diff line comments");
+    
+    // Add plus buttons to each code line
+    this.addCommentButtonsToCodeLines();
+    
+    // Use event delegation for handling clicks on plus buttons
+    diff2htmlContent.addEventListener("click", (event) => {
+      const target = event.target as HTMLElement;
+      
+      // Only respond to clicks on the plus button
+      if (target.classList.contains("d2h-gutter-comment-button")) {
+        // Find the parent row first
+        const row = target.closest("tr");
+        if (!row) return;
+        
+        // Then find the code line in that row
+        const codeLine = row.querySelector(".d2h-code-side-line") || row.querySelector(".d2h-code-line");
+        if (!codeLine) return;
+        
+        // Get the line text content
+        const lineContent = codeLine.querySelector(".d2h-code-line-ctn");
+        if (!lineContent) return;
+        
+        const lineText = lineContent.textContent?.trim() || "";
+        
+        // Get file name to add context
+        const fileHeader = codeLine
+          .closest(".d2h-file-wrapper")
+          ?.querySelector(".d2h-file-name");
+        const fileName = fileHeader
+          ? fileHeader.textContent?.trim()
+          : "Unknown file";
+        
+        // Get line number if available
+        const lineNumElem = codeLine
+          .closest("tr")
+          ?.querySelector(".d2h-code-side-linenumber");
+        const lineNum = lineNumElem ? lineNumElem.textContent?.trim() : "";
+        const lineInfo = lineNum ? `Line ${lineNum}: ` : "";
+        
+        // Format the line for the comment box with file context and line number
+        const formattedLine = `${fileName} ${lineInfo}${lineText}`;
+        
+        console.log("Comment button clicked for line: ", formattedLine);
+        
+        // Open the comment box with this line
+        this.openDiffCommentBox(formattedLine);
+        
+        // Prevent event from bubbling up
+        event.stopPropagation();
+      }
+    });
+  }
+  
+  /**
+   * Add plus buttons to each table row in the diff for commenting
+   */
+  private addCommentButtonsToCodeLines(): void {
+    const diff2htmlContent = this.shadowRoot?.getElementById("diff2htmlContent");
+    if (!diff2htmlContent) return;
+    
+    // Target code lines first, then find their parent rows
+    const codeLines = diff2htmlContent.querySelectorAll(
+      ".d2h-code-side-line, .d2h-code-line"
+    );
+    
+    // Create a Set to store unique rows to avoid duplicates
+    const rowsSet = new Set<HTMLElement>();
+    
+    // Get all rows that contain code lines
+    codeLines.forEach(line => {
+      const row = line.closest('tr');
+      if (row) rowsSet.add(row as HTMLElement);
+    });
+    
+    // Convert Set back to array for processing
+    const codeRows = Array.from(rowsSet);
+    
+    codeRows.forEach((row) => {
+      const rowElem = row as HTMLElement;
+      
+      // Skip info lines without actual code (e.g., "file added")
+      if (rowElem.querySelector(".d2h-info")) {
+        return;
+      }
+      
+      // Find the code line number element (first TD in the row)
+      const lineNumberCell = rowElem.querySelector(
+        ".d2h-code-side-linenumber, .d2h-code-linenumber"
+      );
+      
+      if (!lineNumberCell) return;
+      
+      // Create the plus button
+      const plusButton = document.createElement("span");
+      plusButton.className = "d2h-gutter-comment-button";
+      plusButton.innerHTML = "+";
+      plusButton.title = "Add a comment on this line";
+      
+      // Add button to the line number cell for proper positioning
+      (lineNumberCell as HTMLElement).style.position = "relative"; // Ensure positioning context
+      lineNumberCell.appendChild(plusButton);
+    });
+  }
+  
+  /**
+   * Open the comment box for a selected diff line
+   */
+  private openDiffCommentBox(lineText: string): void {
+    // Make sure the comment box div exists
+    const commentBoxId = "diffCommentBox";
+    let commentBox = this.shadowRoot?.getElementById(commentBoxId);
+    
+    // If it doesn't exist, create it
+    if (!commentBox) {
+      commentBox = document.createElement("div");
+      commentBox.id = commentBoxId;
+      commentBox.className = "diff-comment-box";
+      
+      // Create the comment box contents
+      commentBox.innerHTML = `
+        <h3>Add a comment</h3>
+        <div class="selected-line">
+          Line:
+          <pre id="selectedLine"></pre>
+        </div>
+        <textarea
+          id="diffCommentInput"
+          placeholder="Enter your comment about this line..."
+        ></textarea>
+        <div class="diff-comment-buttons">
+          <button id="cancelDiffComment">Cancel</button>
+          <button id="submitDiffComment">Add Comment</button>
+        </div>
+      `;
+      
+      this.shadowRoot?.appendChild(commentBox);
+    }
+    
+    // Store the selected line
+    this.selectedDiffLine = lineText;
+    
+    // Display the line in the comment box
+    const selectedLine = this.shadowRoot?.getElementById("selectedLine");
+    if (selectedLine) {
+      selectedLine.textContent = lineText;
+    }
+    
+    // Reset the comment input
+    const commentInput = this.shadowRoot?.getElementById(
+      "diffCommentInput"
+    ) as HTMLTextAreaElement;
+    if (commentInput) {
+      commentInput.value = "";
+    }
+    
+    // Show the comment box
+    if (commentBox) {
+      commentBox.style.display = "block";
+    }
+    
+    // Add event listeners for submit and cancel buttons
+    const submitButton = this.shadowRoot?.getElementById("submitDiffComment");
+    if (submitButton) {
+      submitButton.onclick = () => this.submitDiffComment();
+    }
+    
+    const cancelButton = this.shadowRoot?.getElementById("cancelDiffComment");
+    if (cancelButton) {
+      cancelButton.onclick = () => this.closeDiffCommentBox();
+    }
+    
+    // Focus on the comment input
+    if (commentInput) {
+      commentInput.focus();
+    }
+  }
+  
+  /**
+   * Close the diff comment box without submitting
+   */
+  private closeDiffCommentBox(): void {
+    const commentBox = this.shadowRoot?.getElementById("diffCommentBox");
+    if (commentBox) {
+      commentBox.style.display = "none";
+    }
+    this.selectedDiffLine = null;
+  }
+  
+  /**
+   * Submit a comment on a diff line
+   */
+  private submitDiffComment(): void {
+    const commentInput = this.shadowRoot?.getElementById(
+      "diffCommentInput"
+    ) as HTMLTextAreaElement;
+    
+    if (!commentInput) return;
+    
+    const comment = commentInput.value.trim();
+    
+    // Validate inputs
+    if (!this.selectedDiffLine || !comment) {
+      alert("Please select a line and enter a comment.");
+      return;
+    }
+    
+    // Format the comment in a readable way
+    const formattedComment = `\`\`\`\n${this.selectedDiffLine}\n\`\`\`\n\n${comment}`;
+    
+    // Dispatch a custom event with the formatted comment
+    const event = new CustomEvent('diff-comment', {
+      detail: { comment: formattedComment },
+      bubbles: true,
+      composed: true
+    });
+    this.dispatchEvent(event);
+    
+    // Close only the comment box but keep the diff view open
+    this.closeDiffCommentBox();
+  }
+  
+  // Clear the current state
+  public clearState(): void {
+    this.commitHash = "";
+  }
+  
+  // Show diff for a specific commit
+  public showCommitDiff(commitHash: string): void {
+    // Store the commit hash
+    this.commitHash = commitHash;
+    // Load the diff content
+    this.loadDiffContent();
+  }
+  
+  render() {
+    return html`
+      <div class="diff-view">
+        <div class="diff-container">
+          <div id="diff-view-controls">
+            <div class="diff-view-format">
+              <label>
+                <input 
+                  type="radio" 
+                  name="diffViewFormat" 
+                  value="side-by-side" 
+                  ?checked=${this.viewFormat === "side-by-side"}
+                  @change=${this.handleViewFormatChange}
+                > Side-by-side
+              </label>
+              <label>
+                <input 
+                  type="radio" 
+                  name="diffViewFormat" 
+                  value="line-by-line"
+                  ?checked=${this.viewFormat === "line-by-line"}
+                  @change=${this.handleViewFormatChange}
+                > Line-by-line
+              </label>
+            </div>
+          </div>
+          <div id="diff2htmlContent" class="diff2html-content"></div>
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-diff-view": SketchDiffView;
+  }
+}