Skip to content

Commit 2b8318e

Browse files
committed
feat(transcript): the collapsed activity header says what happened, not how many things
Reported against the Claude Code reference: a finished run collapsed to "3 steps", which tells nobody anything, where the reference says "Ran 2 commands". THE CAUSE was narrower than it looked. groupAggregate already builds a real sentence for file work and commands — "Read and edited extension.js, ran a command". What it could not describe were the SETUP steps: project rules, project memory, recall, skills, MCP. Those arrive as pre-baked emoji chips, chipStep dropped every one of them into `note`, `note` contributes no phrase, and a run made only of setup fell through to the bare count. So they are classified now — rules / memory / recall / skill / mcp / mcpcall / preview — and each contributes a phrase. The screenshot's run goes from 3 steps to Loaded project rules and memory, connected MCP tools Two distinctions worth having: - An MCP tool CALL (🔌 github · search_code) is different work from setup chatter about servers (🔌 mcp · github (26) · 2/26 allow-listed). The first says "Called github", the second "connected MCP tools". Collapsing them would let a run that actually used a tool read as if it had only connected one. - Setup is named LAST, so what changed still leads the sentence. A run that edited a file and loaded rules reads "Edited a.ts, ran a command, loaded project rules" — a test pins that ordering. And the last-resort fallback names the first step and counts the rest ("Something unusual happened and 1 more") rather than emitting a bare number. Sentence-cased like every other path, which the first version was not. The expanded steps now sit in one hairline container instead of loose rows under a heading — closer to the reference, and it replaces a vertical cue rather than adding one: the group already had a rail and a chevron, and a third was what made the expanded state look busy. Bypass-verified: rules unrecognised; setup contributing no phrase; an MCP call lumped in with setup; the fallback not sentence-cased; no setup kind detected at all. groupAggregate and chipStep are now exposed on the test harness's api — they were extracted into the sandbox but not reachable, so neither had ever been called directly by a test. 20 tests in groupReducer (was 16), 37 suites green.
1 parent eda94c7 commit 2b8318e

2 files changed

Lines changed: 114 additions & 3 deletions

File tree

extensions/levelcode-ai/media/chat.html

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,17 @@
558558
.tl-group .groupstop[hidden] { display: none; }
559559
.tl-group .groupstop:hover { color: var(--vscode-errorForeground, #f14c4c); }
560560
.tl-group .groupstop .ci { width: 12px; height: 12px; }
561-
.tl-group .groupbody { padding: 2px 0 0 4px; }
561+
/* The expanded steps read as ONE unit, not as loose rows under a heading. A hairline container
562+
does that with less weight than the rail it replaces — the group already has a rail and a
563+
chevron, and a third vertical cue was the thing that made the expanded state look busy. */
564+
.tl-group .groupbody {
565+
padding: 3px 2px; margin: 4px 0 2px;
566+
border: 1px solid var(--border); border-radius: 7px;
567+
background: var(--vscode-editorWidget-background, rgba(127,127,127,.045));
568+
}
569+
.tl-group .groupbody:empty { display: none; }
570+
/* Rows sit flush inside the container and separate by hairline rather than by gap. */
571+
.tl-group .groupbody > .tl + .tl { border-top: 1px solid var(--line-soft, rgba(127,127,127,.16)); }
562572
.tl-group.collapsed .groupbody { display: none; }
563573
.tl-group .groupbody > .tl > .tl-rail { display: none; }
564574
.tl-group .groupbody > .tl { margin: 0; }
@@ -2054,7 +2064,37 @@
20542064
if (lists){ parts.push('listed files'); }
20552065
if (cmds){ parts.push('ran ' + (cmds === 1 ? 'a command' : cmds + ' commands')); }
20562066
if (steps.some((s) => s.kind === 'verify')){ parts.push('verified the edits'); }
2057-
if (!parts.length){ return steps.length === 1 ? '1 step' : steps.length + ' steps'; }
2067+
2068+
// Setup. Named LAST so real work still leads the sentence, but named — a run whose only steps
2069+
// were setup should say what it set up, not how many things it did.
2070+
const kinds = new Set(steps.map((s) => s.kind));
2071+
const ctxBits = [];
2072+
if (kinds.has('rules')) { ctxBits.push('project rules'); }
2073+
if (kinds.has('memory') || kinds.has('recall')) { ctxBits.push('memory'); }
2074+
if (ctxBits.length) { parts.push('loaded ' + ctxBits.join(' and ')); }
2075+
if (kinds.has('skill')) {
2076+
const names = uniq(steps.filter((s) => s.kind === 'skill').map((s) => s.path).filter(Boolean));
2077+
parts.push(names.length ? ('used the ' + files(names) + ' skill' + (names.length > 1 ? 's' : '')) : 'used a skill');
2078+
}
2079+
const calls = steps.filter((s) => s.kind === 'mcpcall');
2080+
if (calls.length) {
2081+
const servers = uniq(calls.map((s) => s.path).filter(Boolean));
2082+
parts.push('called ' + (servers.length ? files(servers) : (calls.length + ' MCP tools')));
2083+
} else if (kinds.has('mcp')) {
2084+
parts.push('connected MCP tools');
2085+
}
2086+
if (kinds.has('preview')) { parts.push('opened a preview'); }
2087+
2088+
if (!parts.length){
2089+
// Last resort: name the FIRST thing rather than count. "3 steps" tells no one anything.
2090+
const first = steps.find((s) => s.base && s.base.trim());
2091+
if (first){
2092+
const rest = steps.length - 1;
2093+
const one = first.base.trim() + (rest > 0 ? (' and ' + rest + ' more') : '');
2094+
return one.charAt(0).toUpperCase() + one.slice(1); // same sentence-casing as the built path
2095+
}
2096+
return steps.length === 1 ? '1 step' : steps.length + ' steps';
2097+
}
20582098
const sentence = parts.join(', ');
20592099
return sentence.charAt(0).toUpperCase() + sentence.slice(1);
20602100
}
@@ -2072,6 +2112,20 @@
20722112
}
20732113
if (kind === 'search' || icon === 'search'){ return { kind: 'search', base: titled || 'Search the workspace' }; }
20742114
if (icon === 'list-tree'){ return { kind: 'list', base: titled || 'List files' }; }
2115+
// The run's SETUP steps. These arrive as pre-baked emoji chips and used to fall through to
2116+
// `note`, which contributes no phrase — so a run that loaded rules, memory and an MCP server
2117+
// summarised as the useless "3 steps". They describe real work and should say so.
2118+
if (/^📋 project rules/.test(t)){ return { kind: 'rules', base: titled || t, path: t.replace(/^📋 project rules · /, '') }; }
2119+
if (/^🧠 project memory/.test(t)){ return { kind: 'memory', base: titled || t }; }
2120+
if (/^🧠 recalling:/.test(t)){ return { kind: 'recall', base: titled || t }; }
2121+
if (/^🧩 using skill:/.test(t)){ return { kind: 'skill', base: titled || t, path: t.replace(/^🧩 using skill: /, '') }; }
2122+
if (/^🔌 /.test(t)){
2123+
// A tool CALL through a server (🔌 name · tool) is different work from setup chatter.
2124+
const call = /^🔌 (?!mcp · )([^·]+) · (.+)$/.exec(t);
2125+
return call ? { kind: 'mcpcall', base: titled || t, path: call[1].trim() }
2126+
: { kind: 'mcp', base: titled || t };
2127+
}
2128+
if (/^🌐 preview/.test(t)){ return { kind: 'preview', base: titled || t }; }
20752129
return { kind: 'note', base: titled || t };
20762130
}
20772131
// The label for a command step: the model's explanation (imperative, per the system prompt), or

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function newHarness(groupsOn) {
101101
'collapseMember', 'groupAppend', 'refreshGroupHead', 'finalizeGroup', 'closeGroup',
102102
'groupStepDone', 'groupStepCounts', 'addAgentLine', 'add'
103103
].map(extract).join('\n')
104-
+ '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, add, get curGroup(){ return curGroup; } };';
104+
+ '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, add, groupAggregate, chipStep, get curGroup(){ return curGroup; } };';
105105
new Function('document', 'log', src).call(sandbox, { createElement: (t) => new El(t) }, log);
106106
return { log, api: /** @type {any} */ (sandbox).api };
107107
}
@@ -317,4 +317,61 @@ test('a new user message re-arms the label', () => {
317317
assert.strictEqual(roleOf(h.log.children[4]), 'LevelCode AI', 'the next turn is labelled again');
318318
});
319319

320+
321+
// ── the collapsed header must SAY something (the "3 steps" problem) ─────────────────────────────
322+
323+
test('SETUP STEPS: a run that only loaded context says so, instead of counting', () => {
324+
// This was the reported UX gap: rules + memory + MCP summarised as "3 steps", which tells nobody
325+
// anything. They fell through chipStep into `note`, which contributes no phrase.
326+
const S = newHarness().api;
327+
const chip = (icon, text) => S.chipStep(icon, text, '', undefined, undefined);
328+
const steps = [
329+
chip('file', '📋 project rules · CLAUDE.md'),
330+
chip('history', '🧠 project memory'),
331+
chip('sparkle', '🔌 mcp · github (26) · 2/26 allow-listed')
332+
];
333+
const out = S.groupAggregate(steps);
334+
assert.ok(!/^\d+ steps$/.test(out), 'must not fall back to a bare count: ' + out);
335+
assert.match(out, /project rules/, 'should name the rules it loaded');
336+
assert.match(out, /memory/, 'and the memory');
337+
assert.match(out, /MCP/, 'and the MCP connection');
338+
});
339+
340+
test('SETUP STEPS: each kind is recognised, not lumped into note', () => {
341+
const S = newHarness().api;
342+
const chip = (icon, text) => S.chipStep(icon, text, '', undefined, undefined);
343+
const kindOf = (icon, text) => chip(icon, text).kind;
344+
assert.strictEqual(kindOf('file', '📋 project rules · CLAUDE.md'), 'rules');
345+
assert.strictEqual(kindOf('history', '🧠 project memory'), 'memory');
346+
assert.strictEqual(kindOf('history', '🧠 recalling: how auth works'), 'recall');
347+
assert.strictEqual(kindOf('sparkle', '🧩 using skill: pdf'), 'skill');
348+
assert.strictEqual(kindOf('globe', '🌐 preview · http://localhost:3000'), 'preview');
349+
// A tool CALL through a server is different work from setup chatter about servers.
350+
assert.strictEqual(kindOf('sparkle', '🔌 github · search_code'), 'mcpcall');
351+
assert.strictEqual(kindOf('sparkle', '🔌 mcp · github (26) · 2/26 allow-listed'), 'mcp');
352+
});
353+
354+
test('SETUP STEPS: real work still leads the sentence', () => {
355+
// Setup is named, but never at the expense of what actually changed.
356+
const S = newHarness().api;
357+
const chip = (icon, text) => S.chipStep(icon, text, '', undefined, undefined);
358+
const out = S.groupAggregate([
359+
chip('file', '📋 project rules · CLAUDE.md'),
360+
{ kind: 'cmd' },
361+
{ kind: 'edit', path: 'src/a.ts' }
362+
]);
363+
assert.ok(out.indexOf('a.ts') < out.indexOf('project rules'),
364+
'what changed must come before what was loaded: ' + out);
365+
});
366+
367+
test('FALLBACK: an unrecognised step is named, not counted', () => {
368+
const S = newHarness().api;
369+
const out = S.groupAggregate([
370+
S.chipStep('info', 'something unusual happened', '', undefined, undefined),
371+
S.chipStep('info', 'and another', '', undefined, undefined)
372+
]);
373+
assert.match(out, /^Something unusual happened and 1 more$/,
374+
'name the first and count the rest, sentence-cased like every other path: ' + out);
375+
});
376+
320377
console.log('groupReducer: ' + n + ' tests passed');

0 commit comments

Comments
 (0)