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-tool-calls.ts b/loop/webui/src/web-components/sketch-tool-calls.ts
new file mode 100644
index 0000000..a8d0acc
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-tool-calls.ts
@@ -0,0 +1,639 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { repeat } from "lit/directives/repeat.js";
+import { customElement, property } from "lit/decorators.js";
+import { State, ToolCall } from "../types";
+import { marked, MarkedOptions } from "marked";
+
+@customElement("sketch-tool-calls")
+export class SketchToolCalls extends LitElement {
+ @property()
+ toolCalls: ToolCall[] = [];
+
+ // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
+ // Note that these styles only apply to the scope of this web component's
+ // shadow DOM node, so they won't leak out or collide with CSS declared in
+ // other components or the containing web page (...unless you want it to do that).
+ static styles = css`
+ /* Tool calls container styles */
+ .tool-calls-container {
+ /* Removed dotted border */
+ }
+
+ .tool-calls-toggle {
+ cursor: pointer;
+ background-color: #f0f0f0;
+ padding: 5px 10px;
+ border: none;
+ border-radius: 4px;
+ text-align: left;
+ font-size: 12px;
+ margin-top: 5px;
+ color: #555;
+ font-weight: 500;
+ }
+
+ .tool-calls-toggle:hover {
+ background-color: #e0e0e0;
+ }
+
+ .tool-calls-details {
+ margin-top: 10px;
+ transition: max-height 0.3s ease;
+ }
+
+ .tool-calls-details.collapsed {
+ max-height: 0;
+ overflow: hidden;
+ margin-top: 0;
+ }
+
+ .tool-call {
+ background: #f9f9f9;
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 10px;
+ border-left: 3px solid #4caf50;
+ }
+
+ .tool-call-header {
+ margin-bottom: 8px;
+ font-size: 14px;
+ padding: 2px 0;
+ }
+
+ /* Compact tool display styles */
+ .tool-compact-line {
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 1.4;
+ padding: 4px 6px;
+ background: #f8f8f8;
+ border-radius: 3px;
+ position: relative;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+ display: flex;
+ align-items: center;
+ }
+
+ .tool-result-inline {
+ font-family: monospace;
+ color: #0066bb;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 400px;
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ .copy-inline-button {
+ font-size: 10px;
+ padding: 2px 4px;
+ margin-left: 8px;
+ background: #eee;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ opacity: 0.7;
+ }
+
+ .copy-inline-button:hover {
+ opacity: 1;
+ background: #ddd;
+ }
+
+ .tool-input.compact,
+ .tool-result.compact {
+ margin: 2px 0;
+ padding: 4px;
+ font-size: 12px;
+ }
+
+ /* Removed old compact container CSS */
+
+ /* Ultra-compact tool call box styles */
+ .tool-calls-header {
+ /* Empty header - just small spacing */
+ }
+
+ .tool-call-boxes-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 8px;
+ }
+
+ .tool-call-wrapper {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 4px;
+ }
+
+ .tool-call-box {
+ display: inline-flex;
+ align-items: center;
+ background: #f0f0f0;
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 12px;
+ cursor: pointer;
+ max-width: 320px;
+ position: relative;
+ border: 1px solid #ddd;
+ transition: background-color 0.2s;
+ }
+
+ .tool-call-box:hover {
+ background-color: #e8e8e8;
+ }
+
+ .tool-call-box.expanded {
+ background-color: #e0e0e0;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom: 1px solid #ccc;
+ }
+
+ .tool-call-input {
+ color: #666;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: monospace;
+ font-size: 11px;
+ }
+
+ .tool-call-card {
+ display: flex;
+ flex-direction: column;
+ background-color: white;
+ overflow: hidden;
+ cursor: pointer;
+ }
+
+ /* Compact view (default) */
+ .tool-call-compact-view {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.9em;
+ white-space: nowrap;
+ overflow: visible; /* Don't hide overflow, we'll handle text truncation per element */
+ position: relative; /* For positioning the expand icon */
+ }
+
+ /* Expanded view (hidden by default) */
+ .tool-call-card.collapsed .tool-call-expanded-view {
+ display: none;
+ }
+
+ .tool-call-expanded-view {
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid #eee;
+ }
+
+ .tool-call-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 10px;
+ background-color: #f0f0f0;
+ border-bottom: 1px solid #ddd;
+ font-weight: bold;
+ }
+
+ .tool-call-name {
+ color: gray;
+ }
+
+ .tool-call-status {
+ margin-right: 4px;
+ text-align: center;
+ }
+
+ .tool-call-status.spinner {
+ animation: spin 1s infinite linear;
+ display: inline-block;
+ width: 1em;
+ }
+
+ .tool-call-time {
+ margin-left: 8px;
+ font-size: 0.85em;
+ color: #666;
+ font-weight: normal;
+ }
+
+ .tool-call-input-preview {
+ color: #555;
+ font-family: var(--monospace-font);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 30%;
+ background-color: rgba(240, 240, 240, 0.5);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-size: 0.9em;
+ }
+
+ .tool-call-result-preview {
+ color: #28a745;
+ font-family: var(--monospace-font);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 40%;
+ background-color: rgba(240, 248, 240, 0.5);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-size: 0.9em;
+ }
+
+ .tool-call-expand-icon {
+ position: absolute;
+ right: 10px;
+ font-size: 0.8em;
+ color: #888;
+ }
+
+ .tool-call-input {
+ padding: 6px 10px;
+ border-bottom: 1px solid #eee;
+ font-family: var(--monospace-font);
+ font-size: 0.9em;
+ white-space: pre-wrap;
+ word-break: break-all;
+ background-color: #f5f5f5;
+ }
+
+ .tool-call-result {
+ padding: 6px 10px;
+ font-family: var(--monospace-font);
+ font-size: 0.9em;
+ white-space: pre-wrap;
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ .tool-call-result pre {
+ margin: 0;
+ white-space: pre-wrap;
+ }
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+
+ /* Standalone tool messages (legacy/disconnected) */
+ .tool-details.standalone .tool-header {
+ border-radius: 4px;
+ background-color: #fff3cd;
+ border-color: #ffeeba;
+ }
+
+ .tool-details.standalone .tool-warning {
+ margin-left: 10px;
+ font-size: 0.85em;
+ color: #856404;
+ font-style: italic;
+ }
+
+ /* Tool call expanded view with sections */
+ .tool-call-section {
+ border-bottom: 1px solid #eee;
+ }
+
+ .tool-call-section:last-child {
+ border-bottom: none;
+ }
+
+ .tool-call-section-label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 10px;
+ background-color: #f5f5f5;
+ font-weight: bold;
+ font-size: 0.9em;
+ }
+
+ .tool-call-section-content {
+ padding: 0;
+ }
+
+ .tool-call-copy-btn {
+ background-color: #f0f0f0;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 2px 8px;
+ font-size: 0.8em;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ }
+
+ .tool-call-copy-btn:hover {
+ background-color: #e0e0e0;
+ }
+
+ /* Override for tool call input in expanded view */
+ .tool-call-section-content .tool-call-input {
+ margin: 0;
+ padding: 8px 10px;
+ border: none;
+ background-color: #fff;
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ .tool-call-card .tool-call-input-preview,
+ .tool-call-card .tool-call-result-preview {
+ font-family: monospace;
+ background: black;
+ padding: 1em;
+ }
+ .tool-call-input-preview {
+ color: white;
+ }
+ .tool-call-result-preview {
+ color: gray;
+ }
+
+ .tool-call-card.title {
+ font-style: italic;
+ }
+
+ .cancel-button {
+ background: rgb(76, 175, 80);
+ color: white;
+ border: none;
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ margin: 5px;
+ }
+
+ .cancel-button:hover {
+ background: rgb(200, 35, 51) !important;
+ }
+
+ .thought-bubble {
+ position: relative;
+ background-color: #eee;
+ border-radius: 8px;
+ padding: 0.5em;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
+ margin-left: 24px;
+ margin-top: 24px;
+ margin-bottom: 12px;
+ max-width: 30%;
+ white-space: pre;
+ }
+
+ .thought-bubble .preview {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .thought-bubble:before {
+ content: '';
+ position: absolute;
+ top: -8px;
+ left: -8px;
+ width: 15px;
+ height: 15px;
+ background-color: #eee;
+ border-radius: 50%;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+ }
+
+ .thought-bubble:after {
+ content: '';
+ position: absolute;
+ top: -16px;
+ left: -16px;
+ width: 8px;
+ height: 8px;
+ background-color: #eee;
+ border-radius: 50%;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+ }
+
+
+ .patch-input-preview {
+ color: #555;
+ font-family: monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 30%;
+ background-color: rgba(240, 240, 240, 0.5);
+ padding: 2px 5px;
+ border-radius: 3px;
+ font-size: 0.9em;
+ }
+
+ .codereview-OK {
+ color: green;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ renderMarkdown(markdownContent: string): string {
+ try {
+ // Set markdown options for proper code block highlighting and safety
+ const markedOptions: MarkedOptions = {
+ gfm: true, // GitHub Flavored Markdown
+ breaks: true, // Convert newlines to <br>
+ async: false,
+ // DOMPurify is recommended for production, but not included in this implementation
+ };
+ return marked.parse(markdownContent, markedOptions) as string;
+ } catch (error) {
+ console.error("Error rendering markdown:", error);
+ // Fallback to plain text if markdown parsing fails
+ return markdownContent;
+ }
+ }
+
+ _cancelToolCall = async (tool_call_id: string, button: HTMLButtonElement) => {
+ console.log("cancelToolCall", tool_call_id, button);
+ button.innerText = "Cancelling";
+ button.disabled = true;
+ try {
+ const response = await fetch("cancel", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ tool_call_id: tool_call_id,
+ reason: "user requested cancellation",
+ }),
+ });
+ if (response.ok) {
+ console.log("cancel", tool_call_id, response);
+ button.parentElement.removeChild(button);
+ } else {
+ button.innerText = "Cancel";
+ console.log(`error trying to cancel ${tool_call_id}: `, response);
+ }
+ } catch (e) {
+ console.error("cancel", tool_call_id, e);
+ }
+ };
+
+ toolCard(toolCall: ToolCall) {
+ const toolCallStatus = toolCall.result_message
+ ? toolCall.result_message.tool_error
+ ? "❌"
+ : ""
+ : "⏳";
+
+ const cancelButton = toolCall.result_message
+ ? ""
+ : html`<button
+ class="cancel-button"
+ title="Cancel this operation"
+ @click=${(e: Event) => {
+ e.stopPropagation();
+ const button = e.target as HTMLButtonElement;
+ this._cancelToolCall(toolCall.tool_call_id, button);
+ }}
+ >
+ Cancel
+ </button>`;
+
+ const status = html`<span
+ class="tool-call-status ${toolCall.result_message ? "" : "spinner"}"
+ >${toolCallStatus}</span
+ >`;
+
+ switch (toolCall.name) {
+ case "title":
+ const titleInput = JSON.parse(toolCall.input);
+ return html`
+ <div class="tool-call-compact-view">
+ I've set the title of this sketch to <b>"${titleInput.title}"</b>
+ </div>`;
+ case "bash":
+ const bashInput = JSON.parse(toolCall.input);
+ return html`
+ <div class="tool-call-compact-view">
+ ${status}
+ <span class="tool-call-name">${toolCall.name}</span>
+ <pre class="tool-call-input-preview">${bashInput.command}</pre>
+ ${toolCall.result_message
+ ? html`
+ ${toolCall.result_message.tool_result
+ ? html`
+ <pre class="tool-call-result-preview">
+${toolCall.result_message.tool_result}</pre>`
+ : ""}`
+ : cancelButton}
+ </div>`;
+ case "codereview":
+ return html`
+ <div class="tool-call-compact-view">
+ ${status}
+ <span class="tool-call-name">${toolCall.name}</span>
+ ${cancelButton}
+ <code class="codereview-preview codereview-${toolCall.result_message?.tool_result}">${toolCall.result_message?.tool_result == 'OK' ? '✔️': '⛔ ' + toolCall.result_message?.tool_result}</code>
+ </div>`;
+ case "think":
+ const thinkInput = JSON.parse(toolCall.input);
+ return html`
+ <div class="tool-call-compact-view">
+ ${status}
+ <span class="tool-call-name">${toolCall.name}</span>
+ <div class="thought-bubble"><div class="preview">${thinkInput.thoughts}</div></div>
+ ${cancelButton}
+ </div>`;
+ case "patch":
+ const patchInput = JSON.parse(toolCall.input);
+ return html`
+ <div class="tool-call-compact-view">
+ ${status}
+ <span class="tool-call-name">${toolCall.name}</span>
+ <div class="patch-input-preview"><span class="patch-path">${patchInput.path}</span>: ${patchInput.patches.length} edit${patchInput.patches.length > 1 ? 's': ''}</div>
+ ${cancelButton}
+ </div>`;
+ case "done":
+ const doneInput = JSON.parse(toolCall.input);
+ return html`
+ <div class="tool-call-compact-view">
+ ${status}
+ <span class="tool-call-name">${toolCall.name}</span>
+ <div class="done-input-preview">
+ ${Object.keys(doneInput.checklist_items).map((key) => {
+ const item = doneInput.checklist_items[key];
+ let statusIcon = '⛔';
+ if (item.status == 'yes') {
+ statusIcon = '👍';
+ } else if (item.status =='not applicable') {
+ statusIcon = '🤷♂️';
+ }
+ return html`<div><span>${statusIcon}</span> ${key}:${item.status}</div>`;
+ })}
+ </div>
+ ${cancelButton}
+ </div>`;
+
+ default: // Generic tool card:
+ return html`
+ <div class="tool-call-compact-view">
+ ${status}
+ <span class="tool-call-name">${toolCall.name}</span>
+ <code class="tool-call-input-preview">${toolCall.input}</code>
+ ${cancelButton}
+ <code class="tool-call-result-preview">${toolCall.result_message?.tool_result}</code>
+ </div>
+ ${toolCall.result_message?.tool_result}
+ `;
+ }
+ }
+ render() {
+ return html`
+ <div class="tool-calls-container">
+ <div class="tool-calls-header"></div>
+ <div class="tool-call-cards-container">
+ ${this.toolCalls?.map((toolCall) => {
+ return html`<div class="tool-call-card ${toolCall.name}">
+ ${this.toolCard(toolCall)}
+ </div>`;
+ })}
+ </div>
+ </div>`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-tool-calls": SketchToolCalls;
+ }
+}