webui: split sketch-tool-card into per-tool cards
diff --git a/loop/webui/src/web-components/sketch-tool-calls.ts b/loop/webui/src/web-components/sketch-tool-calls.ts
index e7a5c74..2b4c426 100644
--- a/loop/webui/src/web-components/sketch-tool-calls.ts
+++ b/loop/webui/src/web-components/sketch-tool-calls.ts
@@ -1,172 +1,27 @@
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";
+import "./sketch-tool-card";
@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 */
+ /* Container for all tool calls */
}
- .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 */
+ /* Header for tool calls section */
.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;
- }
-
+ /* Card container */
.tool-call-card {
display: flex;
flex-direction: column;
@@ -175,42 +30,7 @@
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;
- }
-
+ /* Status indicators for tool calls */
.tool-call-status {
margin-right: 4px;
text-align: center;
@@ -222,70 +42,6 @@
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);
@@ -294,344 +50,73 @@
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
- >`;
-
+ cardForToolCall(toolCall: ToolCall, open: boolean) {
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>`;
+ return html`<sketch-tool-card-bash
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-bash>`;
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>`;
+ return html`<sketch-tool-card-codereview
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-codereview>`;
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}
- `;
+ return html`<sketch-tool-card-done
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-done>`;
+ case "patch":
+ return html`<sketch-tool-card-patch
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-patch>`;
+ case "think":
+ return html`<sketch-tool-card-think
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-think>`;
+ case "title":
+ return html`<sketch-tool-card-title
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-title>`;
}
+ return html`<sketch-tool-card-generic
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-generic>`;
}
+
render() {
- return html` <div class="tool-calls-container">
+ 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)}
+ ${this.toolCalls?.map((toolCall, idx) => {
+ let lastCall = false;
+ if (idx == this.toolCalls?.length - 1) {
+ lastCall = true;
+ }
+ return html`<div
+ id="${toolCall.tool_call_id}"
+ class="tool-call-card ${toolCall.name}"
+ >
+ ${this.cardForToolCall(toolCall, lastCall)}
</div>`;
})}
</div>