@sigit / sigit-vscode / commits / 727f0c3

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
@@ -35,3 +35,6 @@ jobs:
35
36 - name: Smoke test (ACP round-trip)
37 run: pnpm run test:smoke
38 +
39 + - name: Webview test (tool-call rendering)
40 + run: pnpm run test:webview
media/main.css
+30
@@ -94,6 +94,36 @@ body {
94 font-family: var(--vscode-editor-font-family, monospace);
95 font-size: 0.9em;
96 border: 1px solid var(--vscode-panel-border);
97 + min-width: 180px;
98 +}
99 +
100 +.tool-label {
101 + white-space: pre-wrap;
102 + word-break: break-word;
103 +}
104 +
105 +.tool-progress {
106 + margin-top: 6px;
107 + height: 4px;
108 + border-radius: 2px;
109 + background-color: var(--vscode-input-background);
110 + overflow: hidden;
111 +}
112 +
113 +.tool-progress-bar {
114 + height: 100%;
115 + width: 0%;
116 + background-color: var(--vscode-progressBar-background);
117 + transition: width 0.2s ease;
118 +}
119 +
120 +.message-tool.tool-completed .bubble {
121 + border-color: var(--vscode-testing-iconPassed, #89d185);
122 +}
123 +
124 +.message-tool.tool-failed .bubble {
125 + border-color: var(--vscode-inputValidation-errorBorder);
126 + color: var(--vscode-foreground);
127 }
128
129 .message-error .bubble {
media/main.js
+71 -4
@@ -13,6 +13,10 @@
13 let currentAssistant = null;
14 let busy = false;
15
16 + // Tool calls arrive as tool_call -> tool_call_update sharing one toolCallId.
17 + // Track each row by id so updates mutate it in place instead of stacking.
18 + const toolEls = new Map();
19 +
20 function scrollToBottom() {
21 messagesEl.scrollTop = messagesEl.scrollHeight;
22 }
@@ -41,6 +45,70 @@
45 currentAssistant = null;
46 }
47
48 + function parsePercent(title) {
49 + const m = /\((\d+)%\)/.exec(title || "");
50 + if (!m) {
51 + return null;
52 + }
53 + const n = parseInt(m[1], 10);
54 + return isNaN(n) ? null : Math.max(0, Math.min(100, n));
55 + }
56 +
57 + function stripPercent(title) {
58 + return (title || "").replace(/\s*\(\d+%\)/, "").trim();
59 + }
60 +
61 + // Render (or update in place) a tool-call row keyed by toolCallId. A title
62 + // like "Downloading … (42%)" is shown as a label plus a progress bar.
63 + function renderTool(message) {
64 + const key = message.toolCallId || null;
65 + let entry = key ? toolEls.get(key) : null;
66 +
67 + if (!entry) {
68 + const el = document.createElement("div");
69 + el.className = "message message-tool";
70 + const bubble = document.createElement("div");
71 + bubble.className = "bubble";
72 + const labelEl = document.createElement("div");
73 + labelEl.className = "tool-label";
74 + const progress = document.createElement("div");
75 + progress.className = "tool-progress";
76 + progress.hidden = true;
77 + const bar = document.createElement("div");
78 + bar.className = "tool-progress-bar";
79 + progress.appendChild(bar);
80 + bubble.appendChild(labelEl);
81 + bubble.appendChild(progress);
82 + el.appendChild(bubble);
83 + messagesEl.appendChild(el);
84 + entry = { el, labelEl, progress, bar, title: "", status: "" };
85 + if (key) {
86 + toolEls.set(key, entry);
87 + }
88 + }
89 +
90 + // Updates may omit fields; only overwrite what we were given.
91 + if (typeof message.title === "string" && message.title) {
92 + entry.title = message.title;
93 + }
94 + if (typeof message.status === "string" && message.status) {
95 + entry.status = message.status;
96 + }
97 +
98 + const pct = parsePercent(entry.title);
99 + const text = (pct === null ? entry.title : stripPercent(entry.title)) || "tool";
100 + entry.labelEl.textContent = text + (entry.status ? " — " + entry.status : "");
101 + entry.el.className = "message message-tool" + (entry.status ? " tool-" + entry.status : "");
102 +
103 + if (pct === null) {
104 + entry.progress.hidden = true;
105 + } else {
106 + entry.progress.hidden = false;
107 + entry.bar.style.width = pct + "%";
108 + }
109 + scrollToBottom();
110 + }
111 +
112 function setStatus(text) {
113 statusEl.textContent = text;
114 }
@@ -87,11 +155,9 @@
155 case "thought":
156 addMessage("thought", message.text || "");
157 break;
90 - case "tool": {
91 - const label = message.title + (message.status ? " — " + message.status : "");
92 - addMessage("tool", label);
158 + case "tool":
159 + renderTool(message);
160 break;
94 - }
161 case "status":
162 setStatus(message.text || "");
163 break;
@@ -114,6 +180,7 @@
180 break;
181 case "clear":
182 messagesEl.innerHTML = "";
183 + toolEls.clear();
184 endAssistant();
185 break;
186 default:
package.json
+2
@@ -155,6 +155,8 @@
155 "compile": "tsc --noEmit",
156 "lint": "eslint src --ext ts",
157 "test:smoke": "node test/smoke.mjs",
158 + "test:webview": "node test/webview.mjs",
159 + "test": "node test/smoke.mjs && node test/webview.mjs",
160 "package": "vsce package"
161 },
162 "devDependencies": {
src/chatView.ts
+3 -1
@@ -181,7 +181,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
181 case "tool_call_update":
182 this.post({
183 type: "tool",
184 - title: (inner.title as string) ?? (inner.kind as string) ?? "tool",
184 + // May be undefined on an update; the webview keeps the prior label
185 + // and only mutates status/progress in that case.
186 + title: (inner.title as string | undefined) ?? (inner.kind as string | undefined),
187 status: (inner.status as string) ?? "",
188 toolCallId: inner.toolCallId as string | undefined
189 });
test/webview.mjs new
+123
@@ -0,0 +1,123 @@
1 +#!/usr/bin/env node
2 +// Headless test for the webview's tool-call rendering (media/main.js).
3 +//
4 +// Loads the real webview script against a minimal fake DOM and replays a
5 +// tool_call -> tool_call_update sequence (the model-download flow), asserting
6 +// that updates sharing a toolCallId mutate ONE row in place (with a progress
7 +// bar) instead of stacking new bubbles.
8 +//
9 +// Run: node test/webview.mjs
10 +
11 +import { readFileSync } from "node:fs";
12 +import { fileURLToPath } from "node:url";
13 +import { dirname, join } from "node:path";
14 +
15 +const __dirname = dirname(fileURLToPath(import.meta.url));
16 +const src = readFileSync(join(__dirname, "..", "media", "main.js"), "utf8");
17 +
18 +function makeEl() {
19 + return {
20 + className: "",
21 + hidden: false,
22 + style: {},
23 + textContent: "",
24 + children: [],
25 + value: "",
26 + disabled: false,
27 + scrollTop: 0,
28 + scrollHeight: 0,
29 + appendChild(c) {
30 + this.children.push(c);
31 + return c;
32 + },
33 + addEventListener() {},
34 + _innerHTML: "",
35 + set innerHTML(v) {
36 + this._innerHTML = v;
37 + if (!v) {
38 + this.children = [];
39 + }
40 + },
41 + get innerHTML() {
42 + return this._innerHTML;
43 + }
44 + };
45 +}
46 +
47 +const messagesEl = makeEl();
48 +const byId = {
49 + messages: messagesEl,
50 + status: makeEl(),
51 + composer: makeEl(),
52 + input: makeEl(),
53 + send: makeEl()
54 +};
55 +
56 +let messageHandler = null;
57 +globalThis.acquireVsCodeApi = () => ({ postMessage() {}, getState() {}, setState() {} });
58 +globalThis.document = {
59 + getElementById: (id) => byId[id],
60 + createElement: () => makeEl()
61 +};
62 +globalThis.window = {
63 + addEventListener: (type, fn) => {
64 + if (type === "message") {
65 + messageHandler = fn;
66 + }
67 + }
68 +};
69 +
70 +// Run the real webview IIFE in this faked global context.
71 +(0, eval)(src);
72 +
73 +function send(msg) {
74 + messageHandler({ data: msg });
75 +}
76 +function toolRows() {
77 + return messagesEl.children.filter((c) => c.className.includes("message-tool"));
78 +}
79 +function assert(cond, msg) {
80 + if (!cond) {
81 + console.error(`FAIL: ${msg}`);
82 + process.exit(1);
83 + }
84 +}
85 +
86 +assert(typeof messageHandler === "function", "webview registered a message handler");
87 +
88 +// Same toolCallId across four updates -> exactly one row.
89 +send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B", status: "in_progress" });
90 +send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B (~1.80 GB) (0%)", status: "in_progress" });
91 +send({ type: "tool", toolCallId: "tc1", title: "Downloading Qwen 2.5 3B (~1.80 GB) (50%)", status: "in_progress" });
92 +send({ type: "tool", toolCallId: "tc1", status: "failed" }); // no title -> keep prior label
93 +
94 +assert(toolRows().length === 1, `4 updates should yield 1 row, got ${toolRows().length}`);
95 +console.log(`OK in-place: 4 updates -> ${toolRows().length} row`);
96 +
97 +const row = toolRows()[0];
98 +assert(row.className.includes("tool-failed"), `final status class was "${row.className}"`);
99 +console.log("OK final status -> tool-failed");
100 +
101 +const bubble = row.children[0];
102 +const labelEl = bubble.children[0];
103 +const bar = bubble.children[1].children[0];
104 +assert(
105 + labelEl.textContent === "Downloading Qwen 2.5 3B (~1.80 GB) — failed",
106 + `label was "${labelEl.textContent}"`
107 +);
108 +console.log(`OK label updated in place: "${labelEl.textContent}"`);
109 +assert(bar.style.width === "50%", `progress width was "${bar.style.width}"`);
110 +console.log(`OK progress bar at ${bar.style.width}`);
111 +
112 +// A different toolCallId makes a new row.
113 +send({ type: "tool", toolCallId: "tc2", title: "Reading file", status: "completed" });
114 +assert(toolRows().length === 2, `distinct id should add a row, got ${toolRows().length}`);
115 +console.log("OK distinct toolCallId -> new row");
116 +
117 +// clear() resets tracking so ids can be reused cleanly.
118 +send({ type: "clear" });
119 +send({ type: "tool", toolCallId: "tc1", title: "Again", status: "in_progress" });
120 +assert(toolRows().length === 1, `after clear expected 1 row, got ${toolRows().length}`);
121 +console.log("OK clear resets tool tracking");
122 +
123 +console.log("\nALL WEBVIEW CHECKS PASSED");