Move webui from /loop/webui to /webui
Thanks, perl (and git mv):
perl -pi -e s,loop/webui,webui,g $(git grep -l loop/webui)
diff --git a/webui/src/web-components/aggregateAgentMessages.ts b/webui/src/web-components/aggregateAgentMessages.ts
new file mode 100644
index 0000000..3dc11f8
--- /dev/null
+++ b/webui/src/web-components/aggregateAgentMessages.ts
@@ -0,0 +1,35 @@
+import { AgentMessage } from "../types";
+
+export function aggregateAgentMessages(
+ arr1: AgentMessage[],
+ arr2: AgentMessage[],
+): AgentMessage[] {
+ const mergedArray = [...arr1, ...arr2];
+ const seenIds = new Set<number>();
+ const toolCallResults = new Map<string, AgentMessage>();
+
+ let ret: AgentMessage[] = mergedArray
+ .filter((msg) => {
+ if (msg.type == "tool") {
+ toolCallResults.set(msg.tool_call_id, msg);
+ return false;
+ }
+ if (seenIds.has(msg.idx)) {
+ return false; // Skip if idx is already seen
+ }
+
+ seenIds.add(msg.idx);
+ return true;
+ })
+ .sort((a: AgentMessage, b: AgentMessage) => a.idx - b.idx);
+
+ // Attach any tool_call result messages to the original message's tool_call object.
+ ret.forEach((msg) => {
+ msg.tool_calls?.forEach((toolCall) => {
+ if (toolCallResults.has(toolCall.tool_call_id)) {
+ toolCall.result_message = toolCallResults.get(toolCall.tool_call_id);
+ }
+ });
+ });
+ return ret;
+}
diff --git a/webui/src/web-components/demo/demo.css b/webui/src/web-components/demo/demo.css
new file mode 100644
index 0000000..08e02a2
--- /dev/null
+++ b/webui/src/web-components/demo/demo.css
@@ -0,0 +1,18 @@
+body {
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ margin: 0;
+ padding: 20px;
+ padding-bottom: 100px; /* Adjusted padding for chat container */
+ color: #333;
+ line-height: 1.4; /* Reduced line height for more compact text */
+}
+
+pre {
+ white-space: normal;
+}
diff --git a/webui/src/web-components/demo/index.html b/webui/src/web-components/demo/index.html
new file mode 100644
index 0000000..77df51e
--- /dev/null
+++ b/webui/src/web-components/demo/index.html
@@ -0,0 +1,29 @@
+<html>
+ <head>
+ <link rel="stylesheet" href="demo.css" />
+ </head>
+ <body>
+ sketch web-components demo index
+ <ul>
+ <li><a href="sketch-app-shell.demo.html">sketch-app-shell</a></li>
+ <li><a href="sketch-charts.demo.html">sketch-charts</a></li>
+ <li><a href="sketch-chat-input.demo.html">sketch-chat-input</a></li>
+ <li><a href="sketch-diff-view.demo.html">sketch-diff-view</a></li>
+ <li>
+ <a href="sketch-container-status.demo.html">sketch-container-status</a>
+ </li>
+ <li>
+ <a href="sketch-network-status.demo.html">sketch-network-status</a>
+ </li>
+ <li>
+ <a href="sketch-timeline-message.demo.html">sketch-timeline-message</a>
+ </li>
+ <li><a href="sketch-timeline.demo.html">sketch-timeline</a></li>
+ <li><a href="sketch-tool-calls.demo.html">sketch-tool-calls</a></li>
+ <li><a href="sketch-tool-card.demo.html">sketch-tool-card</a></li>
+ <li>
+ <a href="sketch-view-mode-select.demo.html">sketch-view-mode-select</a>
+ </li>
+ </ul>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/readme.md b/webui/src/web-components/demo/readme.md
new file mode 100644
index 0000000..324d077
--- /dev/null
+++ b/webui/src/web-components/demo/readme.md
@@ -0,0 +1,5 @@
+# Stand-alone demo pages for sketch web components
+
+These are handy for iterating on specific component UI issues in isolation from the rest of the sketch application, and without having to start a full backend to serve the full frontend app UI.
+
+See [README](../../../readme.md#development-mode) for more information on how to run the demo pages.
diff --git a/webui/src/web-components/demo/sketch-app-shell.demo.html b/webui/src/web-components/demo/sketch-app-shell.demo.html
new file mode 100644
index 0000000..48fc100
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-app-shell.demo.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>sketch coding assistant</title>
+ <link rel="stylesheet" href="sketch-app-shell.css" />
+ <script src="../sketch-app-shell.ts" type="module"></script>
+ </head>
+ <body>
+ <sketch-app-shell></sketch-app-shell>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-charts.demo.html b/webui/src/web-components/demo/sketch-charts.demo.html
new file mode 100644
index 0000000..64a9bd2
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-charts.demo.html
@@ -0,0 +1,134 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Sketch Charts Demo</title>
+ <script type="module" src="../sketch-charts.ts"></script>
+ <link rel="stylesheet" href="demo.css" />
+ <style>
+ sketch-charts {
+ margin: 20px;
+ max-width: 1000px;
+ }
+
+ body {
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ }
+ </style>
+ </head>
+ <body>
+ <h1>Sketch Charts Demo</h1>
+ <sketch-charts id="charts"></sketch-charts>
+
+ <script>
+ // Sample data for testing
+ const sampleMessages = [
+ {
+ idx: 1,
+ type: "human",
+ content: "Hello, can you help me with a coding task?",
+ timestamp: new Date(Date.now() - 3600000).toISOString(),
+ usage: { cost_usd: 0.0001 },
+ },
+ {
+ idx: 2,
+ type: "assistant",
+ content:
+ "I'd be happy to help! What kind of coding task are you working on?",
+ timestamp: new Date(Date.now() - 3500000).toISOString(),
+ usage: { cost_usd: 0.0005 },
+ },
+ {
+ idx: 3,
+ type: "human",
+ content: "I need to create a web component using lit-element",
+ timestamp: new Date(Date.now() - 3400000).toISOString(),
+ usage: { cost_usd: 0.0001 },
+ },
+ {
+ idx: 4,
+ type: "assistant",
+ content:
+ "I can definitely help with that. Lit Element is a great library for building web components.",
+ timestamp: new Date(Date.now() - 3300000).toISOString(),
+ usage: { cost_usd: 0.0008 },
+ },
+ {
+ idx: 5,
+ type: "assistant",
+ tool_name: "bash",
+ input: "ls -la",
+ tool_result:
+ "total 16\ndrwxr-xr-x 4 user staff 128 Jan 10 12:34 .\ndrwxr-xr-x 10 user staff 320 Jan 10 12:34 ..\n-rw-r--r-- 1 user staff 123 Jan 10 12:34 file1.txt\n-rw-r--r-- 1 user staff 456 Jan 10 12:34 file2.txt",
+ start_time: new Date(Date.now() - 3200000).toISOString(),
+ end_time: new Date(Date.now() - 3190000).toISOString(),
+ timestamp: new Date(Date.now() - 3190000).toISOString(),
+ usage: { cost_usd: 0.0002 },
+ },
+ {
+ idx: 6,
+ type: "assistant",
+ content: "Let me create a basic web component for you.",
+ timestamp: new Date(Date.now() - 3100000).toISOString(),
+ usage: { cost_usd: 0.0015 },
+ },
+ {
+ idx: 7,
+ type: "human",
+ content: "Can you show me how to handle events in the web component?",
+ timestamp: new Date(Date.now() - 3000000).toISOString(),
+ usage: { cost_usd: 0.0001 },
+ },
+ {
+ idx: 8,
+ type: "assistant",
+ tool_name: "bash",
+ input: "cat example.ts",
+ tool_result:
+ "import { LitElement, html } from 'lit';\nimport { customElement } from 'lit/decorators.js';\n\n@customElement('my-element')\nexport class MyElement extends LitElement {\n render() {\n return html`<div>Hello World</div>`;\n }\n}",
+ start_time: new Date(Date.now() - 2900000).toISOString(),
+ end_time: new Date(Date.now() - 2800000).toISOString(),
+ timestamp: new Date(Date.now() - 2800000).toISOString(),
+ usage: { cost_usd: 0.0003 },
+ },
+ {
+ idx: 9,
+ type: "assistant",
+ content:
+ "Here's how you can handle events in a web component using Lit.",
+ timestamp: new Date(Date.now() - 2700000).toISOString(),
+ usage: { cost_usd: 0.002 },
+ },
+ {
+ idx: 10,
+ type: "human",
+ content: "Thank you! How about adding properties and attributes?",
+ timestamp: new Date(Date.now() - 2600000).toISOString(),
+ usage: { cost_usd: 0.0001 },
+ },
+ {
+ idx: 11,
+ type: "assistant",
+ content:
+ "You can use the @property decorator to define properties in your Lit Element component.",
+ timestamp: new Date(Date.now() - 2500000).toISOString(),
+ usage: { cost_usd: 0.0025 },
+ },
+ ];
+
+ // Set sample data as soon as the component is defined
+ document.addEventListener("DOMContentLoaded", () => {
+ console.time("chart-demo-load");
+ const chartsComponent = document.getElementById("charts");
+ chartsComponent.messages = sampleMessages;
+ console.timeEnd("chart-demo-load");
+ });
+ </script>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-chat-input.demo.html b/webui/src/web-components/demo/sketch-chat-input.demo.html
new file mode 100644
index 0000000..afc79fb
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-chat-input.demo.html
@@ -0,0 +1,30 @@
+<html>
+ <head>
+ <title>sketch-chat-input demo</title>
+ <link rel="stylesheet" href="demo.css" />
+ <script type="module" src="../sketch-chat-input.ts"></script>
+
+ <script>
+ document.addEventListener("DOMContentLoaded", () => {
+ const chatInput = document.querySelector("sketch-chat-input");
+ console.log("chatInput: ", chatInput);
+ chatInput.content = "hi";
+ chatInput.addEventListener("send-chat", (evt) => {
+ console.log("send chat event: ", evt);
+ const msgDiv = document.querySelector("#chat-messages");
+ const newDiv = document.createElement("div");
+ newDiv.innerText = evt.detail.message;
+ msgDiv.append(newDiv);
+ chatInput.content = "";
+ });
+ });
+ </script>
+ </head>
+ <body>
+ <h1>sketch-chat-input demo</h1>
+
+ <div id="chat-messages"></div>
+
+ <sketch-chat-input></sketch-chat-input>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-container-status.demo.html b/webui/src/web-components/demo/sketch-container-status.demo.html
new file mode 100644
index 0000000..0945d70
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-container-status.demo.html
@@ -0,0 +1,38 @@
+<html>
+ <head>
+ <title>sketch-container-status demo</title>
+ <link rel="stylesheet" href="demo.css" />
+ <script type="module" src="../sketch-container-status.ts"></script>
+
+ <script>
+ document.addEventListener("DOMContentLoaded", () => {
+ const containerStatus = document.querySelector("#status-2");
+ containerStatus.state = {
+ hostname: "example.hostname",
+ initial_commit: "decafbad",
+ message_count: 27,
+ os: "linux",
+ total_usage: {
+ start_time: "around lunch",
+ messages: 1337,
+ input_tokens: 3,
+ output_tokens: 1000,
+ cache_read_input_tokens: 28,
+ cache_creation_input_tokens: 12354,
+ total_cost_usd: 2.03,
+ },
+ working_dir: "/app",
+ };
+ });
+ </script>
+ </head>
+ <body>
+ <h1>sketch-container-status demo</h1>
+
+ Empty:
+ <sketch-container-status id="status-1"></sketch-container-status>
+
+ With state fields set:
+ <sketch-container-status id="status-2"></sketch-container-status>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-diff-view.demo.html b/webui/src/web-components/demo/sketch-diff-view.demo.html
new file mode 100644
index 0000000..6ab6e62
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-diff-view.demo.html
@@ -0,0 +1,109 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Sketch Diff Viewer Demo</title>
+ <link
+ rel="stylesheet"
+ href="../../../node_modules/diff2html/bundles/css/diff2html.min.css"
+ />
+ <script type="module" src="../sketch-diff-view.ts"></script>
+ <style>
+ body {
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
+ Arial, sans-serif;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+ }
+
+ h1 {
+ color: #333;
+ margin-bottom: 2rem;
+ }
+
+ .control-panel {
+ margin-bottom: 2rem;
+ padding: 1rem;
+ background-color: #f0f0f0;
+ border-radius: 4px;
+ }
+
+ input {
+ padding: 0.5rem;
+ border-radius: 4px;
+ border: 1px solid #ccc;
+ width: 300px;
+ }
+
+ button {
+ padding: 0.5rem 1rem;
+ background-color: #2196f3;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ margin-left: 1rem;
+ }
+
+ button:hover {
+ background-color: #0d8bf2;
+ }
+ </style>
+
+ <script>
+ document.addEventListener("DOMContentLoaded", () => {
+ const diffViewer = document.getElementById("diffViewer");
+ const commitHashInput = document.getElementById("commitHash");
+ const viewDiffButton = document.getElementById("viewDiff");
+ let commit = false;
+ viewDiffButton.addEventListener("click", () => {
+ let diffContent = `diff --git a/sample.txt b/sample.txt
+index 1111111..2222222 100644
+--- a/sample.txt
++++ b/sample.txt
+@@ -1,5 +1,5 @@
+ This is a sample file
+-This line will be removed
++This line is added as a replacement
+ This line stays the same
+-Another line to remove
++A completely new line
+ The last line is unchanged`;
+ if (commit) {
+ // For demo purposes, generate fake diff based on commit hash
+ diffContent = `diff --git a/file-${commit.substring(0, 5)}.txt b/file-${commit.substring(0, 5)}.txt
+index 3333333..4444444 100644
+--- a/file-${commit.substring(0, 5)}.txt
++++ b/file-${commit.substring(0, 5)}.txt
+@@ -1,4 +1,6 @@
+ File with commit: ${commit}
++This line was added in commit ${commit}
+ This line exists in both versions
+-This line was removed in commit ${commit}
++This line replaced the removed line
++Another new line added in this commit
+ Last line of the file`;
+ }
+ diffViewer.diffText = diffContent;
+ diffViewer.commitHash = commitHashInput.value.trim();
+ });
+ });
+ </script>
+ </head>
+ <body>
+ <h1>Sketch Diff Viewer Demo</h1>
+
+ <div class="control-panel">
+ <label for="commitHash"
+ >Commit Hash (leave empty for unstaged changes):</label
+ >
+ <input type="text" id="commitHash" placeholder="Enter commit hash" />
+ <button id="viewDiff">View Diff</button>
+ </div>
+
+ <sketch-diff-view id="diffViewer"></sketch-diff-view>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-network-status.demo.html b/webui/src/web-components/demo/sketch-network-status.demo.html
new file mode 100644
index 0000000..f248a5d
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-network-status.demo.html
@@ -0,0 +1,22 @@
+<html>
+ <head>
+ <title>sketch-network-status demo</title>
+ <link rel="stylesheet" href="demo.css" />
+ <script type="module" src="../sketch-network-status.ts"></script>
+ </head>
+ <body>
+ <h1>sketch-network-status demo</h1>
+
+ Connected:
+ <sketch-network-status
+ connection="connected"
+ message="connected"
+ ></sketch-network-status>
+
+ Error:
+ <sketch-network-status
+ connection="error"
+ error="error"
+ ></sketch-network-status>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-timeline-message.demo.html b/webui/src/web-components/demo/sketch-timeline-message.demo.html
new file mode 100644
index 0000000..3c5d77e
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-timeline-message.demo.html
@@ -0,0 +1,62 @@
+<html>
+ <head>
+ <title>sketch-timeline-message demo</title>
+ <link rel="stylesheet" href="demo.css" />
+ <script type="module" src="../sketch-timeline-message.ts"></script>
+
+ <script>
+ const messages = [
+ {
+ type: "agent",
+ content: "an agent message",
+ },
+ {
+ type: "user",
+ content: "a user message",
+ },
+ {
+ type: "tool",
+ content: "a tool use message",
+ },
+ {
+ type: "commit",
+ end_of_turn: false,
+ content: "",
+ commits: [
+ {
+ hash: "ece101c103ec231da87f4df05c1b5e6a24e13add",
+ subject: "Add README.md for web components directory",
+ body: "This adds documentation for the web components used in the Loop UI,\nincluding a description of each component, usage examples, and\ndevelopment guidelines.\n\nCo-Authored-By: sketch\nadd README.md for webui/src/web-components",
+ pushed_branch:
+ "sketch/create-readmemd-for-web-components-directory",
+ },
+ ],
+ timestamp: "2025-04-14T16:39:33.639533919Z",
+ conversation_id: "",
+ idx: 17,
+ },
+ {
+ type: "agent",
+ content: "an end-of-turn agent message",
+ end_of_turn: true,
+ },
+ ];
+ document.addEventListener("DOMContentLoaded", () => {
+ messages.forEach((msg, idx) => {
+ const jsonEl = document.createElement("pre");
+ jsonEl.innerText = `.message property: ${JSON.stringify(msg)}`;
+ document.body.append(jsonEl);
+ const messageEl = document.createElement("sketch-timeline-message");
+ messageEl.message = msg;
+ document.body.appendChild(messageEl);
+ });
+ window.addEventListener("show-commit-diff", (evt) => {
+ console.log("show-commit-diff", evt);
+ });
+ });
+ </script>
+ </head>
+ <body>
+ <h1>sketch-timeline-message demo</h1>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-timeline.demo.html b/webui/src/web-components/demo/sketch-timeline.demo.html
new file mode 100644
index 0000000..58ff5d9
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-timeline.demo.html
@@ -0,0 +1,149 @@
+<html>
+ <head>
+ <title>sketch-timeline demo</title>
+ <link rel="stylesheet" href="demo.css" />
+ <script type="module" src="../sketch-timeline.ts"></script>
+ <script>
+ const messages = [
+ {
+ type: "user",
+ content: "a user message",
+ },
+ {
+ type: "agent",
+ content: "an agent message",
+ },
+ {
+ type: "agent",
+ content: "an agent message",
+ },
+ {
+ type: "agent",
+ content: "an agent message",
+ },
+ {
+ type: "user",
+ content: "a user message",
+ },
+ {
+ type: "user",
+ content: "a user message",
+ },
+ {
+ type: "agent",
+ content: "an agent message",
+ },
+ {
+ type: "user",
+ content: "a user message",
+ },
+ {
+ type: "tool",
+ content: "a tool use message",
+ },
+ {
+ type: "commit",
+ end_of_turn: false,
+ content: "",
+ commits: [
+ {
+ hash: "ece101c103ec231da87f4df05c1b5e6a24e13add",
+ subject: "Add README.md for web components directory",
+ body: "This adds documentation for the web components used in the Loop UI,\nincluding a description of each component, usage examples, and\ndevelopment guidelines.\n\nCo-Authored-By: sketch\nadd README.md for webui/src/web-components",
+ pushed_branch:
+ "sketch/create-readmemd-for-web-components-directory",
+ },
+ ],
+ timestamp: "2025-04-14T16:39:33.639533919Z",
+ conversation_id: "",
+ idx: 17,
+ },
+ {
+ type: "agent",
+ content: "an end-of-turn agent message",
+ end_of_turn: true,
+ },
+ ];
+
+ document.addEventListener("DOMContentLoaded", () => {
+ const appShell = document.querySelector(".app-shell");
+ const timelineEl = document.querySelector("sketch-timeline");
+ timelineEl.messages = messages;
+ timelineEl.scrollContainer = appShell;
+ const addMessagesCheckbox = document.querySelector("#addMessages");
+ addMessagesCheckbox.addEventListener("change", toggleAddMessages);
+
+ let addingMessages = false;
+ const addNewMessagesInterval = 1000;
+
+ function addNewMessages() {
+ if (!addingMessages) {
+ return;
+ }
+ const n = new Date().getMilliseconds() % messages.length;
+ const msgToDup = messages[n];
+ const dup = JSON.parse(JSON.stringify(msgToDup));
+ dup.idx = messages.length;
+ dup.timestamp = new Date().toISOString();
+ messages.push(dup);
+ timelineEl.messages = messages.concat();
+ timelineEl.prop;
+ timelineEl.requestUpdate();
+ }
+
+ let addMessagesHandler = setInterval(
+ addNewMessages,
+ addNewMessagesInterval,
+ );
+
+ function toggleAddMessages() {
+ addingMessages = !addingMessages;
+ if (addingMessages) {
+ } else {
+ }
+ }
+ });
+ </script>
+ <style>
+ .app-shell {
+ display: block;
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: rgb(51, 51, 51);
+ line-height: 1.4;
+ min-height: 100vh;
+ width: 100%;
+ position: relative;
+ overflow-x: hidden;
+ }
+ .app-header {
+ flex-grow: 0;
+ }
+ .view-container {
+ flex-grow: 2;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="app-shell">
+ <div class="app-header">
+ <h1>sketch-timeline demo</h1>
+ <input
+ type="checkbox"
+ id="addMessages"
+ title="Automatically add new messages"
+ /><label for="addMessages">Automatically add new messages</label>
+ </div>
+ <div class="view-container">
+ <div class="chat-view view-active">
+ <sketch-timeline></sketch-timeline>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-tool-calls.demo.html b/webui/src/web-components/demo/sketch-tool-calls.demo.html
new file mode 100644
index 0000000..9ad1677
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-tool-calls.demo.html
@@ -0,0 +1,184 @@
+<html>
+ <head>
+ <title>sketch-tool-calls demo</title>
+ <link rel="stylesheet" href="demo.css" />
+
+ <script type="module" src="../sketch-tool-calls.ts"></script>
+
+ <script>
+ const toolCalls = [
+ [
+ {
+ name: "bash",
+ input: JSON.stringify({
+ command:
+ "docker ps -a --format '{{.ID}} {{.Image }} {{.Names}}' | grep sketch | awk '{print $1 }' | xargs -I {} docker rm {} && docker image prune -af",
+ }),
+ },
+ ],
+ [
+ {
+ name: "bash",
+ input: JSON.stringify({
+ command: "ls -a",
+ }),
+ result_message: {
+ type: "tool",
+ tool_result: ".\n..",
+ },
+ },
+ ],
+ [
+ {
+ name: "bash",
+ input: JSON.stringify({
+ command: "sleep 200",
+ }),
+ result_message: {
+ type: "tool",
+ tool_error: "the user canceled this operation",
+ },
+ },
+ ],
+ [
+ {
+ name: "title",
+ input: JSON.stringify({
+ title: "a new title for this sketch",
+ }),
+ },
+ ],
+ [
+ {
+ name: "codereview",
+ input: "{}",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "codereview",
+ input: "{}",
+ tool_result: "OK",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ timestamp: "2025-04-14T16:33:17.575759565Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:33:07.11793816Z",
+ end_time: "2025-04-14T16:33:17.57575719Z",
+ elapsed: 10457819031,
+ idx: 45,
+ },
+ },
+ ],
+ [
+ {
+ name: "codereview",
+ input: "{}",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "codereview",
+ input: "{}",
+ tool_result: "Not OK",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ timestamp: "2025-04-14T16:33:17.575759565Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:33:07.11793816Z",
+ end_time: "2025-04-14T16:33:17.57575719Z",
+ elapsed: 10457819031,
+ idx: 45,
+ },
+ },
+ ],
+ [
+ {
+ name: "think",
+ input:
+ '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+ tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "think",
+ input:
+ '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+ tool_result: "recorded",
+ tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+ timestamp: "2025-04-14T16:32:14.12647133Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:32:14.126454329Z",
+ end_time: "2025-04-14T16:32:14.126468539Z",
+ elapsed: 14209,
+ idx: 18,
+ },
+ },
+ ],
+ [
+ {
+ name: "patch",
+ input:
+ '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│ └── sketch-timeline-message\\n│ └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n @property()\\n myProperty: string = \'default\';\\n\\n static styles = css`\\n :host {\\n display: block;\\n }\\n `;\\n\\n render() {\\n return html`\\n <div>\\n ${this.myProperty}\\n </div>\\n `;\\n }\\n}\\n\\ndeclare global {\\n interface HTMLElementTagNameMap {\\n \'sketch-new-component\': SketchNewComponent;\\n }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+ tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "patch",
+ input:
+ '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│ └── sketch-timeline-message\\n│ └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n @property()\\n myProperty: string = \'default\';\\n\\n static styles = css`\\n :host {\\n display: block;\\n }\\n `;\\n\\n render() {\\n return html`\\n <div>\\n ${this.myProperty}\\n </div>\\n `;\\n }\\n}\\n\\ndeclare global {\\n interface HTMLElementTagNameMap {\\n \'sketch-new-component\': SketchNewComponent;\\n }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+ tool_result: "- Applied all patches\n",
+ tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+ timestamp: "2025-04-14T16:32:47.10299854Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:32:47.101660683Z",
+ end_time: "2025-04-14T16:32:47.102994206Z",
+ elapsed: 1333524,
+ idx: 34,
+ },
+ },
+ ],
+ [
+ {
+ name: "done",
+ input:
+ '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+ tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "done",
+ input:
+ '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+ tool_result:
+ "codereview tool has not been run for commit 0b1f45dc17fbe7800f5164993ec99d6564256787",
+ tool_error: true,
+ tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+ timestamp: "2025-04-14T16:33:04.639179373Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:33:04.616273148Z",
+ end_time: "2025-04-14T16:33:04.639173456Z",
+ elapsed: 22900309,
+ idx: 43,
+ },
+ },
+ ],
+ ];
+ document.addEventListener("DOMContentLoaded", () => {
+ toolCalls.forEach((calls) => {
+ const toolCallsEl = document.createElement("sketch-tool-calls");
+ toolCallsEl.toolCalls = calls;
+ document.body.append(toolCallsEl);
+ });
+ });
+ </script>
+ </head>
+ <body>
+ <h1>sketch-tool-calls demo</h1>
+
+ <sketch-tool-calls></sketch-tool-calls>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-tool-card.demo.html b/webui/src/web-components/demo/sketch-tool-card.demo.html
new file mode 100644
index 0000000..f8ba308
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-tool-card.demo.html
@@ -0,0 +1,254 @@
+<html>
+ <head>
+ <title>sketch-tool-card demo</title>
+ <link rel="stylesheet" href="demo.css" />
+
+ <script type="module" src="../sketch-tool-card.ts"></script>
+
+ <script>
+ const toolCalls = [
+ {
+ name: "bash",
+ input: JSON.stringify({
+ command:
+ "docker ps -a --format '{{.ID}} {{.Image }} {{.Names}}' | grep sketch | awk '{print $1 }' | xargs -I {} docker rm {} && docker image prune -af",
+ }),
+ result_message: {
+ type: "tool",
+ tool_result: `Deleted Images:
+deleted: sha256:110d4aed8bcc76cb7327412504af8aef31670b816453a3088d834bbeefd11a2c
+deleted: sha256:042622460c913078901555a8a72de18e95228fca98b9ac388503b3baafafb683
+deleted: sha256:04ccf3d087e258ffd5f940f378c2aab3c0ed646fb2fb283f90e65397db304694
+deleted: sha256:877120aa3efd02b6afdad181c1cd75bbdc67e41a75dd770fbf781e4fe9c95fc7
+deleted: sha256:d96824c284e594acacc631458818d07842fd4cfa3a1037668a1b23abce077d7b
+deleted: sha256:d90eef6007f5782b59643eecb3edab38af6399d4142f0bb306742efa0e1cf6a4
+deleted: sha256:66b006b0d7570ccf7e2afa15e7b6e6385debba0e60e76eb314383215e480a664
+deleted: sha256:834ff90a57edf5c3987a3f21713310d189f209cec7b002a863c75a22e24cc114
+deleted: sha256:735be867a9939611842099b1131e23096fbde47bb326416382ff7a90a86ab687
+deleted: sha256:986792e96058cabe4452eab0fda2694fe2d5f0b951c446c9c1f94d86614f7bc6
+deleted: sha256:01539d19a06b87dd7a2268677c6beb06bc5aed3cde0c52691a684f4d085bc437
+deleted: sha256:d03b7602a43340d6d1e53ad1d7daa5b55740613ad969c360e1377b7af7597eba
+deleted: sha256:5a7310817c5fa3e29ebfe5b17031fdc5789543460c790ae2e1039226044a6109
+deleted: sha256:def65005e4b1e48e9531ce6ca6bea682bd8285e32b0748212fb8ace12976f920
+deleted: sha256:3b17b8e4e349ac09bac24da27ec4d65e3dec359645f73bd9a38bf015ca5f8a98
+deleted: sha256:1bef4e5c965c2fa2658954096dbe64dae8f3b1d7d595bdb370d54f4027a95603
+deleted: sha256:16e6b5b274b06916833d3f040ca045a12fe1a6a10bebf5f92338fe6b4c7dbbf7
+deleted: sha256:d90588879cc818bc3b3b575a291a3c4088d0ea1c61fad2c4a2f34160bdc86db6
+deleted: sha256:85903960027c7b9baf8bd0ee662571758ce8ffe83526839377284e2fccac558f
+untagged: sketch-94924d08c163:latest
+deleted: sha256:7c7c3957d3ba526a351d21e52a1aee0e72bb4a62d0422a0eb3a0e2b53391824f
+deleted: sha256:e4a1fe6a3369ca8f24baaba277bc9d97353992e9e051020c5a25e588a702e634
+deleted: sha256:28ccbe834ee66199498458f500b10cc9ea69460216982a537ea3294d6dfb0b63
+deleted: sha256:95c7d2956020039d92b546d6824c5d7fac163a6247be599160483d263094c047
+deleted: sha256:f87bc9eb655a06edd50d5a34e016175006c430ad129146b9b755169a3c318a57
+deleted: sha256:b455829fdcd5fe238567af2370f9fc021eb416ec2140f98b0ab59478febcfb2e
+deleted: sha256:ed64271d223807308a391a733fc556a6c16bfb87e6f9aed6d4ce394fcbb77ba6
+deleted: sha256:a5ce6521003bca24abcb4a0021837e789349fb3f44f7ceb00ef4af33ca01f84f
+deleted: sha256:57e05db1ff95deab5f5c3f38f9607a1c3bb21518133f4e0c137ffe6bb9cbfde9
+deleted: sha256:540194db01e12f59d19f7795ec9c8a1bb753df2de935469b21a10fc7ca1d25a5
+deleted: sha256:97519dae495c256597a9b7975a332e67edb21f93e306b72132ed2c30bb01b8aa
+deleted: sha256:162c7a942156fd5f16616c6fea4a26f2bfa01a53e499d59fdb8c68e815f5350e
+deleted: sha256:51b9d76df1fbcb277e4f22496ff661d4d748f499453a27a012629f78bb61107e
+deleted: sha256:7a1a595c3015a6b2f5e996988d094bcaca328ebeaafe37403e78322e10d6b859
+deleted: sha256:27631f63a84d9a524381a95168f24deb89612fb468e03bce724f352bb5ef7b3b
+deleted: sha256:58746669dff4a4051d05542e05109d57c94f867981b47bdb5800d62567a6280f
+untagged: golang:1.24.2-alpine3.21
+untagged: golang@sha256:7772cb5322baa875edd74705556d08f0eeca7b9c4b5367754ce3f2f00041ccee
+untagged: sketch-3c262c60c42c:latest
+deleted: sha256:fadf166900e61610d77d613ce52ca1c03711ce2a7bcd31f1f634529791c0c107
+deleted: sha256:8b719162dad84cddd630e1e943520041947ca91b3794417c0d2a03b3726ebaa4
+deleted: sha256:444f0e44dcaff517142f8aab35d35f08536d886a746f6858dac7052977ee2cff
+deleted: sha256:a95a3660958ed25a27ae7b0622b5426e046d4c5587693aa7c0098e050e057311
+deleted: sha256:edb781114acb505bbde5e4a3db68b7ab6f4a3c0da92ceed2d10f02c6278b93c8
+deleted: sha256:1429402020a73b7d5c1de32f9451c68e22508cc4238750f5a500e1d9737eedae
+deleted: sha256:3f749e03b0f5ef2dfc538581c92230f2cd6b844fe3c734c728fd3775865ed24c
+deleted: sha256:f62c6ba2d4f4b94796d4c4c111031fbbbaf22df24623a2d6729277dc1eaf8da8
+deleted: sha256:504579f990b8894755910252d3b401f86a589709efafb30b9ded67cb3edad80e
+deleted: sha256:2e22f953ef8cc5fac95fb0babc5042f5e2a7fefc9d5ec444429c490d54acb1ab
+deleted: sha256:afa0c23676c039532a39faa1f1506b19f34507b586796ea070dcaee30e6228ef
+deleted: sha256:5f176f397253734bdc726a505c84448f9b00e5652d9a28ef59de0581a2e8e923
+deleted: sha256:253afbfd579bc6daf71e42b0f1e369d2b6c9015028191af4478da0b77b8a85ed
+deleted: sha256:81f79e13183887f93db52268f00975f43613abc520c88e1090a1dbb3d09094e9
+deleted: sha256:3c0b6f56bdbec5bf995b818e8a67d2d6c3bd9aa3698c403b6dabc01a81a4cb52
+deleted: sha256:635f4ba57c6445e69cf8c6fba61c3690f76901e17334f6d2d165979b2d387dfa
+
+Total reclaimed space: 1.426GB`,
+ },
+ },
+ {
+ name: "bash",
+ input: JSON.stringify({
+ command: "ls -a",
+ }),
+ result_message: {
+ type: "tool",
+ tool_result: ".\n..",
+ },
+ },
+ {
+ name: "bash",
+ input: JSON.stringify({
+ command: "sleep 200",
+ }),
+ result_message: {
+ type: "tool",
+ tool_error: "the user canceled this operation",
+ },
+ },
+ {
+ name: "title",
+ input: JSON.stringify({
+ title: "a new title for this sketch",
+ }),
+ },
+ {
+ name: "codereview",
+ input: "{}",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "codereview",
+ input: "{}",
+ tool_result: "OK",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ timestamp: "2025-04-14T16:33:17.575759565Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:33:07.11793816Z",
+ end_time: "2025-04-14T16:33:17.57575719Z",
+ elapsed: 10457819031,
+ idx: 45,
+ },
+ },
+ {
+ name: "codereview",
+ input: "{}",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "codereview",
+ input: "{}",
+ tool_result: "Not OK",
+ tool_call_id: "toolu_01WT5qQwHZgdogfKhkD8R9PZ",
+ timestamp: "2025-04-14T16:33:17.575759565Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:33:07.11793816Z",
+ end_time: "2025-04-14T16:33:17.57575719Z",
+ elapsed: 10457819031,
+ idx: 45,
+ },
+ },
+ {
+ name: "think",
+ input:
+ '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+ tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "think",
+ input:
+ '{"thoughts":"I\'m going to inspect a few key components to understand their purpose and relationships:\\n1. sketch-app-shell.ts - Appears to be the main container component\\n2. sketch-timeline.ts - Likely manages the chat timeline\\n3. sketch-view-mode-select.ts - Handles switching between different views\\n\\nThis will help me create a comprehensive README that explains the components and their relationships."}',
+ tool_result: "recorded",
+ tool_call_id: "toolu_01R1g5mQVgKxEJZFNp9QGvUr",
+ timestamp: "2025-04-14T16:32:14.12647133Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:32:14.126454329Z",
+ end_time: "2025-04-14T16:32:14.126468539Z",
+ elapsed: 14209,
+ idx: 18,
+ },
+ },
+ {
+ name: "patch",
+ input:
+ '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│ └── sketch-timeline-message\\n│ └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n @property()\\n myProperty: string = \'default\';\\n\\n static styles = css`\\n :host {\\n display: block;\\n }\\n `;\\n\\n render() {\\n return html`\\n <div>\\n ${this.myProperty}\\n </div>\\n `;\\n }\\n}\\n\\ndeclare global {\\n interface HTMLElementTagNameMap {\\n \'sketch-new-component\': SketchNewComponent;\\n }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+ tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "patch",
+ input:
+ '{"path":"/app/webui/src/web-components/README.md","patches":[{"operation":"overwrite","newText":"# Web Components\\n\\nThis directory contains the custom web components used in the Loop WebUI. These components are built using the [Lit](https://lit.dev/) library for creating fast, lightweight web components with a declarative template system.\\n\\n## Component Architecture\\n\\nThe components follow a hierarchical structure that creates a complete UI for interacting with the CodingAgent:\\n\\n```\\nsketch-app-shell (main container)\\n├── sketch-container-status\\n├── sketch-network-status\\n├── sketch-view-mode-select\\n├── sketch-timeline (chat view)\\n│ └── sketch-timeline-message\\n│ └── sketch-tool-calls\\n├── sketch-diff-view (code diff view)\\n└── sketch-chat-input\\n```\\n\\n## Component Overview\\n\\n### sketch-app-shell.ts\\nThe main container component that orchestrates the entire UI. It manages:\\n- View modes (chat, diff, charts, terminal)\\n- Network status and connection management\\n- Timeline data fetching and rendering\\n- Auto-scrolling behavior for chat messages\\n\\n### sketch-chat-input.ts\\nHandles user input for sending messages to the CodingAgent:\\n- Text input area with markdown support\\n- Send button and keyboard shortcuts (Enter to send, Shift+Enter for newline)\\n- Auto-focusing behavior\\n\\n### sketch-container-status.ts\\nDisplays information about the container environment:\\n- OS information\\n- Resource usage (CPU, memory)\\n- Container status indicators\\n\\n### sketch-diff-view.ts\\nProvides a visual diff viewer for code changes:\\n- Git commit display\\n- Side-by-side or unified diff viewing\\n- Syntax highlighting for code\\n- Comment creation for code review\\n\\n### sketch-network-status.ts\\nShows the current connection status to the server:\\n- Connected/disconnected indicators\\n- Error messages when connection issues occur\\n- Visual feedback on connection state\\n\\n### sketch-timeline.ts\\nDisplays the conversation history between user and CodingAgent:\\n- Message rendering\\n- Manages the sequence of messages\\n- Handles scrolling behavior\\n\\n### sketch-timeline-message.ts\\nRenders individual messages in the timeline:\\n- Different styling for user vs. agent messages\\n- Markdown rendering with syntax highlighting\\n- Handles special message types\\n\\n### sketch-tool-calls.ts\\nDisplays tool call information within messages:\\n- Tool call parameters and outputs\\n- Expandable/collapsible sections for tool details\\n- Syntax highlighting for code in tool outputs\\n\\n### sketch-view-mode-select.ts\\nProvides UI for switching between different views:\\n- Chat view for conversation\\n- Diff view for code changes\\n- Charts view for data visualization\\n- Terminal view for command execution\\n\\n## Development\\n\\n### Creating New Components\\n\\nWhen creating new components, follow these patterns:\\n\\n1. Use the `@customElement` decorator to define the component\\n2. Encapsulate styles using the static `styles` property\\n3. Use Lit\'s reactive properties (`@property`, `@state`) to manage component state\\n4. Implement lifecycle methods as needed (`connectedCallback`, `disconnectedCallback`, etc.)\\n\\n### Example\\n\\n```typescript\\nimport { css, html, LitElement } from \'lit\';\\nimport { customElement, property } from \'lit/decorators.js\';\\n\\n@customElement(\'sketch-new-component\')\\nexport class SketchNewComponent extends LitElement {\\n @property()\\n myProperty: string = \'default\';\\n\\n static styles = css`\\n :host {\\n display: block;\\n }\\n `;\\n\\n render() {\\n return html`\\n <div>\\n ${this.myProperty}\\n </div>\\n `;\\n }\\n}\\n\\ndeclare global {\\n interface HTMLElementTagNameMap {\\n \'sketch-new-component\': SketchNewComponent;\\n }\\n}\\n```\\n\\n### Best Practices\\n\\n1. Maintain a single responsibility for each component\\n2. Use properties and events for parent-child communication\\n3. Document the purpose and API of each component\\n4. Write tests for components where possible\\n5. Keep styles encapsulated within components\\n6. Use type definitions for strong typing\\n\\n## Related Resources\\n\\n- [Lit Documentation](https://lit.dev/docs/)\\n- [Web Components Introduction](https://developer.mozilla.org/en-US/docs/Web/Web_Components)\\n- See `/app/webui/readme.md` for more information on the Loop WebUI project\\n"}]}',
+ tool_result: "- Applied all patches\n",
+ tool_call_id: "toolu_01TNhLX2AWkZwsu2KCLKrpju",
+ timestamp: "2025-04-14T16:32:47.10299854Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:32:47.101660683Z",
+ end_time: "2025-04-14T16:32:47.102994206Z",
+ elapsed: 1333524,
+ idx: 34,
+ },
+ },
+ {
+ name: "done",
+ input:
+ '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+ tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+ result_message: {
+ type: "tool",
+ end_of_turn: false,
+ content: "",
+ tool_name: "done",
+ input:
+ '{"checklist_items":{"code_reviewed":{"status":"yes","description":"If any commits were made, the codereview tool was run and its output was addressed.","comments":"Since this commit only adds a documentation file (README.md) with no functional code changes, a formal code review is not essential, but I\'ve verified the content for accuracy and completeness."},"wrote_tests":{"status":"not applicable","description":"If code was changed, tests were written or updated.","comments":"No code was changed; only documentation was added. The README file is purely informational and doesn\'t require tests."},"passes_tests":{"status":"not applicable","description":"If any commits were made, tests pass.","comments":"No functional code changes were made that would impact existing tests. The addition of a README file is a documentation-only change."},"git_commit":{"status":"yes","description":"Create git commits for any code changes you made. Match the style of recent commit messages. Include \'Co-Authored-By: sketch\' and the original user prompt. Use GIT_AUTHOR_NAME=\\"Sean McCullough\\" GIT_AUTHOR_EMAIL=\\"banksean@gmail.com\\" (not git config).","comments":"Created a commit with a descriptive message that follows the requested format, including \'Co-Authored-By: sketch\' attribution."}}}',
+ tool_result:
+ "codereview tool has not been run for commit 0b1f45dc17fbe7800f5164993ec99d6564256787",
+ tool_error: true,
+ tool_call_id: "toolu_01HPgWQJF1aF9LUqkdDKWeES",
+ timestamp: "2025-04-14T16:33:04.639179373Z",
+ conversation_id: "xsa-8hw0",
+ start_time: "2025-04-14T16:33:04.616273148Z",
+ end_time: "2025-04-14T16:33:04.639173456Z",
+ elapsed: 22900309,
+ idx: 43,
+ },
+ },
+ ];
+ document.addEventListener("DOMContentLoaded", () => {
+ toolCalls.forEach((toolCall) => {
+ const h2El = document.createElement("h2");
+ h2El.innerText = toolCall.name;
+ document.body.append(h2El);
+
+ let toolCardEl = document.createElement("sketch-tool-card-generic");
+ switch (toolCall.name) {
+ case "bash":
+ toolCardEl = document.createElement("sketch-tool-card-bash");
+ break;
+ case "codereview":
+ toolCardEl = document.createElement(
+ "sketch-tool-card-codereview",
+ );
+ break;
+ case "done":
+ toolCardEl = document.createElement("sketch-tool-card-done");
+ break;
+ case "patch":
+ toolCardEl = document.createElement("sketch-tool-card-patch");
+ break;
+ case "think":
+ toolCardEl = document.createElement("sketch-tool-card-think");
+ break;
+ case "title":
+ toolCardEl = document.createElement("sketch-tool-card-title");
+ break;
+ }
+ toolCardEl.toolCall = toolCall;
+ toolCardEl.open = true;
+ document.body.append(toolCardEl);
+ });
+ });
+ </script>
+ </head>
+ <body>
+ <h1>sketch-tool-calls demo</h1>
+
+ <sketch-tool-calls></sketch-tool-calls>
+ </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-view-mode-select.demo.html b/webui/src/web-components/demo/sketch-view-mode-select.demo.html
new file mode 100644
index 0000000..0068616
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-view-mode-select.demo.html
@@ -0,0 +1,32 @@
+<html>
+ <head>
+ <title>sketch-view-mode-select demo</title>
+ <link rel="stylesheet" href="demo.css" />
+
+ <script type="module" src="../sketch-view-mode-select.ts"></script>
+
+ <script>
+ document.addEventListener("DOMContentLoaded", () => {
+ const viewModeSelect = document.querySelector(
+ "sketch-view-mode-select",
+ );
+ const msgDiv = document.querySelector("#selected-mode");
+ msgDiv.innerText = `selected mode: ${viewModeSelect.activeMode}`;
+
+ console.log("viewModeSelect: ", viewModeSelect);
+ viewModeSelect.addEventListener("view-mode-select", (evt) => {
+ console.log("view mode change event: ", evt);
+ const msgDiv = document.querySelector("#selected-mode");
+ msgDiv.innerText = `selected mode: ${evt.detail.mode}`;
+ viewModeSelect.activeMode = evt.detail.mode;
+ });
+ });
+ </script>
+ </head>
+ <body>
+ <h1>sketch-view-mode-select demo</h1>
+
+ <sketch-view-mode-select></sketch-view-mode-select>
+ <div id="selected-mode"></div>
+ </body>
+</html>
diff --git a/webui/src/web-components/sketch-app-shell.ts b/webui/src/web-components/sketch-app-shell.ts
new file mode 100644
index 0000000..1dd3b6f
--- /dev/null
+++ b/webui/src/web-components/sketch-app-shell.ts
@@ -0,0 +1,603 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { DataManager, ConnectionStatus } from "../data";
+import { State, AgentMessage } from "../types";
+import "./sketch-container-status";
+import "./sketch-view-mode-select";
+import "./sketch-network-status";
+import "./sketch-timeline";
+import "./sketch-chat-input";
+import "./sketch-diff-view";
+import "./sketch-charts";
+import "./sketch-terminal";
+import { SketchDiffView } from "./sketch-diff-view";
+import { aggregateAgentMessages } from "./aggregateAgentMessages";
+
+type ViewMode = "chat" | "diff" | "charts" | "terminal";
+
+@customElement("sketch-app-shell")
+export class SketchAppShell extends LitElement {
+ // Current view mode (chat, diff, charts, terminal)
+ @state()
+ viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
+
+ // Current commit hash for diff view
+ @state()
+ currentCommitHash: string = "";
+
+ // 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`
+ :host {
+ display: block;
+ font-family:
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: #333;
+ line-height: 1.4;
+ min-height: 100vh;
+ width: 100%;
+ position: relative;
+ overflow-x: hidden;
+ }
+
+ /* Top banner with combined elements */
+ .top-banner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 5px 20px;
+ margin-bottom: 0;
+ border-bottom: 1px solid #eee;
+ gap: 10px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ background: white;
+ z-index: 100;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ max-width: 100%;
+ }
+
+ .banner-title {
+ font-size: 18px;
+ font-weight: 600;
+ margin: 0;
+ min-width: 6em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .chat-title {
+ margin: 0;
+ padding: 0;
+ color: rgba(82, 82, 82, 0.85);
+ font-size: 16px;
+ font-weight: normal;
+ font-style: italic;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ /* View mode container styles - mirroring timeline.css structure */
+ .view-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ margin-top: 65px; /* Space for the top banner */
+ margin-bottom: 90px; /* Increased space for the chat input */
+ position: relative;
+ padding-bottom: 15px; /* Additional padding to prevent clipping */
+ padding-top: 15px; /* Add padding at top to prevent content touching the header */
+ }
+
+ /* Allow the container to expand to full width in diff mode */
+ .view-container.diff-active {
+ max-width: 100%;
+ }
+
+ /* Individual view styles */
+ .chat-view,
+ .diff-view,
+ .chart-view,
+ .terminal-view {
+ display: none; /* Hidden by default */
+ width: 100%;
+ }
+
+ /* Active view styles - these will be applied via JavaScript */
+ .view-active {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .title-container {
+ display: flex;
+ flex-direction: column;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 33%;
+ }
+
+ .refresh-control {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0;
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ flex-shrink: 0;
+ }
+
+ .refresh-button {
+ background: #4caf50;
+ color: white;
+ border: none;
+ padding: 4px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ margin-right: 5px;
+ }
+
+ .stop-button:hover {
+ background-color: #c82333 !important;
+ }
+
+ .poll-updates {
+ display: flex;
+ align-items: center;
+ margin: 0 5px;
+ font-size: 12px;
+ }
+ `;
+
+ // Header bar: Network connection status details
+ @property()
+ connectionStatus: ConnectionStatus = "disconnected";
+
+ @property()
+ connectionErrorMessage: string = "";
+
+ @property()
+ messageStatus: string = "";
+
+ // Chat messages
+ @property({ attribute: false })
+ messages: AgentMessage[] = [];
+
+ @property()
+ title: string = "";
+
+ private dataManager = new DataManager();
+
+ @property({ attribute: false })
+ containerState: State = {
+ title: "",
+ os: "",
+ message_count: 0,
+ hostname: "",
+ working_dir: "",
+ initial_commit: "",
+ };
+
+ // Mutation observer to detect when new messages are added
+ private mutationObserver: MutationObserver | null = null;
+
+ constructor() {
+ super();
+ console.log("Hello!");
+
+ // Binding methods to this
+ this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
+ this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+ this._handlePopState = this._handlePopState.bind(this);
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ connectedCallback() {
+ super.connectedCallback();
+
+ // Initialize client-side nav history.
+ const url = new URL(window.location.href);
+ const mode = url.searchParams.get("view") || "chat";
+ window.history.replaceState({ mode }, "", url.toString());
+
+ this.toggleViewMode(mode as ViewMode, false);
+ // Add popstate event listener to handle browser back/forward navigation
+ window.addEventListener("popstate", this._handlePopState);
+
+ // Add event listeners
+ window.addEventListener("view-mode-select", this._handleViewModeSelect);
+ window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
+
+ // register event listeners
+ this.dataManager.addEventListener(
+ "dataChanged",
+ this.handleDataChanged.bind(this),
+ );
+ this.dataManager.addEventListener(
+ "connectionStatusChanged",
+ this.handleConnectionStatusChanged.bind(this),
+ );
+
+ // Initialize the data manager
+ this.dataManager.initialize();
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener("popstate", this._handlePopState);
+
+ // Remove event listeners
+ window.removeEventListener("view-mode-select", this._handleViewModeSelect);
+ window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
+
+ // unregister data manager event listeners
+ this.dataManager.removeEventListener(
+ "dataChanged",
+ this.handleDataChanged.bind(this),
+ );
+ this.dataManager.removeEventListener(
+ "connectionStatusChanged",
+ this.handleConnectionStatusChanged.bind(this),
+ );
+
+ // Disconnect mutation observer if it exists
+ if (this.mutationObserver) {
+ console.log("Auto-scroll: Disconnecting mutation observer");
+ this.mutationObserver.disconnect();
+ this.mutationObserver = null;
+ }
+ }
+
+ updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
+ // Get the current URL without search parameters
+ const url = new URL(window.location.href);
+
+ // Clear existing parameters
+ url.search = "";
+
+ // Only add view parameter if not in default chat view
+ if (mode !== "chat") {
+ url.searchParams.set("view", mode);
+ const diffView = this.shadowRoot?.querySelector(
+ ".diff-view",
+ ) as SketchDiffView;
+
+ // If in diff view and there's a commit hash, include that too
+ if (mode === "diff" && diffView.commitHash) {
+ url.searchParams.set("commit", diffView.commitHash);
+ }
+ }
+
+ // Update the browser history without reloading the page
+ window.history.pushState({ mode }, "", url.toString());
+ }
+
+ private _handlePopState(event: PopStateEvent) {
+ if (event.state && event.state.mode) {
+ this.toggleViewMode(event.state.mode, false);
+ } else {
+ this.toggleViewMode("chat", false);
+ }
+ }
+
+ /**
+ * Handle view mode selection event
+ */
+ private _handleViewModeSelect(event: CustomEvent) {
+ const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
+ this.toggleViewMode(mode, true);
+ }
+
+ /**
+ * Handle show commit diff event
+ */
+ private _handleShowCommitDiff(event: CustomEvent) {
+ const { commitHash } = event.detail;
+ if (commitHash) {
+ this.showCommitDiff(commitHash);
+ }
+ }
+
+ /**
+ * Listen for commit diff event
+ * @param commitHash The commit hash to show diff for
+ */
+ private showCommitDiff(commitHash: string): void {
+ // Store the commit hash
+ this.currentCommitHash = commitHash;
+
+ // Switch to diff view
+ this.toggleViewMode("diff", true);
+
+ // Wait for DOM update to complete
+ this.updateComplete.then(() => {
+ // Get the diff view component
+ const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
+ if (diffView) {
+ // Call the showCommitDiff method
+ (diffView as any).showCommitDiff(commitHash);
+ }
+ });
+ }
+
+ /**
+ * Toggle between different view modes: chat, diff, charts, terminal
+ */
+ private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
+ // Don't do anything if the mode is already active
+ if (this.viewMode === mode) return;
+
+ // Update the view mode
+ this.viewMode = mode;
+
+ if (updateHistory) {
+ // Update URL with the current view mode
+ this.updateUrlForViewMode(mode);
+ }
+
+ // Wait for DOM update to complete
+ this.updateComplete.then(() => {
+ // Update active view
+ const viewContainer = this.shadowRoot?.querySelector(".view-container");
+ const chatView = this.shadowRoot?.querySelector(".chat-view");
+ const diffView = this.shadowRoot?.querySelector(".diff-view");
+ const chartView = this.shadowRoot?.querySelector(".chart-view");
+ const terminalView = this.shadowRoot?.querySelector(".terminal-view");
+
+ // Remove active class from all views
+ chatView?.classList.remove("view-active");
+ diffView?.classList.remove("view-active");
+ chartView?.classList.remove("view-active");
+ terminalView?.classList.remove("view-active");
+
+ // Add/remove diff-active class on view container
+ if (mode === "diff") {
+ viewContainer?.classList.add("diff-active");
+ } else {
+ viewContainer?.classList.remove("diff-active");
+ }
+
+ // Add active class to the selected view
+ switch (mode) {
+ case "chat":
+ chatView?.classList.add("view-active");
+ break;
+ case "diff":
+ diffView?.classList.add("view-active");
+ // Load diff content if we have a diff view
+ const diffViewComp =
+ this.shadowRoot?.querySelector("sketch-diff-view");
+ if (diffViewComp && this.currentCommitHash) {
+ (diffViewComp as any).showCommitDiff(this.currentCommitHash);
+ } else if (diffViewComp) {
+ (diffViewComp as any).loadDiffContent();
+ }
+ break;
+ case "charts":
+ chartView?.classList.add("view-active");
+ break;
+ case "terminal":
+ terminalView?.classList.add("view-active");
+ break;
+ }
+
+ // Update view mode buttons
+ const viewModeSelect = this.shadowRoot?.querySelector(
+ "sketch-view-mode-select",
+ );
+ if (viewModeSelect) {
+ const event = new CustomEvent("update-active-mode", {
+ detail: { mode },
+ bubbles: true,
+ composed: true,
+ });
+ viewModeSelect.dispatchEvent(event);
+ }
+
+ // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
+ // When the chart is in the background, its container has a width of 0, so vega
+ // renders width 0 and only changes that width on a resize event.
+ // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
+ window.dispatchEvent(new Event("resize"));
+ });
+ }
+
+ private handleDataChanged(eventData: {
+ state: State;
+ newMessages: AgentMessage[];
+ isFirstFetch?: boolean;
+ }): void {
+ const { state, newMessages, isFirstFetch } = eventData;
+
+ // Check if this is the first data fetch or if there are new messages
+ if (isFirstFetch) {
+ this.messageStatus = "Initial messages loaded";
+ } else if (newMessages && newMessages.length > 0) {
+ this.messageStatus = "Updated just now";
+ } else {
+ this.messageStatus = "No new messages";
+ }
+
+ // Update state if we received it
+ if (state) {
+ this.containerState = state;
+ this.title = state.title;
+ }
+
+ // Create a copy of the current messages before updating
+ const oldMessageCount = this.messages.length;
+
+ // Update messages
+ this.messages = aggregateAgentMessages(this.messages, newMessages);
+
+ // Log information about the message update
+ if (this.messages.length > oldMessageCount) {
+ console.log(
+ `Auto-scroll: Messages updated from ${oldMessageCount} to ${this.messages.length}`,
+ );
+ }
+ }
+
+ private handleConnectionStatusChanged(
+ status: ConnectionStatus,
+ errorMessage?: string,
+ ): void {
+ this.connectionStatus = status;
+ this.connectionErrorMessage = errorMessage || "";
+ }
+
+ async _sendChat(e: CustomEvent) {
+ console.log("app shell: _sendChat", e);
+ const message = e.detail.message?.trim();
+ if (message == "") {
+ return;
+ }
+ try {
+ // Send the message to the server
+ const response = await fetch("chat", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ message }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.text();
+ throw new Error(`Server error: ${response.status} - ${errorData}`);
+ }
+
+ // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
+ // Reset data manager state to force a full refresh after sending a message
+ // This ensures we get all messages in the correct order
+ // Use private API for now - TODO: add a resetState() method to DataManager
+ (this.dataManager as any).nextFetchIndex = 0;
+ (this.dataManager as any).currentFetchStartIndex = 0;
+
+ // // If in diff view, switch to conversation view
+ // if (this.viewMode === "diff") {
+ // await this.toggleViewMode("chat");
+ // }
+
+ // Refresh the timeline data to show the new message
+ await this.dataManager.fetchData();
+ } catch (error) {
+ console.error("Error sending chat message:", error);
+ const statusText = document.getElementById("statusText");
+ if (statusText) {
+ statusText.textContent = "Error sending message";
+ }
+ }
+ }
+
+ render() {
+ return html`
+ <div class="top-banner">
+ <div class="title-container">
+ <h1 class="banner-title">sketch</h1>
+ <h2 id="chatTitle" class="chat-title">${this.title}</h2>
+ </div>
+
+ <sketch-container-status
+ .state=${this.containerState}
+ ></sketch-container-status>
+
+ <div class="refresh-control">
+ <sketch-view-mode-select></sketch-view-mode-select>
+
+ <button id="stopButton" class="refresh-button stop-button">
+ Stop
+ </button>
+
+ <div class="poll-updates">
+ <input type="checkbox" id="pollToggle" checked />
+ <label for="pollToggle">Poll</label>
+ </div>
+
+ <sketch-network-status
+ message=${this.messageStatus}
+ connection=${this.connectionStatus}
+ error=${this.connectionErrorMessage}
+ ></sketch-network-status>
+ </div>
+ </div>
+
+ <div class="view-container">
+ <div class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}">
+ <sketch-timeline
+ .messages=${this.messages}
+ .scrollContainer=${this}
+ ></sketch-timeline>
+ </div>
+
+ <div class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}">
+ <sketch-diff-view
+ .commitHash=${this.currentCommitHash}
+ ></sketch-diff-view>
+ </div>
+
+ <div
+ class="chart-view ${this.viewMode === "charts" ? "view-active" : ""}"
+ >
+ <sketch-charts .messages=${this.messages}></sketch-charts>
+ </div>
+
+ <div
+ class="terminal-view ${this.viewMode === "terminal"
+ ? "view-active"
+ : ""}"
+ >
+ <sketch-terminal></sketch-terminal>
+ </div>
+ </div>
+
+ <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
+ `;
+ }
+
+ /**
+ * Lifecycle callback when component is first connected to DOM
+ */
+ firstUpdated(): void {
+ if (this.viewMode !== "chat") {
+ return;
+ }
+
+ // Initial scroll to bottom when component is first rendered
+ setTimeout(
+ () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
+ 50,
+ );
+
+ const pollToggleCheckbox = this.renderRoot?.querySelector(
+ "#pollToggle",
+ ) as HTMLInputElement;
+ pollToggleCheckbox?.addEventListener("change", () => {
+ this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
+ if (!pollToggleCheckbox.checked) {
+ this.connectionStatus = "disabled";
+ this.messageStatus = "Polling stopped";
+ } else {
+ this.messageStatus = "Polling for updates...";
+ }
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-app-shell": SketchAppShell;
+ }
+}
diff --git a/webui/src/web-components/sketch-charts.ts b/webui/src/web-components/sketch-charts.ts
new file mode 100644
index 0000000..8cf2606
--- /dev/null
+++ b/webui/src/web-components/sketch-charts.ts
@@ -0,0 +1,498 @@
+import "./vega-embed";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { TopLevelSpec } from "vega-lite";
+import type { AgentMessage } from "../types";
+import "vega-embed";
+import { VisualizationSpec } from "vega-embed";
+
+/**
+ * Web component for rendering charts related to the timeline data
+ * Displays cumulative cost over time and message timing visualization
+ */
+@customElement("sketch-charts")
+export class SketchCharts extends LitElement {
+ @property({ type: Array })
+ messages: AgentMessage[] = [];
+
+ @state()
+ private chartData: { timestamp: Date; cost: number }[] = [];
+
+ // We need to make the styles available to Vega-Embed when it's rendered
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ .chart-container {
+ padding: 20px;
+ background-color: #fff;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ margin-bottom: 20px;
+ }
+
+ .chart-section {
+ margin-bottom: 30px;
+ }
+
+ .chart-section h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+ font-size: 18px;
+ color: #333;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 8px;
+ }
+
+ .chart-content {
+ width: 100%;
+ min-height: 300px;
+ }
+
+ .loader {
+ border: 4px solid #f3f3f3;
+ border-radius: 50%;
+ border-top: 4px solid #3498db;
+ width: 40px;
+ height: 40px;
+ margin: 20px auto;
+ animation: spin 2s linear infinite;
+ }
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+ `;
+
+ constructor() {
+ super();
+ this.chartData = [];
+ }
+
+ private calculateCumulativeCostData(
+ messages: AgentMessage[],
+ ): { timestamp: Date; cost: number }[] {
+ if (!messages || messages.length === 0) {
+ return [];
+ }
+
+ let cumulativeCost = 0;
+ const data: { timestamp: Date; cost: number }[] = [];
+
+ for (const message of messages) {
+ if (message.timestamp && message.usage && message.usage.cost_usd) {
+ const timestamp = new Date(message.timestamp);
+ cumulativeCost += message.usage.cost_usd;
+
+ data.push({
+ timestamp,
+ cost: cumulativeCost,
+ });
+ }
+ }
+
+ return data;
+ }
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ if (changedProperties.has("messages")) {
+ this.chartData = this.calculateCumulativeCostData(this.messages);
+ }
+ }
+
+ private getMessagesChartSpec(): VisualizationSpec {
+ try {
+ const allMessages = this.messages;
+ if (!Array.isArray(allMessages) || allMessages.length === 0) {
+ return null;
+ }
+
+ // Sort messages chronologically
+ allMessages.sort((a, b) => {
+ const dateA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+ const dateB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
+ return dateA - dateB;
+ });
+
+ // Create unique indexes for all messages
+ const messageIndexMap = new Map<string, number>();
+ let messageIdx = 0;
+
+ // First pass: Process parent messages
+ allMessages.forEach((msg, index) => {
+ // Create a unique ID for each message to track its position
+ const msgId = msg.timestamp ? msg.timestamp.toString() : `msg-${index}`;
+ messageIndexMap.set(msgId, messageIdx++);
+ });
+
+ // Process tool calls from messages to account for filtered out tool messages
+ const toolCallData: any[] = [];
+ allMessages.forEach((msg) => {
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
+ msg.tool_calls.forEach((toolCall) => {
+ if (toolCall.result_message) {
+ // Add this tool result message to our data
+ const resultMsg = toolCall.result_message;
+
+ // Important: use the original message's idx to maintain the correct order
+ // The original message idx value is what we want to show in the chart
+ if (resultMsg.idx !== undefined) {
+ // If the tool call has start/end times, add it to bar data, otherwise to point data
+ if (resultMsg.start_time && resultMsg.end_time) {
+ toolCallData.push({
+ type: "bar",
+ index: resultMsg.idx, // Use actual idx from message
+ message_type: "tool",
+ content: resultMsg.content || "",
+ tool_name: resultMsg.tool_name || toolCall.name || "",
+ tool_input: toolCall.input || "",
+ tool_result: resultMsg.tool_result || "",
+ start_time: new Date(resultMsg.start_time).toISOString(),
+ end_time: new Date(resultMsg.end_time).toISOString(),
+ message: JSON.stringify(resultMsg, null, 2),
+ });
+ } else if (resultMsg.timestamp) {
+ toolCallData.push({
+ type: "point",
+ index: resultMsg.idx, // Use actual idx from message
+ message_type: "tool",
+ content: resultMsg.content || "",
+ tool_name: resultMsg.tool_name || toolCall.name || "",
+ tool_input: toolCall.input || "",
+ tool_result: resultMsg.tool_result || "",
+ time: new Date(resultMsg.timestamp).toISOString(),
+ message: JSON.stringify(resultMsg, null, 2),
+ });
+ }
+ }
+ }
+ });
+ }
+ });
+
+ // Prepare data for messages with start_time and end_time (bar marks)
+ const barData = allMessages
+ .filter((msg) => msg.start_time && msg.end_time) // Only include messages with explicit start and end times
+ .map((msg) => {
+ // Parse start and end times
+ const startTime = new Date(msg.start_time!);
+ const endTime = new Date(msg.end_time!);
+
+ // Use the message idx directly for consistent ordering
+ const index = msg.idx;
+
+ // Truncate content for tooltip readability
+ const displayContent = msg.content
+ ? msg.content.length > 100
+ ? msg.content.substring(0, 100) + "..."
+ : msg.content
+ : "No content";
+
+ // Prepare tool input and output for tooltip if applicable
+ const toolInput = msg.input
+ ? msg.input.length > 100
+ ? msg.input.substring(0, 100) + "..."
+ : msg.input
+ : "";
+
+ const toolResult = msg.tool_result
+ ? msg.tool_result.length > 100
+ ? msg.tool_result.substring(0, 100) + "..."
+ : msg.tool_result
+ : "";
+
+ return {
+ index: index,
+ message_type: msg.type,
+ content: displayContent,
+ tool_name: msg.tool_name || "",
+ tool_input: toolInput,
+ tool_result: toolResult,
+ start_time: startTime.toISOString(),
+ end_time: endTime.toISOString(),
+ message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
+ };
+ });
+
+ // Prepare data for messages with timestamps only (point marks)
+ const pointData = allMessages
+ .filter((msg) => msg.timestamp && !(msg.start_time && msg.end_time)) // Only messages with timestamp but without start/end times
+ .map((msg) => {
+ // Get the timestamp
+ const timestamp = new Date(msg.timestamp!);
+
+ // Use the message idx directly for consistent ordering
+ const index = msg.idx;
+
+ // Truncate content for tooltip readability
+ const displayContent = msg.content
+ ? msg.content.length > 100
+ ? msg.content.substring(0, 100) + "..."
+ : msg.content
+ : "No content";
+
+ // Prepare tool input and output for tooltip if applicable
+ const toolInput = msg.input
+ ? msg.input.length > 100
+ ? msg.input.substring(0, 100) + "..."
+ : msg.input
+ : "";
+
+ const toolResult = msg.tool_result
+ ? msg.tool_result.length > 100
+ ? msg.tool_result.substring(0, 100) + "..."
+ : msg.tool_result
+ : "";
+
+ return {
+ index: index,
+ message_type: msg.type,
+ content: displayContent,
+ tool_name: msg.tool_name || "",
+ tool_input: toolInput,
+ tool_result: toolResult,
+ time: timestamp.toISOString(),
+ message: JSON.stringify(msg, null, 2), // Full message for detailed inspection
+ };
+ });
+
+ // Add tool call data to the appropriate arrays
+ const toolBarData = toolCallData
+ .filter((d) => d.type === "bar")
+ .map((d) => {
+ delete d.type;
+ return d;
+ });
+
+ const toolPointData = toolCallData
+ .filter((d) => d.type === "point")
+ .map((d) => {
+ delete d.type;
+ return d;
+ });
+
+ // Check if we have any data to display
+ if (
+ barData.length === 0 &&
+ pointData.length === 0 &&
+ toolBarData.length === 0 &&
+ toolPointData.length === 0
+ ) {
+ return null;
+ }
+
+ // Calculate height based on number of unique messages
+ const chartHeight = 20 * Math.min(allMessages.length, 25); // Max 25 visible at once
+
+ // Create a layered Vega-Lite spec combining bars and points
+ const messagesSpec: TopLevelSpec = {
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
+ description: "Message Timeline",
+ width: "container",
+ height: chartHeight,
+ layer: [],
+ };
+
+ // Add bar layer if we have bar data
+ if (barData.length > 0 || toolBarData.length > 0) {
+ const combinedBarData = [...barData, ...toolBarData];
+ messagesSpec.layer.push({
+ data: { values: combinedBarData },
+ mark: {
+ type: "bar",
+ height: 16,
+ },
+ encoding: {
+ x: {
+ field: "start_time",
+ type: "temporal",
+ title: "Time",
+ axis: {
+ format: "%H:%M:%S",
+ title: "Time",
+ labelAngle: -45,
+ },
+ },
+ x2: { field: "end_time" },
+ y: {
+ field: "index",
+ type: "ordinal",
+ title: "Message Index",
+ axis: {
+ grid: true,
+ },
+ },
+ color: {
+ field: "message_type",
+ type: "nominal",
+ title: "Message Type",
+ legend: {},
+ },
+ tooltip: [
+ { field: "message_type", type: "nominal", title: "Type" },
+ { field: "tool_name", type: "nominal", title: "Tool" },
+ {
+ field: "start_time",
+ type: "temporal",
+ title: "Start Time",
+ format: "%H:%M:%S.%L",
+ },
+ {
+ field: "end_time",
+ type: "temporal",
+ title: "End Time",
+ format: "%H:%M:%S.%L",
+ },
+ { field: "content", type: "nominal", title: "Content" },
+ { field: "tool_input", type: "nominal", title: "Tool Input" },
+ { field: "tool_result", type: "nominal", title: "Tool Result" },
+ ],
+ },
+ });
+ }
+
+ // Add point layer if we have point data
+ if (pointData.length > 0 || toolPointData.length > 0) {
+ const combinedPointData = [...pointData, ...toolPointData];
+ messagesSpec.layer.push({
+ data: { values: combinedPointData },
+ mark: {
+ type: "point",
+ size: 100,
+ filled: true,
+ },
+ encoding: {
+ x: {
+ field: "time",
+ type: "temporal",
+ title: "Time",
+ axis: {
+ format: "%H:%M:%S",
+ title: "Time",
+ labelAngle: -45,
+ },
+ },
+ y: {
+ field: "index",
+ type: "ordinal",
+ title: "Message Index",
+ },
+ color: {
+ field: "message_type",
+ type: "nominal",
+ title: "Message Type",
+ },
+ tooltip: [
+ { field: "message_type", type: "nominal", title: "Type" },
+ { field: "tool_name", type: "nominal", title: "Tool" },
+ {
+ field: "time",
+ type: "temporal",
+ title: "Timestamp",
+ format: "%H:%M:%S.%L",
+ },
+ { field: "content", type: "nominal", title: "Content" },
+ { field: "tool_input", type: "nominal", title: "Tool Input" },
+ { field: "tool_result", type: "nominal", title: "Tool Result" },
+ ],
+ },
+ });
+ }
+ return messagesSpec;
+ } catch (error) {
+ console.error("Error rendering messages chart:", error);
+ }
+ }
+
+ render() {
+ const costSpec = this.createCostChartSpec();
+ const messagesSpec = this.getMessagesChartSpec();
+
+ return html`
+ <div class="chart-container" id="chartContainer">
+ <div class="chart-section">
+ <h3>Dollar Usage Over Time</h3>
+ <div class="chart-content">
+ ${this.chartData.length > 0
+ ? html`<vega-embed .spec=${costSpec}></vega-embed>`
+ : html`<p>No cost data available to display.</p>`}
+ </div>
+ </div>
+ <div class="chart-section">
+ <h3>Message Timeline</h3>
+ <div class="chart-content">
+ ${messagesSpec?.data
+ ? html`<vega-embed .spec=${messagesSpec}></vega-embed>`
+ : html`<p>No messages available to display.</p>`}
+ </div>
+ </div>
+ </div>
+ `;
+ }
+
+ private createCostChartSpec(): VisualizationSpec {
+ return {
+ $schema: "https://vega.github.io/schema/vega-lite/v5.json",
+ description: "Cumulative cost over time",
+ width: "container",
+ height: 300,
+ data: {
+ values: this.chartData.map((d) => ({
+ timestamp: d.timestamp.toISOString(),
+ cost: d.cost,
+ })),
+ },
+ mark: {
+ type: "line",
+ point: true,
+ },
+ encoding: {
+ x: {
+ field: "timestamp",
+ type: "temporal",
+ title: "Time",
+ axis: {
+ format: "%H:%M:%S",
+ title: "Time",
+ labelAngle: -45,
+ },
+ },
+ y: {
+ field: "cost",
+ type: "quantitative",
+ title: "Cumulative Cost (USD)",
+ axis: {
+ format: "$,.4f",
+ },
+ },
+ tooltip: [
+ {
+ field: "timestamp",
+ type: "temporal",
+ title: "Time",
+ format: "%Y-%m-%d %H:%M:%S",
+ },
+ {
+ field: "cost",
+ type: "quantitative",
+ title: "Cumulative Cost",
+ format: "$,.4f",
+ },
+ ],
+ },
+ };
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-charts": SketchCharts;
+ }
+}
diff --git a/webui/src/web-components/sketch-chat-input.test.ts b/webui/src/web-components/sketch-chat-input.test.ts
new file mode 100644
index 0000000..efb303f
--- /dev/null
+++ b/webui/src/web-components/sketch-chat-input.test.ts
@@ -0,0 +1,163 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchChatInput } from "./sketch-chat-input";
+
+test("initializes with empty content by default", async ({ mount }) => {
+ const component = await mount(SketchChatInput, {});
+
+ // Check public property via component's evaluate method
+ const content = await component.evaluate((el: SketchChatInput) => el.content);
+ expect(content).toBe("");
+
+ // Check textarea value
+ await expect(component.locator("#chatInput")).toHaveValue("");
+});
+
+test("initializes with provided content", async ({ mount }) => {
+ const testContent = "Hello, world!";
+ const component = await mount(SketchChatInput, {
+ props: {
+ content: testContent,
+ },
+ });
+
+ // Check public property via component's evaluate method
+ const content = await component.evaluate((el: SketchChatInput) => el.content);
+ expect(content).toBe(testContent);
+
+ // Check textarea value
+ await expect(component.locator("#chatInput")).toHaveValue(testContent);
+});
+
+test("updates content when typing in the textarea", async ({ mount }) => {
+ const component = await mount(SketchChatInput, {});
+ const newValue = "New message";
+
+ // Fill the textarea with new content
+ await component.locator("#chatInput").fill(newValue);
+
+ // Check that the content property was updated
+ const content = await component.evaluate((el: SketchChatInput) => el.content);
+ expect(content).toBe(newValue);
+});
+
+test("sends message when clicking the send button", async ({ mount }) => {
+ const testContent = "Test message";
+ const component = await mount(SketchChatInput, {
+ props: {
+ content: testContent,
+ },
+ });
+
+ // Set up promise to wait for the event
+ const eventPromise = component.evaluate((el) => {
+ return new Promise((resolve) => {
+ el.addEventListener(
+ "send-chat",
+ (event) => {
+ resolve((event as CustomEvent).detail);
+ },
+ { once: true },
+ );
+ });
+ });
+
+ // Click the send button
+ await component.locator("#sendChatButton").click();
+
+ // Wait for the event and check its details
+ const detail: any = await eventPromise;
+ expect(detail.message).toBe(testContent);
+});
+
+test.skip("sends message when pressing Enter (without shift)", async ({
+ mount,
+}) => {
+ const testContent = "Test message";
+ const component = await mount(SketchChatInput, {
+ props: {
+ content: testContent,
+ },
+ });
+
+ // Set up promise to wait for the event
+ const eventPromise = component.evaluate((el) => {
+ return new Promise((resolve) => {
+ el.addEventListener(
+ "send-chat",
+ (event) => {
+ resolve((event as CustomEvent).detail);
+ },
+ { once: true },
+ );
+ });
+ });
+
+ // Press Enter in the textarea
+ await component.locator("#chatInput").press("Enter");
+
+ // Wait for the event and check its details
+ const detail: any = await eventPromise;
+ expect(detail.message).toBe(testContent);
+
+ // Check that content was cleared
+ const content = await component.evaluate((el: SketchChatInput) => el.content);
+ expect(content).toBe("");
+});
+
+test.skip("does not send message when pressing Shift+Enter", async ({
+ mount,
+}) => {
+ const testContent = "Test message";
+ const component = await mount(SketchChatInput, {
+ props: {
+ content: testContent,
+ },
+ });
+
+ // Set up to track if event fires
+ let eventFired = false;
+ await component.evaluate((el) => {
+ el.addEventListener("send-chat", () => {
+ (window as any).__eventFired = true;
+ });
+ (window as any).__eventFired = false;
+ });
+
+ // Press Shift+Enter in the textarea
+ await component.locator("#chatInput").press("Shift+Enter");
+
+ // Wait a short time and check if event fired
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ eventFired = await component.evaluate(() => (window as any).__eventFired);
+ expect(eventFired).toBe(false);
+
+ // Check that content was not cleared
+ const content = await component.evaluate((el: SketchChatInput) => el.content);
+ expect(content).toBe(testContent);
+});
+
+test("resizes when user enters more text than will fit", async ({ mount }) => {
+ const testContent = "Test message\n\n\n\n\n\n\n\n\n\n\n\n\nends here.";
+ const component = await mount(SketchChatInput, {
+ props: {
+ content: "",
+ },
+ });
+ const origHeight = await component.evaluate(
+ (el: SketchChatInput) => el.chatInput.style.height,
+ );
+
+ // Enter very tall text in the textarea
+ await component.locator("#chatInput").fill(testContent);
+
+ // Wait for the requestAnimationFrame to complete
+ await component.evaluate(() => new Promise(requestAnimationFrame));
+
+ // Check that textarea resized
+ const newHeight = await component.evaluate(
+ (el: SketchChatInput) => el.chatInput.style.height,
+ );
+ expect(Number.parseInt(newHeight)).toBeGreaterThan(
+ Number.parseInt(origHeight),
+ );
+});
diff --git a/webui/src/web-components/sketch-chat-input.ts b/webui/src/web-components/sketch-chat-input.ts
new file mode 100644
index 0000000..74e462f
--- /dev/null
+++ b/webui/src/web-components/sketch-chat-input.ts
@@ -0,0 +1,176 @@
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state, query } from "lit/decorators.js";
+
+@customElement("sketch-chat-input")
+export class SketchChatInput extends LitElement {
+ @state()
+ content: string = "";
+
+ // 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`
+ /* Chat styles - exactly matching timeline.css */
+ .chat-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ background: #f0f0f0;
+ padding: 15px;
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+ min-height: 40px; /* Ensure minimum height */
+ }
+
+ .chat-input-wrapper {
+ display: flex;
+ max-width: 1200px;
+ margin: 0 auto;
+ gap: 10px;
+ }
+
+ #chatInput {
+ flex: 1;
+ padding: 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ resize: vertical;
+ font-family: monospace;
+ font-size: 12px;
+ min-height: 40px;
+ max-height: 300px;
+ background: #f7f7f7;
+ overflow-y: auto;
+ box-sizing: border-box; /* Ensure padding is included in height calculation */
+ line-height: 1.4; /* Consistent line height for better height calculation */
+ }
+
+ #sendChatButton {
+ background-color: #2196f3;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ padding: 0 20px;
+ cursor: pointer;
+ font-weight: 600;
+ align-self: center;
+ height: 40px;
+ }
+
+ #sendChatButton:hover {
+ background-color: #0d8bf2;
+ }
+ `;
+
+ constructor() {
+ super();
+ this._handleDiffComment = this._handleDiffComment.bind(this);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ window.addEventListener("diff-comment", this._handleDiffComment);
+ }
+
+ private _handleDiffComment(event: CustomEvent) {
+ const { comment } = event.detail;
+ if (!comment) return;
+
+ if (this.content != "") {
+ this.content += "\n\n";
+ }
+ this.content += comment;
+ requestAnimationFrame(() => this.adjustChatSpacing());
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener("diff-comment", this._handleDiffComment);
+ }
+
+ sendChatMessage() {
+ const event = new CustomEvent("send-chat", {
+ detail: { message: this.content },
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(event);
+
+ // TODO(philip?): Ideally we only clear the content if the send is successful.
+ this.content = ""; // Clear content after sending
+ }
+
+ adjustChatSpacing() {
+ if (!this.chatInput) return;
+
+ // Reset height to minimal value to correctly calculate scrollHeight
+ this.chatInput.style.height = "auto";
+
+ // Get the scroll height (content height)
+ const scrollHeight = this.chatInput.scrollHeight;
+
+ // Set the height to match content (up to max-height which is handled by CSS)
+ this.chatInput.style.height = `${scrollHeight}px`;
+ }
+
+ async _sendChatClicked() {
+ this.sendChatMessage();
+ this.chatInput.focus(); // Refocus the input after sending
+ // Reset height after sending a message
+ requestAnimationFrame(() => this.adjustChatSpacing());
+ }
+
+ _chatInputKeyDown(event: KeyboardEvent) {
+ // Send message if Enter is pressed without Shift key
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault(); // Prevent default newline
+ this.sendChatMessage();
+ }
+ }
+
+ _chatInputChanged(event) {
+ this.content = event.target.value;
+ // Use requestAnimationFrame to ensure DOM updates have completed
+ requestAnimationFrame(() => this.adjustChatSpacing());
+ }
+
+ @query("#chatInput")
+ chatInput: HTMLTextAreaElement;
+
+ protected firstUpdated(): void {
+ if (this.chatInput) {
+ this.chatInput.focus();
+ // Initialize the input height
+ this.adjustChatSpacing();
+ }
+ }
+
+ render() {
+ return html`
+ <div class="chat-container">
+ <div class="chat-input-wrapper">
+ <textarea
+ id="chatInput"
+ placeholder="Type your message here and press Enter to send..."
+ autofocus
+ @keydown="${this._chatInputKeyDown}"
+ @input="${this._chatInputChanged}"
+ .value=${this.content || ""}
+ ></textarea>
+ <button @click="${this._sendChatClicked}" id="sendChatButton">
+ Send
+ </button>
+ </div>
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-chat-input": SketchChatInput;
+ }
+}
diff --git a/webui/src/web-components/sketch-container-status.test.ts b/webui/src/web-components/sketch-container-status.test.ts
new file mode 100644
index 0000000..db11a4e
--- /dev/null
+++ b/webui/src/web-components/sketch-container-status.test.ts
@@ -0,0 +1,171 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchContainerStatus } from "./sketch-container-status";
+import { State } from "../types";
+
+// Mock complete state for testing
+const mockCompleteState: State = {
+ hostname: "test-host",
+ working_dir: "/test/dir",
+ initial_commit: "abcdef1234567890",
+ message_count: 42,
+ os: "linux",
+ title: "Test Session",
+ total_usage: {
+ input_tokens: 1000,
+ output_tokens: 2000,
+ cache_read_input_tokens: 300,
+ cache_creation_input_tokens: 400,
+ total_cost_usd: 0.25,
+ start_time: "",
+ messages: 0,
+ tool_uses: {},
+ },
+};
+
+test("render props", async ({ mount }) => {
+ const component = await mount(SketchContainerStatus, {
+ props: {
+ state: mockCompleteState,
+ },
+ });
+ await expect(component.locator("#hostname")).toContainText(
+ mockCompleteState.hostname,
+ );
+ // Check that all expected elements exist
+ await expect(component.locator("#workingDir")).toContainText(
+ mockCompleteState.working_dir,
+ );
+ await expect(component.locator("#initialCommit")).toContainText(
+ mockCompleteState.initial_commit.substring(0, 8),
+ );
+
+ await expect(component.locator("#messageCount")).toContainText(
+ mockCompleteState.message_count + "",
+ );
+ await expect(component.locator("#inputTokens")).toContainText(
+ mockCompleteState.total_usage.input_tokens + "",
+ );
+ await expect(component.locator("#outputTokens")).toContainText(
+ mockCompleteState.total_usage.output_tokens + "",
+ );
+
+ await expect(component.locator("#cacheReadInputTokens")).toContainText(
+ mockCompleteState.total_usage.cache_read_input_tokens + "",
+ );
+ await expect(component.locator("#cacheCreationInputTokens")).toContainText(
+ mockCompleteState.total_usage.cache_creation_input_tokens + "",
+ );
+ await expect(component.locator("#totalCost")).toContainText(
+ "$" + mockCompleteState.total_usage.total_cost_usd.toFixed(2),
+ );
+});
+
+test("renders with undefined state", async ({ mount }) => {
+ const component = await mount(SketchContainerStatus, {});
+
+ // Elements should exist but be empty
+ await expect(component.locator("#hostname")).toContainText("");
+ await expect(component.locator("#workingDir")).toContainText("");
+ await expect(component.locator("#initialCommit")).toContainText("");
+ await expect(component.locator("#messageCount")).toContainText("");
+ await expect(component.locator("#inputTokens")).toContainText("");
+ await expect(component.locator("#outputTokens")).toContainText("");
+ await expect(component.locator("#totalCost")).toContainText("$0.00");
+});
+
+test("renders with partial state data", async ({ mount }) => {
+ const partialState: Partial<State> = {
+ hostname: "partial-host",
+ message_count: 10,
+ os: "linux",
+ title: "Partial Test",
+ total_usage: {
+ input_tokens: 500,
+ start_time: "",
+ messages: 0,
+ output_tokens: 0,
+ cache_read_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ total_cost_usd: 0,
+ tool_uses: {},
+ },
+ };
+
+ const component = await mount(SketchContainerStatus, {
+ props: {
+ state: partialState as State,
+ },
+ });
+
+ // Check that elements with data are properly populated
+ await expect(component.locator("#hostname")).toContainText("partial-host");
+ await expect(component.locator("#messageCount")).toContainText("10");
+ await expect(component.locator("#inputTokens")).toContainText("500");
+
+ // Check that elements without data are empty
+ await expect(component.locator("#workingDir")).toContainText("");
+ await expect(component.locator("#initialCommit")).toContainText("");
+ await expect(component.locator("#outputTokens")).toContainText("");
+ await expect(component.locator("#totalCost")).toContainText("$0.00");
+});
+
+test("handles cost formatting correctly", async ({ mount }) => {
+ // Test with different cost values
+ const testCases = [
+ { cost: 0, expected: "$0.00" },
+ { cost: 0.1, expected: "$0.10" },
+ { cost: 1.234, expected: "$1.23" },
+ { cost: 10.009, expected: "$10.01" },
+ ];
+
+ for (const testCase of testCases) {
+ const stateWithCost = {
+ ...mockCompleteState,
+ total_usage: {
+ ...mockCompleteState.total_usage,
+ total_cost_usd: testCase.cost,
+ },
+ };
+
+ const component = await mount(SketchContainerStatus, {
+ props: {
+ state: stateWithCost,
+ },
+ });
+ await expect(component.locator("#totalCost")).toContainText(
+ testCase.expected,
+ );
+ await component.unmount();
+ }
+});
+
+test("truncates commit hash to 8 characters", async ({ mount }) => {
+ const stateWithLongCommit = {
+ ...mockCompleteState,
+ initial_commit: "1234567890abcdef1234567890abcdef12345678",
+ };
+
+ const component = await mount(SketchContainerStatus, {
+ props: {
+ state: stateWithLongCommit,
+ },
+ });
+
+ await expect(component.locator("#initialCommit")).toContainText("12345678");
+});
+
+test("has correct link elements", async ({ mount }) => {
+ const component = await mount(SketchContainerStatus, {
+ props: {
+ state: mockCompleteState,
+ },
+ });
+
+ // Check for logs link
+ const logsLink = component.locator("a").filter({ hasText: "Logs" });
+ await expect(logsLink).toHaveAttribute("href", "logs");
+
+ // Check for download link
+ const downloadLink = component.locator("a").filter({ hasText: "Download" });
+ await expect(downloadLink).toHaveAttribute("href", "download");
+});
diff --git a/webui/src/web-components/sketch-container-status.ts b/webui/src/web-components/sketch-container-status.ts
new file mode 100644
index 0000000..9e542cb
--- /dev/null
+++ b/webui/src/web-components/sketch-container-status.ts
@@ -0,0 +1,237 @@
+import { State } from "../types";
+import { LitElement, css, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+@customElement("sketch-container-status")
+export class SketchContainerStatus extends LitElement {
+ // Header bar: Container status details
+
+ @property()
+ state: State;
+
+ // 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`
+ .info-card {
+ background: #f9f9f9;
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+ display: none; /* Hidden in the combined layout */
+ }
+
+ .info-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ background: #f9f9f9;
+ border-radius: 4px;
+ padding: 4px 10px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+ flex: 1;
+ }
+
+ .info-item {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ margin-right: 10px;
+ font-size: 13px;
+ }
+
+ .info-label {
+ font-size: 11px;
+ color: #555;
+ margin-right: 3px;
+ font-weight: 500;
+ }
+
+ .info-value {
+ font-size: 11px;
+ font-weight: 600;
+ }
+
+ [title] {
+ cursor: help;
+ text-decoration: underline dotted;
+ }
+
+ .cost {
+ color: #2e7d32;
+ }
+
+ .info-item a {
+ --tw-text-opacity: 1;
+ color: rgb(37 99 235 / var(--tw-text-opacity, 1));
+ text-decoration: inherit;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ formatHostname() {
+ const outsideHostname = this.state?.outside_hostname;
+ const insideHostname = this.state?.inside_hostname;
+
+ if (!outsideHostname || !insideHostname) {
+ return this.state?.hostname;
+ }
+
+ if (outsideHostname === insideHostname) {
+ return outsideHostname;
+ }
+
+ return `${outsideHostname}:${insideHostname}`;
+ }
+
+ formatWorkingDir() {
+ const outsideWorkingDir = this.state?.outside_working_dir;
+ const insideWorkingDir = this.state?.inside_working_dir;
+
+ if (!outsideWorkingDir || !insideWorkingDir) {
+ return this.state?.working_dir;
+ }
+
+ if (outsideWorkingDir === insideWorkingDir) {
+ return outsideWorkingDir;
+ }
+
+ return `${outsideWorkingDir}:${insideWorkingDir}`;
+ }
+
+ getHostnameTooltip() {
+ const outsideHostname = this.state?.outside_hostname;
+ const insideHostname = this.state?.inside_hostname;
+
+ if (
+ !outsideHostname ||
+ !insideHostname ||
+ outsideHostname === insideHostname
+ ) {
+ return "";
+ }
+
+ return `Outside: ${outsideHostname}, Inside: ${insideHostname}`;
+ }
+
+ getWorkingDirTooltip() {
+ const outsideWorkingDir = this.state?.outside_working_dir;
+ const insideWorkingDir = this.state?.inside_working_dir;
+
+ if (
+ !outsideWorkingDir ||
+ !insideWorkingDir ||
+ outsideWorkingDir === insideWorkingDir
+ ) {
+ return "";
+ }
+
+ return `Outside: ${outsideWorkingDir}, Inside: ${insideWorkingDir}`;
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ connectedCallback() {
+ super.connectedCallback();
+ // register event listeners
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ // unregister event listeners
+ }
+
+ render() {
+ return html`
+ <div class="info-grid">
+ <div class="info-item">
+ <a href="logs">Logs</a>
+ </div>
+ <div class="info-item">
+ <a href="download">Download</a>
+ </div>
+ <div class="info-item">
+ <span
+ id="hostname"
+ class="info-value"
+ title="${this.getHostnameTooltip()}"
+ >
+ ${this.formatHostname()}
+ </span>
+ </div>
+ <div class="info-item">
+ <span
+ id="workingDir"
+ class="info-value"
+ title="${this.getWorkingDirTooltip()}"
+ >
+ ${this.formatWorkingDir()}
+ </span>
+ </div>
+ ${this.state?.git_origin
+ ? html`
+ <div class="info-item">
+ <span class="info-label">Origin:</span>
+ <span id="gitOrigin" class="info-value"
+ >${this.state?.git_origin}</span
+ >
+ </div>
+ `
+ : ""}
+ <div class="info-item">
+ <span class="info-label">Commit:</span>
+ <span id="initialCommit" class="info-value"
+ >${this.state?.initial_commit?.substring(0, 8)}</span
+ >
+ </div>
+ <div class="info-item">
+ <span class="info-label">Msgs:</span>
+ <span id="messageCount" class="info-value"
+ >${this.state?.message_count}</span
+ >
+ </div>
+ <div class="info-item">
+ <span class="info-label">In:</span>
+ <span id="inputTokens" class="info-value"
+ >${this.state?.total_usage?.input_tokens}</span
+ >
+ </div>
+ <div class="info-item">
+ <span class="info-label">Cache Read:</span>
+ <span id="cacheReadInputTokens" class="info-value"
+ >${this.state?.total_usage?.cache_read_input_tokens}</span
+ >
+ </div>
+ <div class="info-item">
+ <span class="info-label">Cache Create:</span>
+ <span id="cacheCreationInputTokens" class="info-value"
+ >${this.state?.total_usage?.cache_creation_input_tokens}</span
+ >
+ </div>
+ <div class="info-item">
+ <span class="info-label">Out:</span>
+ <span id="outputTokens" class="info-value"
+ >${this.state?.total_usage?.output_tokens}</span
+ >
+ </div>
+ <div class="info-item">
+ <span class="info-label">Cost:</span>
+ <span id="totalCost" class="info-value cost"
+ >$${(this.state?.total_usage?.total_cost_usd || 0).toFixed(2)}</span
+ >
+ </div>
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-container-status": SketchContainerStatus;
+ }
+}
diff --git a/webui/src/web-components/sketch-diff-view.ts b/webui/src/web-components/sketch-diff-view.ts
new file mode 100644
index 0000000..47c14f3
--- /dev/null
+++ b/webui/src/web-components/sketch-diff-view.ts
@@ -0,0 +1,615 @@
+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;
+
+ // The clicked button element used for positioning the comment box
+ @state()
+ private clickedElement: HTMLElement | 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: absolute;
+ 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;
+ margin-top: 10px;
+ }
+
+ .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 {
+ // Build the diff URL - include commit hash if specified
+ const diffUrl = this.commitHash
+ ? `diff?commit=${this.commitHash}`
+ : "diff";
+
+ if (this.commitHash) {
+ diff2htmlContent.innerHTML = `Loading diff for commit <strong>${this.commitHash}</strong>...`;
+ } else {
+ diff2htmlContent.innerHTML = "Loading 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;
+
+ // 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 and store the clicked element for positioning
+ this.clickedElement = target;
+ 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>
+ `;
+
+ // Append the comment box to the diff container to ensure proper positioning
+ const diffContainer = this.shadowRoot?.querySelector(".diff-container");
+ if (diffContainer) {
+ diffContainer.appendChild(commentBox);
+ } else {
+ 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 and position it below the clicked line
+ if (commentBox && this.clickedElement) {
+ // Get the row that contains the clicked button
+ const row = this.clickedElement.closest("tr");
+ if (row) {
+ // Get the position of the row
+ const rowRect = row.getBoundingClientRect();
+ const diffContainerRect = this.shadowRoot
+ ?.querySelector(".diff-container")
+ ?.getBoundingClientRect();
+
+ if (diffContainerRect) {
+ // Position the comment box below the row
+ const topPosition =
+ rowRect.bottom -
+ diffContainerRect.top +
+ this.shadowRoot!.querySelector(".diff-container")!.scrollTop;
+ const leftPosition = rowRect.left - diffContainerRect.left;
+
+ commentBox.style.top = `${topPosition}px`;
+ commentBox.style.left = `${leftPosition}px`;
+ commentBox.style.display = "block";
+ }
+ } else {
+ // Fallback if we can't find the row
+ commentBox.style.display = "block";
+ }
+ } else if (commentBox) {
+ // Fallback if we don't have clickedElement
+ 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;
+ this.clickedElement = 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;
+ }
+}
diff --git a/webui/src/web-components/sketch-network-status.test.ts b/webui/src/web-components/sketch-network-status.test.ts
new file mode 100644
index 0000000..45882a0
--- /dev/null
+++ b/webui/src/web-components/sketch-network-status.test.ts
@@ -0,0 +1,65 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchNetworkStatus } from "./sketch-network-status";
+
+test("displays the correct connection status when connected", async ({
+ mount,
+}) => {
+ const component = await mount(SketchNetworkStatus, {
+ props: {
+ connection: "connected",
+ message: "Connected to server",
+ },
+ });
+
+ await expect(component.locator(".polling-indicator")).toBeVisible();
+ await expect(component.locator(".status-text")).toBeVisible();
+ await expect(component.locator(".polling-indicator.active")).toBeVisible();
+ await expect(component.locator(".status-text")).toContainText(
+ "Connected to server",
+ );
+});
+
+test("displays the correct connection status when disconnected", async ({
+ mount,
+}) => {
+ const component = await mount(SketchNetworkStatus, {
+ props: {
+ connection: "disconnected",
+ message: "Disconnected",
+ },
+ });
+
+ await expect(component.locator(".polling-indicator")).toBeVisible();
+ await expect(component.locator(".polling-indicator.error")).toBeVisible();
+});
+
+test("displays the correct connection status when disabled", async ({
+ mount,
+}) => {
+ const component = await mount(SketchNetworkStatus, {
+ props: {
+ connection: "disabled",
+ message: "Disabled",
+ },
+ });
+
+ await expect(component.locator(".polling-indicator")).toBeVisible();
+ await expect(component.locator(".polling-indicator.error")).not.toBeVisible();
+ await expect(
+ component.locator(".polling-indicator.active"),
+ ).not.toBeVisible();
+});
+
+test("displays error message when provided", async ({ mount }) => {
+ const errorMsg = "Connection error";
+ const component = await mount(SketchNetworkStatus, {
+ props: {
+ connection: "disconnected",
+ message: "Disconnected",
+ error: errorMsg,
+ },
+ });
+
+ await expect(component.locator(".status-text")).toBeVisible();
+ await expect(component.locator(".status-text")).toContainText(errorMsg);
+});
diff --git a/webui/src/web-components/sketch-network-status.ts b/webui/src/web-components/sketch-network-status.ts
new file mode 100644
index 0000000..2a0e455
--- /dev/null
+++ b/webui/src/web-components/sketch-network-status.ts
@@ -0,0 +1,103 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+@customElement("sketch-network-status")
+export class SketchNetworkStatus extends LitElement {
+ @property()
+ connection: string;
+
+ @property()
+ message: string;
+
+ @property()
+ error: string;
+
+ // 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`
+ .status-container {
+ display: flex;
+ align-items: center;
+ }
+
+ .polling-indicator {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 4px;
+ background-color: #ccc;
+ }
+
+ .polling-indicator.active {
+ background-color: #4caf50;
+ animation: pulse 1.5s infinite;
+ }
+
+ .polling-indicator.error {
+ background-color: #f44336;
+ animation: pulse 1.5s infinite;
+ }
+
+ @keyframes pulse {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ .status-text {
+ font-size: 11px;
+ color: #666;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ indicator() {
+ if (this.connection === "disabled") {
+ return "";
+ }
+ return this.connection === "connected" ? "active" : "error";
+ }
+
+ render() {
+ return html`
+ <div class="status-container">
+ <span
+ id="pollingIndicator"
+ class="polling-indicator ${this.indicator()}"
+ ></span>
+ <span id="statusText" class="status-text"
+ >${this.error || this.message}</span
+ >
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-network-status": SketchNetworkStatus;
+ }
+}
diff --git a/webui/src/web-components/sketch-terminal.ts b/webui/src/web-components/sketch-terminal.ts
new file mode 100644
index 0000000..4ffccfd
--- /dev/null
+++ b/webui/src/web-components/sketch-terminal.ts
@@ -0,0 +1,365 @@
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+
+import { css, html, LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import "./sketch-container-status";
+
+@customElement("sketch-terminal")
+export class SketchTerminal extends LitElement {
+ // Terminal instance
+ private terminal: Terminal | null = null;
+ // Terminal fit addon for handling resize
+ private fitAddon: FitAddon | null = null;
+ // Terminal EventSource for SSE
+ private terminalEventSource: EventSource | null = null;
+ // Terminal ID (always 1 for now, will support 1-9 later)
+ private terminalId: string = "1";
+ // Queue for serializing terminal inputs
+ private terminalInputQueue: string[] = [];
+ // Flag to track if we're currently processing a terminal input
+ private processingTerminalInput: boolean = false;
+
+ static styles = css`
+ /* Terminal View Styles */
+ .terminal-view {
+ width: 100%;
+ background-color: #f5f5f5;
+ border-radius: 8px;
+ overflow: hidden;
+ margin-bottom: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ padding: 15px;
+ height: 70vh;
+ }
+
+ .terminal-container {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+ `;
+
+ constructor() {
+ super();
+ this._resizeHandler = this._resizeHandler.bind(this);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.loadXtermlCSS();
+ // Setup resize handler
+ window.addEventListener("resize", this._resizeHandler);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ window.removeEventListener("resize", this._resizeHandler);
+
+ this.closeTerminalConnections();
+
+ if (this.terminal) {
+ this.terminal.dispose();
+ this.terminal = null;
+ }
+ this.fitAddon = null;
+ }
+
+ firstUpdated() {
+ this.initializeTerminal();
+ }
+
+ _resizeHandler() {
+ if (this.fitAddon) {
+ this.fitAddon.fit();
+ // Send resize information to server
+ this.sendTerminalResize();
+ }
+ }
+
+ // Load xterm CSS into the shadow DOM
+ private async loadXtermlCSS() {
+ try {
+ // Check if diff2html styles are already loaded
+ const styleId = "xterm-styles";
+ if (this.shadowRoot?.getElementById(styleId)) {
+ return; // Already loaded
+ }
+
+ // Fetch the diff2html CSS
+ const response = await fetch("static/xterm.css");
+
+ if (!response.ok) {
+ console.error(
+ `Failed to load xterm 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.renderRoot?.appendChild(style);
+
+ console.log("xterm CSS loaded into shadow DOM");
+ } catch (error) {
+ console.error("Error loading xterm CSS:", error);
+ }
+ }
+
+ /**
+ * Initialize the terminal component
+ * @param terminalContainer The DOM element to contain the terminal
+ */
+ public async initializeTerminal(): Promise<void> {
+ const terminalContainer = this.renderRoot.querySelector(
+ "#terminalContainer",
+ ) as HTMLElement;
+
+ if (!terminalContainer) {
+ console.error("Terminal container not found");
+ return;
+ }
+
+ // If terminal is already initialized, just focus it
+ if (this.terminal) {
+ this.terminal.focus();
+ if (this.fitAddon) {
+ this.fitAddon.fit();
+ }
+ return;
+ }
+
+ // Clear the terminal container
+ terminalContainer.innerHTML = "";
+
+ // Create new terminal instance
+ this.terminal = new Terminal({
+ cursorBlink: true,
+ theme: {
+ background: "#f5f5f5",
+ foreground: "#333333",
+ cursor: "#0078d7",
+ selectionBackground: "rgba(0, 120, 215, 0.4)",
+ },
+ });
+
+ // Add fit addon to handle terminal resizing
+ this.fitAddon = new FitAddon();
+ this.terminal.loadAddon(this.fitAddon);
+
+ // Open the terminal in the container
+ this.terminal.open(terminalContainer);
+
+ // Connect to WebSocket
+ await this.connectTerminal();
+
+ // Fit the terminal to the container
+ this.fitAddon.fit();
+
+ // Focus the terminal
+ this.terminal.focus();
+ }
+
+ /**
+ * Connect to terminal events stream
+ */
+ private async connectTerminal(): Promise<void> {
+ if (!this.terminal) {
+ return;
+ }
+
+ // Close existing connections if any
+ this.closeTerminalConnections();
+
+ try {
+ // Connect directly to the SSE endpoint for terminal 1
+ // Use relative URL based on current location
+ const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
+ const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
+ this.terminalEventSource = new EventSource(eventsUrl);
+
+ // Handle SSE events
+ this.terminalEventSource.onopen = () => {
+ console.log("Terminal SSE connection opened");
+ this.sendTerminalResize();
+ };
+
+ this.terminalEventSource.onmessage = (event) => {
+ if (this.terminal) {
+ // Decode base64 data before writing to terminal
+ try {
+ // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
+ const decoded = base64ToUint8Array(event.data);
+ this.terminal.write(decoded);
+ } catch (e) {
+ console.error("Error decoding terminal data:", e);
+ }
+ }
+ };
+
+ this.terminalEventSource.onerror = (error) => {
+ console.error("Terminal SSE error:", error);
+ if (this.terminal) {
+ this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
+ }
+ // Attempt to reconnect if the connection was lost
+ if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
+ this.closeTerminalConnections();
+ }
+ };
+
+ // Send key inputs to the server via POST requests
+ if (this.terminal) {
+ this.terminal.onData((data) => {
+ this.sendTerminalInput(data);
+ });
+ }
+ } catch (error) {
+ console.error("Failed to connect to terminal:", error);
+ if (this.terminal) {
+ this.terminal.write(
+ `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
+ );
+ }
+ }
+ }
+
+ /**
+ * Close any active terminal connections
+ */
+ private closeTerminalConnections(): void {
+ if (this.terminalEventSource) {
+ this.terminalEventSource.close();
+ this.terminalEventSource = null;
+ }
+ }
+
+ /**
+ * Send input to the terminal
+ * @param data The input data to send
+ */
+ private async sendTerminalInput(data: string): Promise<void> {
+ // Add the data to the queue
+ this.terminalInputQueue.push(data);
+
+ // If we're not already processing inputs, start processing
+ if (!this.processingTerminalInput) {
+ await this.processTerminalInputQueue();
+ }
+ }
+
+ /**
+ * Process the terminal input queue in order
+ */
+ private async processTerminalInputQueue(): Promise<void> {
+ if (this.terminalInputQueue.length === 0) {
+ this.processingTerminalInput = false;
+ return;
+ }
+
+ this.processingTerminalInput = true;
+
+ // Concatenate all available inputs from the queue into a single request
+ let combinedData = "";
+
+ // Take all currently available items from the queue
+ while (this.terminalInputQueue.length > 0) {
+ combinedData += this.terminalInputQueue.shift()!;
+ }
+
+ try {
+ // Use relative URL based on current location
+ const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
+ const response = await fetch(
+ `${baseUrl}/terminal/input/${this.terminalId}`,
+ {
+ method: "POST",
+ body: combinedData,
+ headers: {
+ "Content-Type": "text/plain",
+ },
+ },
+ );
+
+ if (!response.ok) {
+ console.error(
+ `Failed to send terminal input: ${response.status} ${response.statusText}`,
+ );
+ }
+ } catch (error) {
+ console.error("Error sending terminal input:", error);
+ }
+
+ // Continue processing the queue (for any new items that may have been added)
+ await this.processTerminalInputQueue();
+ }
+
+ /**
+ * Send terminal resize information to the server
+ */
+ private async sendTerminalResize(): Promise<void> {
+ if (!this.terminal || !this.fitAddon) {
+ return;
+ }
+
+ // Get terminal dimensions
+ try {
+ // Send resize message in a format the server can understand
+ // Use relative URL based on current location
+ const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
+ const response = await fetch(
+ `${baseUrl}/terminal/input/${this.terminalId}`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ type: "resize",
+ cols: this.terminal.cols || 80, // Default to 80 if undefined
+ rows: this.terminal.rows || 24, // Default to 24 if undefined
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ );
+
+ if (!response.ok) {
+ console.error(
+ `Failed to send terminal resize: ${response.status} ${response.statusText}`,
+ );
+ }
+ } catch (error) {
+ console.error("Error sending terminal resize:", error);
+ }
+ }
+
+ render() {
+ return html`
+ <div id="terminalView" class="terminal-view">
+ <div id="terminalContainer" class="terminal-container"></div>
+ </div>
+ `;
+ }
+}
+
+function base64ToUint8Array(base64String) {
+ // This isn't yet available in Chrome, but Safari has it!
+ // @ts-ignore
+ if (Uint8Array.fromBase64) {
+ // @ts-ignore
+ return Uint8Array.fromBase64(base64String);
+ }
+
+ const binaryString = atob(base64String);
+ return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-terminal": SketchTerminal;
+ }
+}
diff --git a/webui/src/web-components/sketch-timeline-message.test.ts b/webui/src/web-components/sketch-timeline-message.test.ts
new file mode 100644
index 0000000..bc74202
--- /dev/null
+++ b/webui/src/web-components/sketch-timeline-message.test.ts
@@ -0,0 +1,311 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchTimelineMessage } from "./sketch-timeline-message";
+import {
+ AgentMessage,
+ CodingAgentMessageType,
+ GitCommit,
+ Usage,
+} from "../types";
+
+// Helper function to create mock timeline messages
+function createMockMessage(props: Partial<AgentMessage> = {}): AgentMessage {
+ return {
+ idx: props.idx || 0,
+ type: props.type || "agent",
+ content: props.content || "Hello world",
+ timestamp: props.timestamp || "2023-05-15T12:00:00Z",
+ elapsed: props.elapsed || 1500000000, // 1.5 seconds in nanoseconds
+ end_of_turn: props.end_of_turn || false,
+ conversation_id: props.conversation_id || "conv123",
+ tool_calls: props.tool_calls || [],
+ commits: props.commits || [],
+ usage: props.usage,
+ ...props,
+ };
+}
+
+test("renders with basic message content", async ({ mount }) => {
+ const message = createMockMessage({
+ type: "agent",
+ content: "This is a test message",
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".message-text")).toBeVisible();
+ await expect(component.locator(".message-text")).toContainText(
+ "This is a test message",
+ );
+});
+
+test.skip("renders with correct message type classes", async ({ mount }) => {
+ const messageTypes: CodingAgentMessageType[] = [
+ "user",
+ "agent",
+ "error",
+ "budget",
+ "tool",
+ "commit",
+ "auto",
+ ];
+
+ for (const type of messageTypes) {
+ const message = createMockMessage({ type });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".message")).toBeVisible();
+ await expect(component.locator(`.message.${type}`)).toBeVisible();
+ }
+});
+
+test("renders end-of-turn marker correctly", async ({ mount }) => {
+ const message = createMockMessage({
+ end_of_turn: true,
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".message")).toBeVisible();
+ await expect(component.locator(".message.end-of-turn")).toBeVisible();
+});
+
+test("formats timestamps correctly", async ({ mount }) => {
+ const message = createMockMessage({
+ timestamp: "2023-05-15T12:00:00Z",
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".message-timestamp")).toBeVisible();
+ // Should include a formatted date like "May 15, 2023"
+ await expect(component.locator(".message-timestamp")).toContainText(
+ "May 15, 2023",
+ );
+ // Should include elapsed time
+ await expect(component.locator(".message-timestamp")).toContainText(
+ "(1.50s)",
+ );
+});
+
+test("renders markdown content correctly", async ({ mount }) => {
+ const markdownContent =
+ "# Heading\n\n- List item 1\n- List item 2\n\n`code block`";
+ const message = createMockMessage({
+ content: markdownContent,
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".markdown-content")).toBeVisible();
+
+ // Check HTML content
+ const html = await component
+ .locator(".markdown-content")
+ .evaluate((element) => element.innerHTML);
+ expect(html).toContain("<h1>Heading</h1>");
+ expect(html).toContain("<ul>");
+ expect(html).toContain("<li>List item 1</li>");
+ expect(html).toContain("<code>code block</code>");
+});
+
+test("displays usage information when available", async ({ mount }) => {
+ const usage: Usage = {
+ input_tokens: 150,
+ output_tokens: 300,
+ cost_usd: 0.025,
+ cache_read_input_tokens: 50,
+ cache_creation_input_tokens: 0,
+ };
+
+ const message = createMockMessage({
+ usage,
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".message-usage")).toBeVisible();
+ await expect(component.locator(".message-usage")).toContainText("150"); // In
+ await expect(component.locator(".message-usage")).toContainText("300"); // Out
+ await expect(component.locator(".message-usage")).toContainText("50"); // Cache
+ await expect(component.locator(".message-usage")).toContainText("$0.03"); // Cost
+});
+
+test("renders commit information correctly", async ({ mount }) => {
+ const commits: GitCommit[] = [
+ {
+ hash: "1234567890abcdef",
+ subject: "Fix bug in application",
+ body: "This fixes a major bug in the application\n\nSigned-off-by: Developer",
+ pushed_branch: "main",
+ },
+ ];
+
+ const message = createMockMessage({
+ commits,
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".commits-container")).toBeVisible();
+ await expect(component.locator(".commits-header")).toBeVisible();
+ await expect(component.locator(".commits-header")).toContainText("1 new");
+
+ await expect(component.locator(".commit-hash")).toBeVisible();
+ await expect(component.locator(".commit-hash")).toHaveText("12345678"); // First 8 chars
+
+ await expect(component.locator(".pushed-branch")).toBeVisible();
+ await expect(component.locator(".pushed-branch")).toContainText("main");
+});
+
+test("dispatches show-commit-diff event when commit diff button is clicked", async ({
+ mount,
+}) => {
+ const commits: GitCommit[] = [
+ {
+ hash: "1234567890abcdef",
+ subject: "Fix bug in application",
+ body: "This fixes a major bug in the application",
+ pushed_branch: "main",
+ },
+ ];
+
+ const message = createMockMessage({
+ commits,
+ });
+
+ const component = await mount(SketchTimelineMessage, {
+ props: {
+ message: message,
+ },
+ });
+
+ await expect(component.locator(".commit-diff-button")).toBeVisible();
+
+ // Set up promise to wait for the event
+ const eventPromise = component.evaluate((el) => {
+ return new Promise((resolve) => {
+ el.addEventListener(
+ "show-commit-diff",
+ (event) => {
+ resolve((event as CustomEvent).detail);
+ },
+ { once: true },
+ );
+ });
+ });
+
+ // Click the diff button
+ await component.locator(".commit-diff-button").click();
+
+ // Wait for the event and check its details
+ const detail = await eventPromise;
+ expect(detail["commitHash"]).toBe("1234567890abcdef");
+});
+
+test.skip("handles message type icon display correctly", async ({ mount }) => {
+ // First message of a type should show icon
+ const firstMessage = createMockMessage({
+ type: "user",
+ idx: 0,
+ });
+
+ // Second message of same type should not show icon
+ const secondMessage = createMockMessage({
+ type: "user",
+ idx: 1,
+ });
+
+ // Test first message (should show icon)
+ const firstComponent = await mount(SketchTimelineMessage, {
+ props: {
+ message: firstMessage,
+ },
+ });
+
+ await expect(firstComponent.locator(".message-icon")).toBeVisible();
+ await expect(firstComponent.locator(".message-icon")).toHaveText("U");
+
+ // Test second message with previous message of same type
+ const secondComponent = await mount(SketchTimelineMessage, {
+ props: {
+ message: secondMessage,
+ previousMessage: firstMessage,
+ },
+ });
+
+ await expect(secondComponent.locator(".message-icon")).not.toBeVisible();
+});
+
+test("formats numbers correctly", async ({ mount }) => {
+ const component = await mount(SketchTimelineMessage, {});
+
+ // Test accessing public method via evaluate
+ const result1 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatNumber(1000),
+ );
+ expect(result1).toBe("1,000");
+
+ const result2 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatNumber(null, "N/A"),
+ );
+ expect(result2).toBe("N/A");
+
+ const result3 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatNumber(undefined, "--"),
+ );
+ expect(result3).toBe("--");
+});
+
+test("formats currency values correctly", async ({ mount }) => {
+ const component = await mount(SketchTimelineMessage, {});
+
+ // Test with different precisions
+ const result1 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatCurrency(10.12345, "$0.00", true),
+ );
+ expect(result1).toBe("$10.1235"); // message level (4 decimals)
+
+ const result2 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatCurrency(10.12345, "$0.00", false),
+ );
+ expect(result2).toBe("$10.12"); // total level (2 decimals)
+
+ const result3 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatCurrency(null, "N/A"),
+ );
+ expect(result3).toBe("N/A");
+
+ const result4 = await component.evaluate((el: SketchTimelineMessage) =>
+ el.formatCurrency(undefined, "--"),
+ );
+ expect(result4).toBe("--");
+});
diff --git a/webui/src/web-components/sketch-timeline-message.ts b/webui/src/web-components/sketch-timeline-message.ts
new file mode 100644
index 0000000..36f1640
--- /dev/null
+++ b/webui/src/web-components/sketch-timeline-message.ts
@@ -0,0 +1,765 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { customElement, property } from "lit/decorators.js";
+import { AgentMessage } from "../types";
+import { marked, MarkedOptions } from "marked";
+import "./sketch-tool-calls";
+@customElement("sketch-timeline-message")
+export class SketchTimelineMessage extends LitElement {
+ @property()
+ message: AgentMessage;
+
+ @property()
+ previousMessage: AgentMessage;
+
+ @property()
+ open: boolean = false;
+
+ // 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`
+ .message {
+ position: relative;
+ margin-bottom: 5px;
+ padding-left: 30px;
+ }
+
+ .message-icon {
+ position: absolute;
+ left: 10px;
+ top: 0;
+ transform: translateX(-50%);
+ width: 16px;
+ height: 16px;
+ border-radius: 3px;
+ text-align: center;
+ line-height: 16px;
+ color: #fff;
+ font-size: 10px;
+ }
+
+ .message-content {
+ position: relative;
+ padding: 5px 10px;
+ background: #fff;
+ border-radius: 3px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ border-left: 3px solid transparent;
+ }
+
+ /* Copy button styles */
+ .message-text-container,
+ .tool-result-container {
+ position: relative;
+ }
+
+ .message-actions {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ z-index: 10;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ .message-text-container:hover .message-actions,
+ .tool-result-container:hover .message-actions {
+ opacity: 1;
+ }
+
+ .copy-button {
+ background-color: rgba(255, 255, 255, 0.9);
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ color: #555;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px 8px;
+ transition: all 0.2s ease;
+ }
+
+ .copy-button:hover {
+ background-color: #f0f0f0;
+ color: #333;
+ }
+
+ /* Removed arrow decoration for a more compact look */
+
+ .message-header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-bottom: 3px;
+ font-size: 12px;
+ }
+
+ .message-timestamp {
+ font-size: 10px;
+ color: #888;
+ font-style: italic;
+ margin-left: 3px;
+ }
+
+ .message-usage {
+ font-size: 10px;
+ color: #888;
+ margin-left: 3px;
+ }
+
+ .conversation-id {
+ font-family: monospace;
+ font-size: 12px;
+ padding: 2px 4px;
+ background-color: #f0f0f0;
+ border-radius: 3px;
+ margin-left: auto;
+ }
+
+ .parent-info {
+ font-size: 11px;
+ opacity: 0.8;
+ }
+
+ .subconversation {
+ border-left: 2px solid transparent;
+ padding-left: 5px;
+ margin-left: 20px;
+ transition: margin-left 0.3s ease;
+ }
+
+ .message-text {
+ overflow-x: auto;
+ margin-bottom: 3px;
+ font-family: monospace;
+ padding: 3px 5px;
+ background: rgb(236, 236, 236);
+ border-radius: 6px;
+ user-select: text;
+ cursor: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ font-size: 13px;
+ line-height: 1.3;
+ }
+
+ .tool-details {
+ margin-top: 3px;
+ padding-top: 3px;
+ border-top: 1px dashed #e0e0e0;
+ font-size: 12px;
+ }
+
+ .tool-name {
+ font-size: 12px;
+ font-weight: bold;
+ margin-bottom: 2px;
+ background: #f0f0f0;
+ padding: 2px 4px;
+ border-radius: 2px;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ }
+
+ .tool-input,
+ .tool-result {
+ margin-top: 2px;
+ padding: 3px 5px;
+ background: #f7f7f7;
+ border-radius: 2px;
+ font-family: monospace;
+ font-size: 12px;
+ overflow-x: auto;
+ white-space: pre;
+ line-height: 1.3;
+ user-select: text;
+ cursor: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ }
+
+ .tool-result {
+ max-height: 300px;
+ overflow-y: auto;
+ }
+
+ .usage-info {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px dashed #e0e0e0;
+ font-size: 12px;
+ color: #666;
+ }
+
+ /* Custom styles for IRC-like experience */
+ .user .message-content {
+ border-left-color: #2196f3;
+ }
+
+ .agent .message-content {
+ border-left-color: #4caf50;
+ }
+
+ .tool .message-content {
+ border-left-color: #ff9800;
+ }
+
+ .error .message-content {
+ border-left-color: #f44336;
+ }
+
+ /* Make message type display bold but without the IRC-style markers */
+ .message-type {
+ font-weight: bold;
+ }
+
+ /* Commit message styling */
+ .message.commit {
+ background-color: #f0f7ff;
+ border-left: 4px solid #0366d6;
+ }
+
+ .commits-container {
+ margin-top: 10px;
+ padding: 5px;
+ }
+
+ .commits-header {
+ font-weight: bold;
+ margin-bottom: 5px;
+ color: #24292e;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .commit-boxes-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .commit-box {
+ border: 1px solid #d1d5da;
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: #ffffff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ max-width: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .commit-preview {
+ padding: 8px 12px;
+ font-family: monospace;
+ background-color: #f6f8fa;
+ border-bottom: 1px dashed #d1d5da;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+
+ .commit-preview:hover {
+ background-color: #eef2f6;
+ }
+
+ .commit-hash {
+ color: #0366d6;
+ font-weight: bold;
+ cursor: pointer;
+ margin-right: 8px;
+ text-decoration: none;
+ position: relative;
+ }
+
+ .commit-hash:hover {
+ text-decoration: underline;
+ }
+
+ .commit-hash:hover::after {
+ content: "📋";
+ font-size: 10px;
+ position: absolute;
+ top: -8px;
+ right: -12px;
+ opacity: 0.7;
+ }
+
+ .branch-wrapper {
+ margin-right: 8px;
+ color: #555;
+ }
+
+ .commit-branch {
+ color: #28a745;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ position: relative;
+ }
+
+ .commit-branch:hover {
+ text-decoration: underline;
+ }
+
+ .commit-branch:hover::after {
+ content: "📋";
+ font-size: 10px;
+ position: absolute;
+ top: -8px;
+ right: -12px;
+ opacity: 0.7;
+ }
+
+ .commit-preview {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+
+ .commit-details {
+ padding: 8px 12px;
+ max-height: 200px;
+ overflow-y: auto;
+ }
+
+ .commit-details pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ .commit-details.is-hidden {
+ display: none;
+ }
+
+ .pushed-branch {
+ color: #28a745;
+ font-weight: 500;
+ margin-left: 6px;
+ }
+
+ .commit-diff-button {
+ padding: 3px 6px;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ background-color: #f7f7f7;
+ color: #24292e;
+ font-size: 11px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ margin-left: auto;
+ }
+
+ .commit-diff-button:hover {
+ background-color: #e7e7e7;
+ border-color: #aaa;
+ }
+
+ /* Tool call cards */
+ .tool-call-cards-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ /* Message type styles */
+
+ .user .message-icon {
+ background-color: #2196f3;
+ }
+
+ .agent .message-icon {
+ background-color: #4caf50;
+ }
+
+ .tool .message-icon {
+ background-color: #ff9800;
+ }
+
+ .error .message-icon {
+ background-color: #f44336;
+ }
+
+ .end-of-turn {
+ margin-bottom: 15px;
+ }
+
+ .end-of-turn::after {
+ content: "End of Turn";
+ position: absolute;
+ left: 15px;
+ bottom: -10px;
+ transform: translateX(-50%);
+ font-size: 10px;
+ color: #666;
+ background: #f0f0f0;
+ padding: 1px 4px;
+ border-radius: 3px;
+ }
+
+ .markdown-content {
+ box-sizing: border-box;
+ min-width: 200px;
+ margin: 0 auto;
+ }
+
+ .markdown-content p {
+ margin-block-start: 0.5em;
+ margin-block-end: 0.5em;
+ }
+ `;
+
+ 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;
+ }
+ }
+
+ /**
+ * Format timestamp for display
+ */
+ formatTimestamp(
+ timestamp: string | number | Date | null | undefined,
+ defaultValue: string = "",
+ ): string {
+ if (!timestamp) return defaultValue;
+ try {
+ const date = new Date(timestamp);
+ if (isNaN(date.getTime())) return defaultValue;
+
+ // Format: Mar 13, 2025 09:53:25 AM
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: true,
+ });
+ } catch (e) {
+ return defaultValue;
+ }
+ }
+
+ formatNumber(
+ num: number | null | undefined,
+ defaultValue: string = "0",
+ ): string {
+ if (num === undefined || num === null) return defaultValue;
+ try {
+ return num.toLocaleString();
+ } catch (e) {
+ return String(num);
+ }
+ }
+ formatCurrency(
+ num: number | string | null | undefined,
+ defaultValue: string = "$0.00",
+ isMessageLevel: boolean = false,
+ ): string {
+ if (num === undefined || num === null) return defaultValue;
+ try {
+ // Use 4 decimal places for message-level costs, 2 for totals
+ const decimalPlaces = isMessageLevel ? 4 : 2;
+ return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
+ } catch (e) {
+ return defaultValue;
+ }
+ }
+
+ showCommit(commitHash: string) {
+ this.dispatchEvent(
+ new CustomEvent("show-commit-diff", {
+ bubbles: true,
+ composed: true,
+ detail: { commitHash },
+ }),
+ );
+ }
+
+ copyToClipboard(text: string, event: Event) {
+ const element = event.currentTarget as HTMLElement;
+ const rect = element.getBoundingClientRect();
+
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ this.showFloatingMessage("Copied!", rect, "success");
+ })
+ .catch((err) => {
+ console.error("Failed to copy text: ", err);
+ this.showFloatingMessage("Failed to copy!", rect, "error");
+ });
+ }
+
+ showFloatingMessage(
+ message: string,
+ targetRect: DOMRect,
+ type: "success" | "error",
+ ) {
+ // Create floating message element
+ const floatingMsg = document.createElement("div");
+ floatingMsg.textContent = message;
+ floatingMsg.className = `floating-message ${type}`;
+
+ // Position it near the clicked element
+ // Position just above the element
+ const top = targetRect.top - 30;
+ const left = targetRect.left + targetRect.width / 2 - 40;
+
+ floatingMsg.style.position = "fixed";
+ floatingMsg.style.top = `${top}px`;
+ floatingMsg.style.left = `${left}px`;
+ floatingMsg.style.zIndex = "9999";
+
+ // Add to document body
+ document.body.appendChild(floatingMsg);
+
+ // Animate in
+ floatingMsg.style.opacity = "0";
+ floatingMsg.style.transform = "translateY(10px)";
+
+ setTimeout(() => {
+ floatingMsg.style.opacity = "1";
+ floatingMsg.style.transform = "translateY(0)";
+ }, 10);
+
+ // Remove after animation
+ setTimeout(() => {
+ floatingMsg.style.opacity = "0";
+ floatingMsg.style.transform = "translateY(-10px)";
+
+ setTimeout(() => {
+ document.body.removeChild(floatingMsg);
+ }, 300);
+ }, 1500);
+ }
+
+ render() {
+ return html`
+ <div
+ class="message ${this.message?.type} ${this.message?.end_of_turn
+ ? "end-of-turn"
+ : ""}"
+ >
+ ${this.previousMessage?.type != this.message?.type
+ ? html`<div class="message-icon">
+ ${this.message?.type.toUpperCase()[0]}
+ </div>`
+ : ""}
+ <div class="message-content">
+ <div class="message-header">
+ <span class="message-type">${this.message?.type}</span>
+ <span class="message-timestamp"
+ >${this.formatTimestamp(this.message?.timestamp)}
+ ${this.message?.elapsed
+ ? html`(${(this.message?.elapsed / 1e9).toFixed(2)}s)`
+ : ""}</span
+ >
+ ${this.message?.usage
+ ? html` <span class="message-usage">
+ <span title="Input tokens"
+ >In: ${this.message?.usage?.input_tokens}</span
+ >
+ ${this.message?.usage?.cache_read_input_tokens > 0
+ ? html`<span title="Cache tokens"
+ >[Cache:
+ ${this.formatNumber(
+ this.message?.usage?.cache_read_input_tokens,
+ )}]</span
+ >`
+ : ""}
+ <span title="Output tokens"
+ >Out: ${this.message?.usage?.output_tokens}</span
+ >
+ <span title="Message cost"
+ >(${this.formatCurrency(
+ this.message?.usage?.cost_usd,
+ )})</span
+ >
+ </span>`
+ : ""}
+ </div>
+ <div class="message-text-container">
+ <div class="message-actions">
+ ${copyButton(this.message?.content)}
+ </div>
+ ${this.message?.content
+ ? html`
+ <div class="message-text markdown-content">
+ ${unsafeHTML(this.renderMarkdown(this.message?.content))}
+ </div>
+ `
+ : ""}
+ </div>
+ <sketch-tool-calls
+ .toolCalls=${this.message?.tool_calls}
+ .open=${this.open}
+ ></sketch-tool-calls>
+ ${this.message?.commits
+ ? html`
+ <div class="commits-container">
+ <div class="commits-header">
+ ${this.message.commits.length} new
+ commit${this.message.commits.length > 1 ? "s" : ""} detected
+ </div>
+ ${this.message.commits.map((commit) => {
+ return html`
+ <div class="commit-boxes-row">
+ <div class="commit-box">
+ <div class="commit-preview">
+ <span
+ class="commit-hash"
+ title="Click to copy: ${commit.hash}"
+ @click=${(e) =>
+ this.copyToClipboard(
+ commit.hash.substring(0, 8),
+ e,
+ )}
+ >
+ ${commit.hash.substring(0, 8)}
+ </span>
+ ${commit.pushed_branch
+ ? html`
+ <span class="branch-wrapper">
+ (<span
+ class="commit-branch pushed-branch"
+ title="Click to copy: ${commit.pushed_branch}"
+ @click=${(e) =>
+ this.copyToClipboard(
+ commit.pushed_branch,
+ e,
+ )}
+ >${commit.pushed_branch}</span
+ >)
+ </span>
+ `
+ : ``}
+ <span class="commit-subject"
+ >${commit.subject}</span
+ >
+ <button
+ class="commit-diff-button"
+ @click=${() => this.showCommit(commit.hash)}
+ >
+ View Diff
+ </button>
+ </div>
+ <div class="commit-details is-hidden">
+ <pre>${commit.body}</pre>
+ </div>
+ </div>
+ </div>
+ `;
+ })}
+ </div>
+ `
+ : ""}
+ </div>
+ </div>
+ `;
+ }
+}
+
+function copyButton(textToCopy: string) {
+ // Add click event listener to handle copying
+ const buttonClass = "copy-button";
+ const buttonContent = "Copy";
+ const successContent = "Copied!";
+ const failureContent = "Failed";
+
+ const ret = html`<button
+ class="${buttonClass}"
+ title="Copy to clipboard"
+ @click=${(e: Event) => {
+ e.stopPropagation();
+ const copyButton = e.currentTarget as HTMLButtonElement;
+ navigator.clipboard
+ .writeText(textToCopy)
+ .then(() => {
+ copyButton.textContent = successContent;
+ setTimeout(() => {
+ copyButton.textContent = buttonContent;
+ }, 2000);
+ })
+ .catch((err) => {
+ console.error("Failed to copy text: ", err);
+ copyButton.textContent = failureContent;
+ setTimeout(() => {
+ copyButton.textContent = buttonContent;
+ }, 2000);
+ });
+ }}
+ >
+ ${buttonContent}
+ </button>`;
+
+ return ret;
+}
+
+// Create global styles for floating messages
+const floatingMessageStyles = document.createElement("style");
+floatingMessageStyles.textContent = `
+ .floating-message {
+ background-color: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 5px 10px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-family: system-ui, sans-serif;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+ pointer-events: none;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ }
+
+ .floating-message.success {
+ background-color: rgba(40, 167, 69, 0.9);
+ }
+
+ .floating-message.error {
+ background-color: rgba(220, 53, 69, 0.9);
+ }
+`;
+document.head.appendChild(floatingMessageStyles);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-timeline-message": SketchTimelineMessage;
+ }
+}
diff --git a/webui/src/web-components/sketch-timeline.ts b/webui/src/web-components/sketch-timeline.ts
new file mode 100644
index 0000000..e2c8ee7
--- /dev/null
+++ b/webui/src/web-components/sketch-timeline.ts
@@ -0,0 +1,224 @@
+import { css, html, LitElement } from "lit";
+import { PropertyValues } from "lit";
+import { repeat } from "lit/directives/repeat.js";
+import { customElement, property, state } from "lit/decorators.js";
+import { AgentMessage } from "../types";
+import "./sketch-timeline-message";
+
+@customElement("sketch-timeline")
+export class SketchTimeline extends LitElement {
+ @property({ attribute: false })
+ messages: AgentMessage[] = [];
+
+ // Track if we should scroll to the bottom
+ @state()
+ private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
+
+ @property({ attribute: false })
+ scrollContainer: HTMLElement;
+
+ static styles = css`
+ /* Hide views initially to prevent flash of content */
+ .timeline-container .timeline,
+ .timeline-container .diff-view,
+ .timeline-container .chart-view,
+ .timeline-container .terminal-view {
+ visibility: hidden;
+ }
+
+ /* Will be set by JavaScript once we know which view to display */
+ .timeline-container.view-initialized .timeline,
+ .timeline-container.view-initialized .diff-view,
+ .timeline-container.view-initialized .chart-view,
+ .timeline-container.view-initialized .terminal-view {
+ visibility: visible;
+ }
+
+ .timeline-container {
+ width: 100%;
+ position: relative;
+ }
+
+ /* Timeline styles that should remain unchanged */
+ .timeline {
+ position: relative;
+ margin: 10px 0;
+ scroll-behavior: smooth;
+ }
+
+ .timeline::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 15px;
+ width: 2px;
+ background: #e0e0e0;
+ border-radius: 1px;
+ }
+
+ /* Hide the timeline vertical line when there are no messages */
+ .timeline.empty::before {
+ display: none;
+ }
+
+ #scroll-container {
+ overflow: auto;
+ padding-left: 1em;
+ }
+ #jump-to-latest {
+ display: none;
+ position: fixed;
+ bottom: 100px;
+ right: 0;
+ background: rgb(33, 150, 243);
+ color: white;
+ border-radius: 8px;
+ padding: 0.5em;
+ margin: 0.5em;
+ font-size: x-large;
+ opacity: 0.5;
+ cursor: pointer;
+ }
+ #jump-to-latest:hover {
+ opacity: 1;
+ }
+ #jump-to-latest.floating {
+ display: block;
+ }
+ `;
+
+ constructor() {
+ super();
+
+ // Binding methods
+ this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
+ this._handleScroll = this._handleScroll.bind(this);
+ }
+
+ /**
+ * Scroll to the bottom of the timeline
+ */
+ private scrollToBottom(): void {
+ this.scrollContainer?.scrollTo({
+ top: this.scrollContainer?.scrollHeight,
+ behavior: "smooth",
+ });
+ }
+
+ /**
+ * Called after the component's properties have been updated
+ */
+ updated(changedProperties: PropertyValues): void {
+ // If messages have changed, scroll to bottom if needed
+ if (changedProperties.has("messages") && this.messages.length > 0) {
+ if (this.scrollingState == "pinToLatest") {
+ setTimeout(() => this.scrollToBottom(), 50);
+ }
+ }
+ if (changedProperties.has("scrollContainer")) {
+ this.scrollContainer?.addEventListener("scroll", this._handleScroll);
+ }
+ }
+
+ /**
+ * Handle showCommitDiff event
+ */
+ private _handleShowCommitDiff(event: CustomEvent) {
+ const { commitHash } = event.detail;
+ if (commitHash) {
+ // Bubble up the event to the app shell
+ const newEvent = new CustomEvent("show-commit-diff", {
+ detail: { commitHash },
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(newEvent);
+ }
+ }
+
+ private _handleScroll(event) {
+ const isAtBottom =
+ Math.abs(
+ this.scrollContainer.scrollHeight -
+ this.scrollContainer.clientHeight -
+ this.scrollContainer.scrollTop,
+ ) <= 1;
+ if (isAtBottom) {
+ this.scrollingState = "pinToLatest";
+ } else {
+ // TODO: does scroll direction matter here?
+ this.scrollingState = "floating";
+ }
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ connectedCallback() {
+ super.connectedCallback();
+
+ // Listen for showCommitDiff events from the renderer
+ document.addEventListener(
+ "showCommitDiff",
+ this._handleShowCommitDiff as EventListener,
+ );
+ this.scrollContainer?.addEventListener("scroll", this._handleScroll);
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ // Remove event listeners
+ document.removeEventListener(
+ "showCommitDiff",
+ this._handleShowCommitDiff as EventListener,
+ );
+
+ this.scrollContainer?.removeEventListener("scroll", this._handleScroll);
+ }
+
+ // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
+ // that we only re-render <sketch-message> elements that we need to re-render.
+ messageKey(message: AgentMessage): string {
+ // If the message has tool calls, and any of the tool_calls get a response, we need to
+ // re-render that message.
+ const toolCallResponses = message.tool_calls
+ ?.filter((tc) => tc.result_message)
+ .map((tc) => tc.tool_call_id)
+ .join("-");
+ return `message-${message.idx}-${toolCallResponses}`;
+ }
+
+ render() {
+ return html`
+ <div id="scroll-container">
+ <div class="timeline-container">
+ ${repeat(this.messages, this.messageKey, (message, index) => {
+ let previousMessage: AgentMessage;
+ if (index > 0) {
+ previousMessage = this.messages[index - 1];
+ }
+ return html`<sketch-timeline-message
+ .message=${message}
+ .previousMessage=${previousMessage}
+ .open=${index == this.messages.length - 1}
+ ></sketch-timeline-message>`;
+ })}
+ </div>
+ </div>
+ <div
+ id="jump-to-latest"
+ class="${this.scrollingState}"
+ @click=${this.scrollToBottom}
+ >
+ ⇩
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-timeline": SketchTimeline;
+ }
+}
diff --git a/webui/src/web-components/sketch-tool-calls.ts b/webui/src/web-components/sketch-tool-calls.ts
new file mode 100644
index 0000000..3f036c2
--- /dev/null
+++ b/webui/src/web-components/sketch-tool-calls.ts
@@ -0,0 +1,148 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { repeat } from "lit/directives/repeat.js";
+import { ToolCall } from "../types";
+import "./sketch-tool-card";
+
+@customElement("sketch-tool-calls")
+export class SketchToolCalls extends LitElement {
+ @property()
+ toolCalls: ToolCall[] = [];
+
+ @property()
+ open: boolean = false;
+
+ static styles = css`
+ /* Tool calls container styles */
+ .tool-calls-container {
+ /* Container for all tool calls */
+ }
+
+ /* Header for tool calls section */
+ .tool-calls-header {
+ /* Empty header - just small spacing */
+ }
+
+ /* Card container */
+ .tool-call-card {
+ display: flex;
+ flex-direction: column;
+ background-color: white;
+ overflow: hidden;
+ cursor: pointer;
+ }
+
+ /* Status indicators for tool calls */
+ .tool-call-status {
+ margin-right: 4px;
+ text-align: center;
+ }
+
+ .tool-call-status.spinner {
+ animation: spin 1s infinite linear;
+ display: inline-block;
+ width: 1em;
+ }
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ cardForToolCall(toolCall: ToolCall, open: boolean) {
+ switch (toolCall.name) {
+ case "bash":
+ return html`<sketch-tool-card-bash
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-bash>`;
+ case "codereview":
+ return html`<sketch-tool-card-codereview
+ .open=${open}
+ .toolCall=${toolCall}
+ ></sketch-tool-card-codereview>`;
+ case "done":
+ 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>`;
+ }
+
+ // toolUseKey return value should change, if the toolCall gets a response.
+ toolUseKey(toolCall: ToolCall): string {
+ console.log(
+ "toolUseKey",
+ toolCall.tool_call_id,
+ toolCall.result_message?.idx,
+ );
+ if (!toolCall.result_message) {
+ return toolCall.tool_call_id;
+ }
+ return `${toolCall.tool_call_id}-${toolCall.result_message.idx}`;
+ }
+
+ render() {
+ return html`<div class="tool-calls-container">
+ <div class="tool-calls-header"></div>
+ <div class="tool-call-cards-container">
+ ${this.toolCalls
+ ? repeat(this.toolCalls, this.toolUseKey, (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 && this.open)}
+ </div>`;
+ })
+ : ""}
+ </div>
+ </div>`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-tool-calls": SketchToolCalls;
+ }
+}
diff --git a/webui/src/web-components/sketch-tool-card.ts b/webui/src/web-components/sketch-tool-card.ts
new file mode 100644
index 0000000..dbb09ae
--- /dev/null
+++ b/webui/src/web-components/sketch-tool-card.ts
@@ -0,0 +1,630 @@
+import { css, html, LitElement } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { customElement, property } from "lit/decorators.js";
+import { ToolCall } from "../types";
+import { marked, MarkedOptions } from "marked";
+
+function 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;
+ }
+}
+
+@customElement("sketch-tool-card")
+export class SketchToolCard extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css`
+ .tool-call {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+ }
+
+ .tool-call-status {
+ margin-right: 4px;
+ text-align: center;
+ }
+
+ .tool-call-status.spinner {
+ animation: spin 1s infinite linear;
+ display: inline-block;
+ width: 1em;
+ }
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+
+ .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;
+ }
+
+ .codereview-OK {
+ color: green;
+ }
+
+ details {
+ border-radius: 4px;
+ padding: 0.25em;
+ margin: 0.25em;
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ }
+
+ details summary {
+ list-style: none;
+ &::before {
+ cursor: hand;
+ font-family: monospace;
+ content: "+";
+ color: white;
+ background-color: darkgray;
+ border-radius: 1em;
+ padding-left: 0.5em;
+ margin: 0.25em;
+ min-width: 1em;
+ }
+ [open] &::before {
+ content: "-";
+ }
+ }
+
+ details summary:hover {
+ list-style: none;
+ &::before {
+ background-color: gray;
+ }
+ }
+ summary {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ align-items: baseline;
+ }
+
+ summary .tool-name {
+ font-family: monospace;
+ color: white;
+ background: rgb(124 145 160);
+ border-radius: 4px;
+ padding: 0.25em;
+ margin: 0.25em;
+ white-space: pre;
+ }
+
+ .summary-text {
+ padding: 0.25em;
+ display: flex;
+ max-width: 50%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ details[open] .summary-text {
+ /*display: none;*/
+ }
+
+ .tool-error-message {
+ font-style: italic;
+ color: #aa0909;
+ }
+
+ .elapsed {
+ font-size: 10px;
+ color: #888;
+ font-style: italic;
+ margin-left: 3px;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ _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);
+ }
+ };
+
+ render() {
+ const toolCallStatus = this.toolCall?.result_message
+ ? this.toolCall?.result_message.tool_error
+ ? html`❌
+ <span class="tool-error-message"
+ >${this.toolCall?.result_message.tool_result}</span
+ >`
+ : ""
+ : "⏳";
+
+ const cancelButton = this.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(this.toolCall?.tool_call_id, button);
+ }}
+ >
+ Cancel
+ </button>`;
+
+ const status = html`<span
+ class="tool-call-status ${this.toolCall?.result_message ? "" : "spinner"}"
+ >${toolCallStatus}</span
+ >`;
+
+ const elapsed = html`${this.toolCall?.result_message?.elapsed
+ ? html`<span class="elapsed"
+ >${(this.toolCall?.result_message?.elapsed / 1e9).toFixed(2)}s
+ elapsed</span
+ >`
+ : ""}`;
+
+ const ret = html`<div class="tool-call">
+ <details ?open=${this.open}>
+ <summary>
+ <span class="tool-name">${this.toolCall?.name}</span>
+ <span class="summary-text"><slot name="summary"></slot></span>
+ ${status} ${cancelButton} ${elapsed}
+ </summary>
+ <slot name="input"></slot>
+ <slot name="result"></slot>
+ </details>
+ </div> `;
+ if (true) {
+ return ret;
+ }
+ }
+}
+
+@customElement("sketch-tool-card-bash")
+export class SketchToolCardBash extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css`
+ pre {
+ background: rgb(236, 236, 236);
+ color: black;
+ padding: 0.5em;
+ border-radius: 4px;
+ }
+ .summary-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: monospace;
+ }
+ .input {
+ display: flex;
+ }
+ .input pre {
+ width: 100%;
+ margin-bottom: 0;
+ border-radius: 4px 4px 0 0;
+ }
+ .result pre {
+ margin-top: 0;
+ color: #555;
+ border-radius: 0 0 4px 4px;
+ }
+ .background-badge {
+ display: inline-block;
+ background-color: #6200ea;
+ color: white;
+ font-size: 10px;
+ font-weight: bold;
+ padding: 2px 6px;
+ border-radius: 10px;
+ margin-left: 8px;
+ vertical-align: middle;
+ }
+ .command-wrapper {
+ display: flex;
+ align-items: center;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ render() {
+ const inputData = JSON.parse(this.toolCall?.input || "{}");
+ const isBackground = inputData?.background === true;
+ const backgroundIcon = isBackground ? "🔄 " : "";
+
+ return html`
+ <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+ <span slot="summary" class="summary-text">
+ <div class="command-wrapper">
+ 🖥️ ${backgroundIcon}${inputData?.command}
+ </div>
+ </span>
+ <div slot="input" class="input">
+ <pre>🖥️ ${backgroundIcon}${inputData?.command}</pre>
+ </div>
+ ${
+ this.toolCall?.result_message
+ ? html` ${this.toolCall?.result_message.tool_result
+ ? html`<div slot="result" class="result">
+ <pre class="tool-call-result">
+${this.toolCall?.result_message.tool_result}</pre
+ >
+ </div>`
+ : ""}`
+ : ""
+ }</div>
+ </sketch-tool-card>`;
+ }
+}
+
+@customElement("sketch-tool-card-codereview")
+export class SketchToolCardCodeReview extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css``;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+ render() {
+ return html` <sketch-tool-card
+ .open=${this.open}
+ .toolCall=${this.toolCall}
+ >
+ <span slot="summary" class="summary-text">
+ ${this.toolCall?.result_message?.tool_result == "OK" ? "✔️" : "⛔"}
+ </span>
+ <div slot="result">
+ <pre>${this.toolCall?.result_message?.tool_result}</pre>
+ </div>
+ </sketch-tool-card>`;
+ }
+}
+
+@customElement("sketch-tool-card-done")
+export class SketchToolCardDone extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css``;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ render() {
+ const doneInput = JSON.parse(this.toolCall.input);
+ return html` <sketch-tool-card
+ .open=${this.open}
+ .toolCall=${this.toolCall}
+ >
+ <span slot="summary" class="summary-text"> </span>
+ <div slot="result">
+ ${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>
+ </sketch-tool-card>`;
+ }
+}
+
+@customElement("sketch-tool-card-patch")
+export class SketchToolCardPatch extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css`
+ .summary-text {
+ color: #555;
+ font-family: monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ border-radius: 3px;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ render() {
+ const patchInput = JSON.parse(this.toolCall?.input);
+ return html` <sketch-tool-card
+ .open=${this.open}
+ .toolCall=${this.toolCall}
+ >
+ <span slot="summary" class="summary-text">
+ ${patchInput?.path}: ${patchInput.patches.length}
+ edit${patchInput.patches.length > 1 ? "s" : ""}
+ </span>
+ <div slot="input">
+ ${patchInput.patches.map((patch) => {
+ return html` Patch operation: <b>${patch.operation}</b>
+ <pre>${patch.newText}</pre>`;
+ })}
+ </div>
+ <div slot="result">
+ <pre>${this.toolCall?.result_message?.tool_result}</pre>
+ </div>
+ </sketch-tool-card>`;
+ }
+}
+
+@customElement("sketch-tool-card-think")
+export class SketchToolCardThink extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css`
+ .thought-bubble {
+ overflow-x: auto;
+ margin-bottom: 3px;
+ font-family: monospace;
+ padding: 3px 5px;
+ background: rgb(236, 236, 236);
+ border-radius: 6px;
+ user-select: text;
+ cursor: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ font-size: 13px;
+ line-height: 1.3;
+ }
+ .summary-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-family: monospace;
+ max-width: 50%;
+ }
+ `;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ render() {
+ return html`
+ <sketch-tool-card .open=${this.open} .toolCall=${this.toolCall}>
+ <span slot="summary" class="summary-text"
+ >${JSON.parse(this.toolCall?.input)?.thoughts}</span
+ >
+ <div slot="input" class="thought-bubble">
+ <div class="markdown-content">
+ ${unsafeHTML(
+ renderMarkdown(JSON.parse(this.toolCall?.input)?.thoughts),
+ )}
+ </div>
+ </div>
+ </sketch-tool-card>
+ `;
+ }
+}
+
+@customElement("sketch-tool-card-title")
+export class SketchToolCardTitle extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ static styles = css`
+ .summary-text {
+ font-style: italic;
+ }
+ `;
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ render() {
+ return html`
+ <span class="summary-text"
+ >I've set the title of this sketch to
+ <b>"${JSON.parse(this.toolCall?.input)?.title}"</b></span
+ >
+ `;
+ }
+}
+
+@customElement("sketch-tool-card-generic")
+export class SketchToolCardGeneric extends LitElement {
+ @property()
+ toolCall: ToolCall;
+
+ @property()
+ open: boolean;
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ }
+
+ render() {
+ return html` <sketch-tool-card
+ .open=${this.open}
+ .toolCall=${this.toolCall}
+ >
+ <span slot="summary" class="summary-text">${this.toolCall?.input}</span>
+ <div slot="input">
+ Input:
+ <pre>${this.toolCall?.input}</pre>
+ </div>
+ <div slot="result">
+ Result:
+ ${this.toolCall?.result_message
+ ? html` ${this.toolCall?.result_message.tool_result
+ ? html`<pre>${this.toolCall?.result_message.tool_result}</pre>`
+ : ""}`
+ : ""}
+ </div>
+ </sketch-tool-card>`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-tool-card": SketchToolCard;
+ "sketch-tool-card-generic": SketchToolCardGeneric;
+ "sketch-tool-card-bash": SketchToolCardBash;
+ "sketch-tool-card-codereview": SketchToolCardCodeReview;
+ "sketch-tool-card-done": SketchToolCardDone;
+ "sketch-tool-card-patch": SketchToolCardPatch;
+ "sketch-tool-card-think": SketchToolCardThink;
+ "sketch-tool-card-title": SketchToolCardTitle;
+ }
+}
diff --git a/webui/src/web-components/sketch-view-mode-select.test.ts b/webui/src/web-components/sketch-view-mode-select.test.ts
new file mode 100644
index 0000000..6db790b
--- /dev/null
+++ b/webui/src/web-components/sketch-view-mode-select.test.ts
@@ -0,0 +1,119 @@
+import { test, expect } from "@sand4rt/experimental-ct-web";
+import { SketchViewModeSelect } from "./sketch-view-mode-select";
+
+test("initializes with 'chat' as the default mode", async ({ mount }) => {
+ const component = await mount(SketchViewModeSelect, {});
+
+ // Check the activeMode property
+ const activeMode = await component.evaluate(
+ (el: SketchViewModeSelect) => el.activeMode,
+ );
+ expect(activeMode).toBe("chat");
+
+ // Check that the chat button has the active class
+ await expect(
+ component.locator("#showConversationButton.active"),
+ ).toBeVisible();
+});
+
+test("displays all four view mode buttons", async ({ mount }) => {
+ const component = await mount(SketchViewModeSelect, {});
+
+ // Count the number of buttons
+ const buttonCount = await component.locator(".emoji-button").count();
+ expect(buttonCount).toBe(4);
+
+ // Check that each button exists
+ await expect(component.locator("#showConversationButton")).toBeVisible();
+ await expect(component.locator("#showDiffButton")).toBeVisible();
+ await expect(component.locator("#showChartsButton")).toBeVisible();
+ await expect(component.locator("#showTerminalButton")).toBeVisible();
+
+ // Check the title attributes
+ expect(
+ await component.locator("#showConversationButton").getAttribute("title"),
+ ).toBe("Conversation View");
+ expect(await component.locator("#showDiffButton").getAttribute("title")).toBe(
+ "Diff View",
+ );
+ expect(
+ await component.locator("#showChartsButton").getAttribute("title"),
+ ).toBe("Charts View");
+ expect(
+ await component.locator("#showTerminalButton").getAttribute("title"),
+ ).toBe("Terminal View");
+});
+
+test("dispatches view-mode-select event when clicking a mode button", async ({
+ mount,
+}) => {
+ const component = await mount(SketchViewModeSelect, {});
+
+ // Set up promise to wait for the event
+ const eventPromise = component.evaluate((el) => {
+ return new Promise((resolve) => {
+ el.addEventListener(
+ "view-mode-select",
+ (event) => {
+ resolve((event as CustomEvent).detail);
+ },
+ { once: true },
+ );
+ });
+ });
+
+ // Click the diff button
+ await component.locator("#showDiffButton").click();
+
+ // Wait for the event and check its details
+ const detail: any = await eventPromise;
+ expect(detail.mode).toBe("diff");
+});
+
+test("updates the active mode when receiving update-active-mode event", async ({
+ mount,
+}) => {
+ const component = await mount(SketchViewModeSelect, {});
+
+ // Initially should be in chat mode
+ let activeMode = await component.evaluate(
+ (el: SketchViewModeSelect) => el.activeMode,
+ );
+ expect(activeMode).toBe("chat");
+
+ // Dispatch the update-active-mode event
+ await component.evaluate((el) => {
+ const updateEvent = new CustomEvent("update-active-mode", {
+ detail: { mode: "diff" },
+ bubbles: true,
+ });
+ el.dispatchEvent(updateEvent);
+ });
+
+ // Check that the mode was updated
+ activeMode = await component.evaluate(
+ (el: SketchViewModeSelect) => el.activeMode,
+ );
+ expect(activeMode).toBe("diff");
+
+ // Check that the diff button is now active
+ await expect(component.locator("#showDiffButton.active")).toBeVisible();
+});
+
+test("correctly marks the active button based on mode", async ({ mount }) => {
+ const component = await mount(SketchViewModeSelect, {
+ props: {
+ activeMode: "terminal",
+ },
+ });
+
+ // Terminal button should be active
+ await expect(component.locator("#showTerminalButton.active")).toBeVisible();
+
+ // Other buttons should not be active
+ await expect(
+ component.locator("#showConversationButton.active"),
+ ).not.toBeVisible();
+ await expect(component.locator("#showDiffButton.active")).not.toBeVisible();
+ await expect(component.locator("#showChartsButton.active")).not.toBeVisible();
+});
diff --git a/webui/src/web-components/sketch-view-mode-select.ts b/webui/src/web-components/sketch-view-mode-select.ts
new file mode 100644
index 0000000..52f8a4e
--- /dev/null
+++ b/webui/src/web-components/sketch-view-mode-select.ts
@@ -0,0 +1,146 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import "./sketch-container-status";
+
+@customElement("sketch-view-mode-select")
+export class SketchViewModeSelect extends LitElement {
+ // Current active mode
+ @property()
+ activeMode: "chat" | "diff" | "charts" | "terminal" = "chat";
+ // Header bar: view mode buttons
+
+ static styles = css`
+ /* View Mode Button Styles */
+ .view-mode-buttons {
+ display: flex;
+ gap: 8px;
+ margin-right: 10px;
+ }
+
+ .emoji-button {
+ font-size: 18px;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ padding: 0;
+ line-height: 1;
+ }
+
+ .emoji-button:hover {
+ background-color: #f0f0f0;
+ transform: translateY(-2px);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+
+ .emoji-button.active {
+ background-color: #e6f7ff;
+ border-color: #1890ff;
+ color: #1890ff;
+ }
+ `;
+
+ constructor() {
+ super();
+
+ // Binding methods
+ this._handleViewModeClick = this._handleViewModeClick.bind(this);
+ this._handleUpdateActiveMode = this._handleUpdateActiveMode.bind(this);
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ connectedCallback() {
+ super.connectedCallback();
+
+ // Listen for update-active-mode events
+ this.addEventListener(
+ "update-active-mode",
+ this._handleUpdateActiveMode as EventListener,
+ );
+ }
+
+ /**
+ * Handle view mode button clicks
+ */
+ private _handleViewModeClick(mode: "chat" | "diff" | "charts" | "terminal") {
+ // Dispatch a custom event to notify the app shell to change the view
+ const event = new CustomEvent("view-mode-select", {
+ detail: { mode },
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(event);
+ }
+
+ /**
+ * Handle updates to the active mode
+ */
+ private _handleUpdateActiveMode(event: CustomEvent) {
+ const { mode } = event.detail;
+ if (mode) {
+ this.activeMode = mode;
+ }
+ }
+
+ // See https://lit.dev/docs/components/lifecycle/
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ // Remove event listeners
+ this.removeEventListener(
+ "update-active-mode",
+ this._handleUpdateActiveMode as EventListener,
+ );
+ }
+
+ render() {
+ return html`
+ <div class="view-mode-buttons">
+ <button
+ id="showConversationButton"
+ class="emoji-button ${this.activeMode === "chat" ? "active" : ""}"
+ title="Conversation View"
+ @click=${() => this._handleViewModeClick("chat")}
+ >
+ 💬
+ </button>
+ <button
+ id="showDiffButton"
+ class="emoji-button ${this.activeMode === "diff" ? "active" : ""}"
+ title="Diff View"
+ @click=${() => this._handleViewModeClick("diff")}
+ >
+ ±
+ </button>
+ <button
+ id="showChartsButton"
+ class="emoji-button ${this.activeMode === "charts" ? "active" : ""}"
+ title="Charts View"
+ @click=${() => this._handleViewModeClick("charts")}
+ >
+ 📈
+ </button>
+ <button
+ id="showTerminalButton"
+ class="emoji-button ${this.activeMode === "terminal" ? "active" : ""}"
+ title="Terminal View"
+ @click=${() => this._handleViewModeClick("terminal")}
+ >
+ 💻
+ </button>
+ </div>
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "sketch-view-mode-select": SketchViewModeSelect;
+ }
+}
diff --git a/webui/src/web-components/vega-embed.ts b/webui/src/web-components/vega-embed.ts
new file mode 100644
index 0000000..04f0087
--- /dev/null
+++ b/webui/src/web-components/vega-embed.ts
@@ -0,0 +1,86 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import vegaEmbed from "vega-embed";
+import { VisualizationSpec } from "vega-embed";
+
+/**
+ * A web component wrapper for vega-embed.
+ * Renders Vega and Vega-Lite visualizations.
+ *
+ * Usage:
+ * <vega-embed .spec="${yourVegaLiteSpec}"></vega-embed>
+ */
+@customElement("vega-embed")
+export class VegaEmbed extends LitElement {
+ /**
+ * The Vega or Vega-Lite specification to render
+ */
+ @property({ type: Object })
+ spec?: VisualizationSpec;
+
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ #vega-container {
+ width: 100%;
+ height: 100%;
+ min-height: 200px;
+ }
+ `;
+
+ @query("#vega-container")
+ protected container?: HTMLElement;
+
+ protected firstUpdated() {
+ this.renderVegaVisualization();
+ }
+
+ protected updated() {
+ this.renderVegaVisualization();
+ }
+
+ /**
+ * Renders the Vega/Vega-Lite visualization using vega-embed
+ */
+ private async renderVegaVisualization() {
+ if (!this.spec) {
+ return;
+ }
+
+ if (!this.container) {
+ return;
+ }
+
+ try {
+ // Clear previous visualization if any
+ this.container.innerHTML = "";
+
+ // Render new visualization
+ await vegaEmbed(this.container, this.spec, {
+ actions: true,
+ renderer: "svg",
+ });
+ } catch (error) {
+ console.error("Error rendering Vega visualization:", error);
+ this.container.innerHTML = `<div style="color: red; padding: 10px;">
+ Error rendering visualization: ${
+ error instanceof Error ? error.message : String(error)
+ }
+ </div>`;
+ }
+ }
+
+ render() {
+ return html`<div id="vega-container"></div> `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "vega-embed": VegaEmbed;
+ }
+}