Stream tool calls in place with a progress bar

Tool calls arrive as tool_call -> tool_call_update sharing a toolCallId. The webview now keys rows by that id and mutates one row in place instead of appending a new bubble per update (the model-download flow previously stacked "Downloading … 0%", "… 50%", "failed" as separate bubbles). - media/main.js: track tool rows by id; parse "(N%)" into a progress bar; preserve the prior label when an update omits the title; reset on clear. - media/main.css: progress track/bar + completed/failed status accents. - chatView.ts: pass title as possibly-undefined so updates don't clobber the original label. - test/webview.mjs: headless DOM test replaying the download sequence, asserting 4 updates collapse to 1 row with the right label/percent. Wired into CI and `pnpm test`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EUvyohLND97Xxew23jN2d

paydii committed Jun 25, 2026 at 21:19 UTC 727f0c375f8f8d17f0ad633fd681de098795d78a
6 files changed +232 -5
.github/workflows/ci.yml
+3
index e2a1f2c..11d2e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,6 @@ jobs: - name: Smoke test (ACP round-trip) run: pnpm run test:smoke + + - name: Webview test (tool-call rendering) + run: pnpm run test:webview
media/main.css
+30
index 348e8b6..bff98eb 100644 --- a/media/main.css +++ b/media/main.css @@ -94,6 +94,36 @@ body { font-family: var(--vscode-editor-font-family, monospace); font-size: 0.9em; border: 1px solid var(--vscode-panel-border); + min-width: 180px; +} + +.tool-label { + white-space: pre-wrap; + word-break: break-word; +} + +.tool-progress { + margin-top: 6px; + height: 4px; + border-radius: 2px; + background-color: var(--vscode-input-background); + overflow: hidden; +} + +.tool-progress-bar { + height: 100%; + width: 0%; + background-color: var(--vscode-progressBar-background); + transition: width 0.2s ease; +} + +.message-tool.tool-completed .bubble { + border-color: var(--vscode-testing-iconPassed, #89d185); +} + +.message-tool.tool-failed .bubble { + border-color: var(--vscode-inputValidation-errorBorder); + color: var(--vscode-foreground); } .message-error .bubble {
media/main.js
+71 -4
index cca2e63..9b71e4e 100644 --- a/media/main.js +++ b/media/main.js @@ -13,6 +13,10 @@ let currentAssistant = null; let busy = false; + // Tool calls arrive as tool_call -> tool_call_update sharing one toolCallId. + // Track each row by id so updates mutate it in place instead of stacking. + const toolEls = new Map(); + function scrollToBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; } @@ -41,6 +45,70 @@ currentAssistant = null; } + function parsePercent(title) { + const m = /\((\d+)%\)/.exec(title || ""); + if (!m) { + return null; + } + const n = parseInt(m[1], 10); + return isNaN(n) ? null : Math.max(0, Math.min(100, n)); + } + + function stripPercent(title) { + return (title || "").replace(/\s*\(\d+%\)/, "").trim(); + } + + // Render (or update in place) a tool-call row keyed by toolCallId. A title + // like "Downloading … (42%)" is shown as a label plus a progress bar. + function renderTool(message) { + const key = message.toolCallId || null; + let entry = key ? toolEls.get(key) : null; + + if (!entry) { + const el = document.createElement("div"); + el.className = "message message-tool"; + const bubble = document.createElement("div"); + bubble.className = "bubble"; + const labelEl = document.createElement("div"); + labelEl.className = "tool-label"; + const progress = document.createElement("div"); + progress.className = "tool-progress"; + progress.hidden = true; + const bar = document.createElement("div"); + bar.className = "tool-progress-bar"; + progress.appendChild(bar); + bubble.appendChild(labelEl); + bubble.appendChild(progress); + el.appendChild(bubble); + messagesEl.appendChild(el); + entry = { el, labelEl, progress, bar, title: "", status: "" }; + if (key) { + toolEls.set(key, entry); + } + } + + // Updates may omit fields; only overwrite what we were given. + if (typeof message.title === "string" && message.title) { + entry.title = message.title; + } + if (typeof message.status === "string" && message.status) { + entry.status = message.status; + } + + const pct = parsePercent(entry.title); + const text = (pct === null ? entry.title : stripPercent(entry.title)) || "tool"; + entry.labelEl.textContent = text + (entry.status ? " — " + entry.status : ""); + entry.el.className = "message message-tool" + (entry.status ? " tool-" + entry.status : ""); + + if (pct === null) { + entry.progress.hidden = true; + } else { + entry.progress.hidden = false; + entry.bar.style.width = pct + "%"; + } + scrollToBottom(); + } + function setStatus(text) { statusEl.textContent = text; } @@ -87,11 +155,9 @@ case "thought": addMessage("thought", message.text || ""); break; - case "tool": { - const label = message.title + (message.status ? " — " + message.status : ""); - addMessage("tool", label); + case "tool": + renderTool(message); break; - } case "status": setStatus(message.text || ""); break; @@ -114,6 +180,7 @@ break; case "clear": messagesEl.innerHTML = ""; + toolEls.clear(); endAssistant(); break; default:
package.json
+2
index 5918e1c..fdf3f52 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,8 @@ "compile": "tsc --noEmit", "lint": "eslint src --ext ts", "test:smoke": "node test/smoke.mjs", + "test:webview": "node test/webview.mjs", + "test": "node test/smoke.mjs && node test/webview.mjs", "package": "vsce package" }, "devDependencies": {
src/chatView.ts
+3 -1
index 09249d4..13e1e32 100644 --- a/src/chatView.ts +++ b/src/chatView.ts @@ -181,7 +181,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { case "tool_call_update": this.post({ type: "tool", - title: (inner.title as string) ?? (inner.kind as string) ?? "tool", + // May be undefined on an update; the webview keeps the prior label + // and only mutates status/progress in that case. + title: (inner.title as string | undefined) ?? (inner.kind as string | undefined), status: (inner.status as string) ?? "", toolCallId: inner.toolCallId as string | undefined });
test/webview.mjs
+123
new file mode 100644 index 0000000..71e03ef --- /dev/null +++ b/test/webview.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Headless test for the webview's tool-call rendering (media/main.js). +// +// Loads the real webview script against a minimal fake DOM and replays a +// tool_call -> tool_call_update sequence (the model-download flow), asserting +// that updates sharing a toolCallId mutate ONE row in place (with a progress +// bar) instead of stacking new bubbles. +// +// Run: node test/webview.mjs + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(__dirname, "..", "media", "main.js"), "utf8"); + +function makeEl() { + return { + className: "", + hidden: false, + style: {}, + textContent: "", + children: [], + value: "", + disabled: false, + scrollTop: 0, + scrollHeight: 0, + appendChild(c) { + this.children.push(c); + return c; + }, + addEventListener() {}, + _innerHTML: "", + set innerHTML(v) { + this._innerHTML = v; + if (!v) { + this.children = []; + } + }, + get innerHTML() { + return this._innerHTML; + } + }; +} + +const messagesEl = makeEl(); +const byId = { + messages: messagesEl, + status: makeEl(), + composer: makeEl(), + input: makeEl(), + send: makeEl() +}; + +let messageHandler = null; +globalThis.acquireVsCodeApi = () => ({ postMessage() {}, getState() {}, setState() {} }); +globalThis.document = { + getElementById: (id) => byId[id], + createElement: () => makeEl() +}; +globalThis.window = { + addEventListener: (type, fn) => { + if (type === "message") { + messageHandler = fn; + } + } +}; + +// Run the real webview IIFE in this faked global context. +(0, eval)(src); + +function send(msg) { + messageHandler({ data: msg }); +} +function toolRows() { + return messagesEl.children.filter((c) => c.className.includes("message-tool")); +} +function assert(cond, msg) { + if (!cond) { + console.error(`FAIL: ${msg}`); + process.exit(1); + } +} + +assert(typeof messageHandler === "function", "webview registered a message handler"); + +// Same toolCallId across four updates -> exactly one row. +send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B", status: "in_progress" }); +send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B (~1.80 GB) (0%)", status: "in_progress" }); +send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B (~1.80 GB) (50%)", status: "in_progress" }); +send({ type: "tool", toolCallId: "tc1", status: "failed" }); // no title -> keep prior label + +assert(toolRows().length === 1, `4 updates should yield 1 row, got ${toolRows().length}`); +console.log(`OK in-place: 4 updates -> ${toolRows().length} row`); + +const row = toolRows()[0]; +assert(row.className.includes("tool-failed"), `final status class was "${row.className}"`); +console.log("OK final status -> tool-failed"); + +const bubble = row.children[0]; +const labelEl = bubble.children[0]; +const bar = bubble.children[1].children[0]; +assert( + labelEl.textContent === "Downloading Qwen 2.5 3B (~1.80 GB) — failed", + `label was "${labelEl.textContent}"` +); +console.log(`OK label updated in place: "${labelEl.textContent}"`); +assert(bar.style.width === "50%", `progress width was "${bar.style.width}"`); +console.log(`OK progress bar at ${bar.style.width}`); + +// A different toolCallId makes a new row. +send({ type: "tool", toolCallId: "tc2", title: "Reading file", status: "completed" }); +assert(toolRows().length === 2, `distinct id should add a row, got ${toolRows().length}`); +console.log("OK distinct toolCallId -> new row"); + +// clear() resets tracking so ids can be reused cleanly. +send({ type: "clear" }); +send({ type: "tool", toolCallId: "tc1", title: "Again", status: "in_progress" }); +assert(toolRows().length === 1, `after clear expected 1 row, got ${toolRows().length}`); +console.log("OK clear resets tool tracking"); + +console.log("\nALL WEBVIEW CHECKS PASSED");