Skip to content

Commit 6d260ff

Browse files
committed
fix(levelcode-ai): calm the transcript chrome — fold finished groups, one speaker label
From a real run: the grouping worked but the surrounding chrome undid it. - A finished group never folded. `.tl-group.collapsed .groupbody{display:none}` existed but finalizeGroup never applied it, so a closed group showed its aggregate header AND every finished row — N+1 lines where the reference shows one. Successful groups now fold to the summary line; failures stay open, and a header the user clicked keeps the state they chose. - A finished group kept wearing its spinner: the rail node stayed on the `sync` glyph while a second check sat at the end of the header. The rail now carries the outcome and the trailing state clears on success — one glyph, not three. - Tool rows were monospace. That was right when they read `read src/agent.js`; now they carry the model's own sentence, and prose in the editor font read like a command line. - "LEVELCODE AI" was stamped over every narration block — four times in one turn, chopping a single train of thought into separate announcements. One label per turn; continuations flow as prose with a tightened gap. groupReducer covers all of it (14 tests): fold-on-success, userToggled respected, failure stays open, rail glyph swap, label-once-per-turn, re-armed by a new user message. Full gate green (20 suites).
1 parent 8697dbe commit 6d260ff

2 files changed

Lines changed: 91 additions & 8 deletions

File tree

extensions/levelcode-ai/media/chat.html

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@
7676
#jumpLatest:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.25)); }
7777
.msg { white-space: normal; word-wrap: break-word; line-height: 1.5; }
7878
.msg .role { font-size: 11px; opacity: .55; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .05em; }
79+
/* continuation narration: no second speaker label, and pulled up against the 12px log gap so the
80+
prose reads as one voice continuing rather than a fresh announcement */
81+
.msg.cont { margin-top: -5px; }
7982
/* rendered-markdown block elements inside a message body */
8083
.msg .body p { margin: 0 0 8px; }
8184
.msg .body ul, .msg .body ol { margin: 0 0 8px; padding-left: 22px; }
@@ -261,7 +264,9 @@
261264
.tl-body { flex: 1 1 auto; min-width: 0; padding: 3px 0 8px; }
262265
/* light tool action (read / edit / search …) — a one-liner node */
263266
.tl-tool .tl-body { padding: 4px 0 6px; }
264-
.tl-tool .toolt { display: block; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--muted); line-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
267+
/* UI font, not the editor font: these rows carry the model's own sentence ("Understand how levels
268+
are authored"), and monospace made prose read like a command line. */
269+
.tl-tool .toolt { display: block; font-size: 12px; color: var(--muted); line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
265270
/* heavier command action — collapsible boxed card: header (chevron · verb · chips · state)
266271
→ highlighted command → streamed output → status foot → italic why */
267272
.tl-cmd .tl-node { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, transparent); background: color-mix(in srgb, var(--accent) 12%, var(--vscode-editor-background, var(--field-bg))); }
@@ -1383,11 +1388,19 @@
13831388
b.onclick = () => { const inp = document.getElementById('input'); if (!inp) { return; } inp.value = b.dataset.q || ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.focus(); };
13841389
});
13851390
})();
1391+
// ONE speaker label per turn. A run narrates several times around its tool cards; stamping
1392+
// "LEVELCODE AI" over each block chopped one train of thought into four announcements. The
1393+
// continuation blocks flow as prose (tighter gap, no label) — the way a person keeps talking.
1394+
let turnLabeled = false;
13861395
function add(role, html){
13871396
clearEmpty();
13881397
closeGroup(); // narration / user text ends the activity group (calm transcript)
1389-
const d = document.createElement('div'); d.className = 'msg ' + role;
1390-
d.innerHTML = '<div class="role">' + (role === 'user' ? 'You' : 'LevelCode AI') + '</div><div class="body">' + html + '</div>';
1398+
if (role === 'user'){ turnLabeled = false; }
1399+
const cont = role !== 'user' && turnLabeled;
1400+
if (role !== 'user'){ turnLabeled = true; }
1401+
const d = document.createElement('div'); d.className = 'msg ' + role + (cont ? ' cont' : '');
1402+
d.innerHTML = (cont ? '' : '<div class="role">' + (role === 'user' ? 'You' : 'LevelCode AI') + '</div>')
1403+
+ '<div class="body">' + html + '</div>';
13911404
log.appendChild(d); scrollIfStuck(); return d.querySelector('.body');
13921405
}
13931406
function addAutoCtx(names){
@@ -1525,6 +1538,7 @@
15251538
const g = {
15261539
el: wrap, body: wrap.querySelector('.groupbody'), label: wrap.querySelector('.grouplabel'),
15271540
counts: wrap.querySelector('.groupcounts'), state: wrap.querySelector('.groupstate'),
1541+
node: wrap.querySelector('.tl-node'),
15281542
stopBtn: wrap.querySelector('.groupstop'), steps: [], failed: false, closed: false, userToggled: false
15291543
};
15301544
wrap.querySelector('.grouphead').onclick = (e) => {
@@ -1581,10 +1595,18 @@
15811595
g.el.classList.remove('running');
15821596
g.label.textContent = groupAggregate(g.steps);
15831597
g.counts.innerHTML = groupCountsHtml(g);
1584-
g.state.innerHTML = codicon(g.failed ? 'circle-slash' : 'check');
1598+
// ONE outcome glyph, in the rail. While running the rail carried a spinner and the head carried
1599+
// dots; a finished group must not go on wearing either. The rail states the outcome, so the
1600+
// trailing state clears on success and speaks up only when something failed.
1601+
if (g.node){ g.node.innerHTML = codicon(g.failed ? 'circle-slash' : 'check'); }
1602+
g.state.innerHTML = g.failed ? codicon('circle-slash') : '';
15851603
g.state.className = 'cmdstate groupstate ' + (g.failed ? 'bad' : 'ok');
15861604
g.stopBtn.hidden = true;
1605+
// A finished, successful group folds to its one-line summary — that fold IS the calm payoff.
1606+
// It was born expanded so the running step stayed readable; nobody needs the finished rows in
1607+
// the way afterwards. Failures stay open, and a group the user opened by hand keeps their choice.
15871608
if (g.failed){ g.el.classList.remove('collapsed'); }
1609+
else if (!g.userToggled){ g.el.classList.add('collapsed'); }
15881610
}
15891611
// Narration / user text / an interactive card ends the activity group.
15901612
function closeGroup(){ const g = curGroup; if (!g){ return; } curGroup = null; finalizeGroup(g); }
@@ -2720,7 +2742,7 @@
27202742
else if (m.type === 'autoContext'){ addAutoCtx(m.names); }
27212743
else if (m.type === 'mode'){ applyMode(!!m.agent); }
27222744
else if (m.type === 'autopilot'){ applyAutopilot(!!m.on); }
2723-
else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; renderPlan([]); setAgentStatus('thinking…'); }
2745+
else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; turnLabeled = false; renderPlan([]); setAgentStatus('thinking…'); }
27242746
else if (m.type === 'agentStatus'){ finishAgentBubble(); setAgentStatus(m.text || 'working…'); }
27252747
else if (m.type === 'agentDelta'){ clearStatus(); setWork('Responding…'); if (!agentBubble){ agentBubble = makeStream(add('assistant', '')); agentRaw = ''; } agentRaw += m.text; streamFeed(agentBubble, agentRaw); }
27262748
else if (m.type === 'agentTurnEnd'){ finishAgentBubble(); }

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

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ function newHarness(groupsOn) {
8888
log.__root = true;
8989
const sandbox = {};
9090
const preamble = 'let curGroup = null; let groupsOn = ' + (groupsOn === false ? 'false' : 'true') + ';\n'
91+
+ 'let turnLabeled = false;\n'
9192
+ 'const LC_DOTS = "<dots>";\n'
9293
+ 'const codicon = (n) => "<i:" + n + ">";\n'
9394
+ 'const esc = (s) => String(s == null ? "" : s);\n'
@@ -97,9 +98,9 @@ function newHarness(groupsOn) {
9798
+ [
9899
'tenseLabel', 'groupAggregate', 'chipStep', 'cmdBase', 'groupCountsHtml', 'openGroup',
99100
'collapseMember', 'groupAppend', 'refreshGroupHead', 'finalizeGroup', 'closeGroup',
100-
'groupStepDone', 'groupStepCounts', 'addAgentLine'
101+
'groupStepDone', 'groupStepCounts', 'addAgentLine', 'add'
101102
].map(extract).join('\n')
102-
+ '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, get curGroup(){ return curGroup; } };';
103+
+ '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, add, get curGroup(){ return curGroup; } };';
103104
new Function('document', 'log', src).call(sandbox, { createElement: (t) => new El(t) }, log);
104105
return { log, api: /** @type {any} */ (sandbox).api };
105106
}
@@ -129,9 +130,38 @@ test('header flips to the past-tense aggregate when the group closes', () => {
129130
const g = h.api.curGroup;
130131
h.api.groupStepCounts(g.steps[1], 3, 1);
131132
h.api.groupStepDone(g.steps[2], false);
133+
assert.ok(!g.el.classList.contains('collapsed'), 'stays open while it runs');
132134
h.api.closeGroup();
133135
assert.strictEqual(g.label.textContent, 'Read and edited extension.js, ran a command');
134136
assert.ok(/\+3/.test(g.counts.innerHTML) && /-1/.test(g.counts.innerHTML), 'summed diffstat in the header');
137+
// The fold IS the payoff: a finished group is ONE line, not a header plus every finished row.
138+
assert.ok(g.el.classList.contains('collapsed'), 'a successful group folds to its summary line');
139+
assert.ok(/i:check/.test(g.node.innerHTML), 'rail states the outcome');
140+
assert.ok(!/i:sync/.test(g.node.innerHTML), 'and stops wearing the running spinner');
141+
});
142+
143+
test('a group the user opened by hand keeps their choice when it closes', () => {
144+
const h = newHarness();
145+
const a = card('tl tl-cmd'), b = card('tl tl-cmd');
146+
h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a });
147+
h.api.groupAppend(b, { kind: 'cmd', base: 'Run two', status: 'done', card: b });
148+
const g = h.api.curGroup;
149+
g.userToggled = true; // they clicked the header mid-run
150+
h.api.closeGroup();
151+
assert.ok(!g.el.classList.contains('collapsed'), 'auto-fold never overrides a deliberate click');
152+
});
153+
154+
test('a FAILED group closes open, so the problem is on screen', () => {
155+
const h = newHarness();
156+
const a = card('tl tl-cmd'), b = card('tl tl-cmd');
157+
h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a });
158+
const step = { kind: 'cmd', base: 'Run two', status: 'running', card: b };
159+
h.api.groupAppend(b, step);
160+
const g = h.api.curGroup;
161+
h.api.groupStepDone(step, true);
162+
h.api.closeGroup();
163+
assert.ok(!g.el.classList.contains('collapsed'), 'failures never fold away');
164+
assert.ok(/circle-slash/.test(g.node.innerHTML), 'rail marks the failure');
135165
});
136166

137167
// ── 2. members are one-line rows; a failure re-opens its row ───────────────────────────────────
@@ -167,7 +197,8 @@ test('a user-stopped command is NOT a failure', () => {
167197
const g = h.api.curGroup;
168198
h.api.groupStepDone(step, false); // termExitFinish passes false for how === 'stopped'
169199
h.api.closeGroup();
170-
assert.ok(/i:check/.test(g.state.innerHTML), 'group still closes clean');
200+
assert.ok(/i:check/.test(g.node.innerHTML), 'group still closes clean');
201+
assert.strictEqual(g.state.innerHTML, '', 'no second glyph trailing the header');
171202
});
172203

173204
// ── 3. degenerate + late-exit lifecycles ───────────────────────────────────────────────────────
@@ -226,4 +257,34 @@ test('the model label titles the row; the raw tool text stays as the tooltip', (
226257
assert.ok(/title="read src\/agent\.js"/.test(row.innerHTML), 'raw text preserved as the tooltip');
227258
});
228259

260+
// ── 5. one speaker label per turn ──────────────────────────────────────────────────────────────
261+
// A run narrates repeatedly around its tool cards. Re-stamping "LEVELCODE AI" over every block is
262+
// what made the transcript read as four announcements instead of one person thinking out loud.
263+
const roleOf = (el) => (/class="role">([^<]*)</.exec(el.innerHTML) || [, null])[1];
264+
265+
test('the first narration of a turn is labelled; the rest flow as prose', () => {
266+
const h = newHarness();
267+
h.api.add('user', 'do the thing');
268+
h.api.add('assistant', 'Let me look at the repo first.');
269+
h.api.add('assistant', 'I have enough context now.');
270+
h.api.add('assistant', 'Done: created the file.');
271+
const [u, first, second, third] = h.log.children;
272+
assert.strictEqual(roleOf(u), 'You');
273+
assert.strictEqual(roleOf(first), 'LevelCode AI', 'the turn announces itself once');
274+
assert.strictEqual(roleOf(second), null, 'continuation carries no second label');
275+
assert.strictEqual(roleOf(third), null);
276+
assert.ok(!first.classList.contains('cont') && second.classList.contains('cont'),
277+
'continuations are marked so CSS can tighten the gap');
278+
});
279+
280+
test('a new user message re-arms the label', () => {
281+
const h = newHarness();
282+
h.api.add('user', 'first ask');
283+
h.api.add('assistant', 'working…');
284+
h.api.add('assistant', 'still working…');
285+
h.api.add('user', 'second ask');
286+
h.api.add('assistant', 'on it');
287+
assert.strictEqual(roleOf(h.log.children[4]), 'LevelCode AI', 'the next turn is labelled again');
288+
});
289+
229290
console.log('groupReducer: ' + n + ' tests passed');

0 commit comments

Comments
 (0)