Skip to content

Commit 54ccaa5

Browse files
ndemiancclaude
andcommitted
fix(ai): tighter agent timeline — thread the rail, fold MCP approval into its run-node
Two timeline-rendering fixes, most visible on an MCP run: 1. Thread the rail across #log's 12px flex-gap between consecutive top-level rows (#log > .tl + .tl > .tl-rail::before { top: -12px }). The per-row rail left a 12px void between EVERY row, so a run read as disconnected stubs ('cut in the middle'); it now reads as one connected line, breaking only at narration (intended). Scoped to #log's direct children so grouped rows keep their own rail. 2. Fold an approved MCP tool call into ONE row instead of an 'Approved · server · tool' chip PLUS a separate '🔌 server · tool' node. agent.js tags the run-node kind:'mcp'; addMcpApproval hands its row to that node only when approved; addAgentLine folds it in. Allow-listed calls were already one row; skipped calls keep their chip. Two new webviewCss invariants (rail-bridge == #log gap; the fold handshake). Full extension suite: 27 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 33d1811 commit 54ccaa5

3 files changed

Lines changed: 64 additions & 4 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,9 @@ async function runTool(tu, ctx) {
508508
+ 'continue without it, or tell the user what you needed it for.';
509509
}
510510
}
511-
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 ' + route.server + ' · ' + route.tool });
511+
// kind:'mcp' lets the webview fold this run-node into the approval chip that gated it (one row
512+
// instead of "Approved …" + "🔌 …"); harmless for an allow-listed call, which has no chip to fold into.
513+
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 ' + route.server + ' · ' + route.tool, kind: 'mcp' });
512514
return await server.call(route.tool, input); // never throws — failures come back as `ERROR: …`
513515
}
514516
return 'ERROR: unknown tool ' + tu.name;

extensions/levelcode-ai/media/chat.html

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@
288288
.tl { display: flex; gap: 11px; margin: 0 4px; position: relative; }
289289
.tl-rail { flex: 0 0 22px; position: relative; }
290290
.tl-rail::before { content: ''; position: absolute; left: 11px; top: 0; bottom: 0; width: 2px; transform: translateX(-1px); background: var(--border); }
291+
/* Thread the rail across #log's flex-gap between CONSECUTIVE top-level rows, so a run of tool /
292+
approval / group nodes reads as ONE connected line (GitHub/Cursor/Claude-style) instead of the
293+
disconnected stubs a per-row rail otherwise leaves — this is what made an MCP run (approval →
294+
tool → approval → tool …) look "cut in the middle". A narration bubble between two rows still
295+
breaks the rail (intended — a new train of thought). The -12px MUST equal #log's `gap`; it is
296+
scoped to #log's DIRECT children so group members (nested .tl rows inside a .groupbody, already
297+
contiguous within their own rail) are never touched. */
298+
#log > .tl + .tl > .tl-rail::before { top: -12px; }
291299
.tl-node { position: absolute; left: 0; top: 3px; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
292300
background: var(--vscode-editor-background, var(--field-bg)); border: 1.5px solid var(--border); color: var(--muted); z-index: 1; }
293301
.tl-node .ci { width: 12px; height: 12px; }
@@ -1849,12 +1857,25 @@
18491857
function addAgentLine(icon, text, label, kind, path){
18501858
clearEmpty(); clearStatus();
18511859
const st = chipStep(icon, text, label, kind, path); st.status = 'done';
1852-
const d = document.createElement('div'); d.className = 'tl tl-tool';
18531860
// Inside a group the row reads as the model's sentence ("Read the runAgent call site"); the raw
18541861
// tool text stays as the tooltip so the underlying path/query is never lost.
18551862
const shown = (groupsOn && st.base) ? st.base : String(text || '');
1856-
d.innerHTML = '<div class="tl-rail"><span class="tl-node">' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '</span></div>'
1863+
const rowHtml = '<div class="tl-rail"><span class="tl-node">' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '</span></div>'
18571864
+ '<div class="tl-body"><span class="toolt" title="' + escAttr(text || '') + '">' + esc(shown) + '</span></div>';
1865+
// MCP: an APPROVED tool call's run-node folds INTO the approval chip that gated it (mcpMergePending,
1866+
// set in addMcpApproval) so the pair is ONE row — Copilot-style — instead of "Approved …" plus a
1867+
// separate "🔌 …". An allow-listed call has no pending chip and renders its own node below; a
1868+
// skipped call never posts a node at all.
1869+
if (kind === 'mcp' && mcpMergePending){
1870+
mcpMergePending.className = 'tl tl-tool';
1871+
mcpMergePending.innerHTML = rowHtml;
1872+
mcpMergePending = null;
1873+
scrollIfStuck();
1874+
return;
1875+
}
1876+
mcpMergePending = null; // any other timeline row closes a stale merge window
1877+
const d = document.createElement('div'); d.className = 'tl tl-tool';
1878+
d.innerHTML = rowHtml;
18581879
groupAppend(d, st);
18591880
scrollIfStuck();
18601881
}
@@ -1894,6 +1915,10 @@
18941915
// I being asked", which is exactly what it is for. On decision it collapses to a one-line verdict;
18951916
// the live run card follows with the output.
18961917
let pendingApproval = null; // { done } while a decision is awaited — Enter approves, Esc skips
1918+
// An APPROVED MCP tool call's run-node folds into the approval chip that gated it (set in
1919+
// addMcpApproval's done(), consumed in addAgentLine) so the call is ONE timeline row, not "Approved …"
1920+
// plus a separate "🔌 …". Null except in the brief window between granting approval and the node arriving.
1921+
let mcpMergePending = null;
18971922
// MCP tool call (S4) — its own card: server · tool · arguments, so the user sees exactly what a
18981923
// third-party tool is about to do. Args are shown in full (capped host-side): that IS the decision.
18991924
// G1 consent card: a repo-authored .levelcode/mcp.json wants to SPAWN A PROCESS. This is the one
@@ -1982,6 +2007,9 @@
19822007
'<div class="cmdhead"><span class="cmdverb">' + verb + '</span>'
19832008
+ '<span class="cmdchips"><code>' + esc(m.server || '') + '</code><code>' + esc(m.tool || '') + '</code></span>'
19842009
+ '<span class="cmdstate ' + (approved ? 'ok' : 'bad') + '">' + codicon(approved ? 'check-circle' : 'circle-slash') + '</span></div>';
2010+
// Approved → the tool runs next and posts its own run-node; hand THIS row to it so the two merge
2011+
// into one line (addAgentLine). Skipped → nothing runs, so the chip stays as the record.
2012+
mcpMergePending = approved ? card : null;
19852013
forceStick();
19862014
};
19872015
card.querySelector('.approve').onclick = () => done(true, false); // Allow once (primary)
@@ -3238,7 +3266,7 @@
32383266
else if (m.type === 'autoContext'){ addAutoCtx(m.names); }
32393267
else if (m.type === 'mode'){ applyMode(!!m.agent); }
32403268
else if (m.type === 'autopilot'){ applyAutopilot(!!m.on); }
3241-
else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; turnLabeled = false; renderPlan([]); setAgentStatus('thinking…'); }
3269+
else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; turnLabeled = false; mcpMergePending = null; renderPlan([]); setAgentStatus('thinking…'); }
32423270
else if (m.type === 'agentStatus'){ finishAgentBubble(); setAgentStatus(m.text || 'working…'); }
32433271
else if (m.type === 'agentDelta'){ clearStatus(); setWork('Responding…'); if (!agentBubble){ agentBubble = makeStream(add('assistant', '')); agentRaw = ''; } agentRaw += m.text; streamFeed(agentBubble, agentRaw); }
32443272
else if (m.type === 'agentTurnEnd'){ finishAgentBubble(); }

extensions/levelcode-ai/test/webviewCss.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,34 @@ test('the /mcp command is wired end to end and lists CONFIGURED servers', () =>
184184
assert.ok(/mcpcmd/.test(html) && /overflow-wrap: anywhere/.test(html), 'and wraps rather than truncating');
185185
});
186186

187+
test('the timeline rail bridges the #log gap between consecutive rows (offsets stay in sync)', () => {
188+
// The rail is drawn per-row (.tl-rail::before spans ONE row). #log stacks rows with a flex `gap`,
189+
// so a run of tool/approval/group nodes only reads as one connected line if each consecutive row's
190+
// rail is pulled UP by exactly that gap. Drift re-breaks it: a smaller bridge leaves the "cut in
191+
// the middle" MCP-run stubs; a larger one paints the rail through an intended narration break.
192+
const gap = css.match(/#log\s*\{[^}]*?\bgap:\s*(\d+)px/);
193+
assert.ok(gap, '#log declares a flex gap');
194+
const bridge = css.match(/#log\s*>\s*\.tl\s*\+\s*\.tl\s*>\s*\.tl-rail::before\s*\{\s*top:\s*-(\d+)px/);
195+
assert.ok(bridge, 'consecutive top-level .tl rows bridge the gap (scoped to #log > direct children)');
196+
assert.strictEqual(bridge[1], gap[1], 'the rail-bridge offset must equal #log gap, or the rail drifts');
197+
});
198+
199+
test('an approved MCP tool call folds its run-node into the approval chip (one row, not two)', () => {
200+
// Removes the redundancy where a manually-approved MCP call showed BOTH an "Approved …" chip AND a
201+
// separate "🔌 server · tool" node. It is a three-part handshake; break any leg and the pair splits
202+
// back into two rows (or folds into a stale chip):
203+
// 1. agent.js tags the run-node kind:'mcp' so the webview can recognise it,
204+
// 2. addMcpApproval arms the fold ONLY on approval (a skip keeps its own chip as the record),
205+
// 3. addAgentLine folds a kind==='mcp' node into that pending row, and clears the window otherwise.
206+
const agent = fs.readFileSync(path.join(__dirname, '..', 'agent.js'), 'utf8');
207+
assert.ok(/type: 'agentTool'[^}]*route\.tool[^}]*kind: 'mcp'/.test(agent), 'agent.js posts the MCP run-node with kind:mcp');
208+
209+
const done = html.slice(html.indexOf('function addMcpApproval'), html.indexOf('function addApproval'));
210+
assert.ok(/mcpMergePending = approved \? card : null/.test(done), 'addMcpApproval arms the fold only when approved');
211+
212+
const line = html.slice(html.indexOf('function addAgentLine'), html.indexOf('function setAgentStatus'));
213+
assert.ok(/kind === 'mcp' && mcpMergePending/.test(line), 'addAgentLine folds an MCP node into the pending chip');
214+
assert.ok(/mcpMergePending = null;/.test(line), 'and closes a stale merge window on any other row');
215+
});
216+
187217
console.log('webviewCss: ' + n + ' tests passed');

0 commit comments

Comments
 (0)