Skip to content

Commit a356f2d

Browse files
committed
feat(levelcode-ai): ask_user asks one question at a time
Several questions rendered stacked in one card under a single "Send answers" button. Answering the first and missing the rest was easy — and the agent then proceeded on defaults the user never chose, which is worse than not asking. The card now shows one question at a time: - an `n of N` counter, Back to revise, and an advance button; - NOTHING is posted before the last question — the button advances until then; - skipping is deliberate: with nothing picked the button reads "Skip this one" (quiet style) and that answer posts as empty rather than silently defaulted; - on send the card collapses to "Answers recorded — click to review", unfolding to every question with what was picked; - one question keeps the plain card, no step chrome. Also fixes a specificity trap in the new CSS: `.questions.wizard .qblock.cur` (4 classes) outranked `.questions.collapsed .qblock` (3), so collapsing the card mid-wizard would have left the current question on screen. The override matches at equal weight and comes later; webviewCss pins the source order. askWizard.test.js (12 tests) clicks the real function through a fake DOM: no early post, skip-records-empty, Back preserves and revises, ordered payload, multiSelect, single-question path, and the collapsed record. Gate green (22).
1 parent 0288e37 commit a356f2d

4 files changed

Lines changed: 314 additions & 5 deletions

File tree

docs/CALM-TRANSCRIPT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ rebase-safe, and revertable (one function + CSS).
7878
- Edit cards DO collapse into groups (their Keep/Undo stays usable when expanded; the global
7979
review bar and editor-side review remain the primary review paths). Header shows the summed
8080
`+a −d` so the information isn't lost while collapsed.
81+
- `ask_user` asks **one question at a time** (`n of N`, Back, advance button). Stacked questions in
82+
a single card invited answering the first, missing the rest and sending — the agent then acted on
83+
defaults nobody chose. Nothing posts before the last question, and passing one over is deliberate:
84+
with nothing picked the button reads "Skip this one" and the answer is recorded as empty. On send
85+
the card collapses to `Answers recorded`, unfolding to the full question/answer record. A single
86+
question keeps the plain card — no step chrome.
8187

8288
### D5 — Two header states, exactly like the reference.
8389
While the group is open (agent still acting, no closing event yet): header = the **live** step's

extensions/levelcode-ai/media/chat.html

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,28 @@
422422
.questions .qsend:hover { background: var(--vscode-button-hoverBackground); }
423423
.questions .qverdict { font-size: 12px; font-weight: 600; color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); }
424424
.questions.answered { opacity: .92; }
425+
/* ---- one question at a time ----
426+
Several questions stacked in one card made it far too easy to answer the first, miss the rest
427+
and hit send. In wizard mode exactly one block is on screen and the send button only appears on
428+
the last one, so skipping a question has to be deliberate (the button says so). On submit the
429+
wizard class comes off and every question + answer is revealed as a permanent record. */
430+
.questions.wizard .qblock { display: none; }
431+
.questions.wizard .qblock.cur { display: block; }
432+
.questions.wizard .qnotes { display: none; }
433+
.questions.wizard.laststep .qnotes { display: block; }
434+
.questions .qstep { flex: 0 0 auto; font-size: 11px; font-weight: 600; opacity: .6; font-variant-numeric: tabular-nums; }
435+
.questions .qback { cursor: pointer; border: 1px solid var(--border); border-radius: 6px; padding: 5px 11px; font-size: 12px;
436+
background: transparent; color: var(--vscode-foreground); }
437+
.questions .qback:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); }
438+
.questions .qback[hidden] { display: none; } /* explicit display above would defeat [hidden] */
439+
/* nothing picked yet: the primary button steps down to a quiet "skip", so moving on is a choice */
440+
.questions .qsend.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
441+
.questions .qsend.ghost:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); color: var(--vscode-foreground); }
442+
.questions .qspacer { flex: 1 1 auto; }
443+
/* Collapsing wins over the two rules above. `.questions.collapsed .qblock` is three classes and
444+
would lose to `.questions.wizard .qblock.cur`, so the current question would survive a collapse.
445+
These match at equal weight and come later, which settles it. */
446+
.questions.collapsed .qblock.cur, .questions.collapsed.laststep .qnotes { display: none; }
425447

426448
/* ---- applied-edit review (apply-then-review) ---- */
427449
/* Sits between the message log and the composer — sticky review summary for applied edits. */
@@ -1959,11 +1981,15 @@
19591981
closeGroup(); // interactive questions render at top level, never inside a group (D4)
19601982
const qs = m.questions || [];
19611983
const state = qs.map((q) => ({ multi: !!q.multiSelect, sel: new Set() }));
1962-
const card = document.createElement('div'); card.className = 'questions';
1984+
// One question on screen at a time. Stacked, they invited answering the first and sending with
1985+
// the rest untouched. A single question needs no step chrome, so it keeps the plain card.
1986+
const wizard = qs.length > 1;
1987+
let step = 0;
1988+
const card = document.createElement('div'); card.className = 'questions' + (wizard ? ' wizard' : '');
19631989
let h = '<div class="qhead"><span class="qtoggle" title="Collapse / expand">' + codicon('chevron-down') + '</span>'
19641990
+ '<span class="qtitle">' + codicon('question') + ' A few quick decisions — click to choose</span></div>';
19651991
qs.forEach((q, qi) => {
1966-
h += '<div class="qblock">';
1992+
h += '<div class="qblock' + (wizard && qi === 0 ? ' cur' : '') + '" data-qi="' + qi + '">';
19671993
if (q.header){ h += '<div class="qtag">' + esc(q.header) + (q.multiSelect ? ' · pick any' : '') + '</div>'; }
19681994
h += '<div class="qtext">' + esc(q.question || '') + '</div><div class="qopts">';
19691995
(q.options || []).forEach((o, oi) => {
@@ -1976,14 +2002,32 @@
19762002
h += '</div></div>';
19772003
});
19782004
h += '<textarea class="qnotes" rows="2" placeholder="Anything else? (optional)"></textarea>';
1979-
h += '<div class="qbtns"><button class="qsend">Send answers</button></div>';
2005+
h += '<div class="qbtns"><button class="qback" hidden>Back</button><button class="qsend">Send answers</button>'
2006+
+ '<span class="qspacer"></span><span class="qstep"></span></div>';
19802007
card.innerHTML = h;
19812008
log.appendChild(card); scrollIfStuck();
19822009

19832010
// Collapse/expand the whole panel by clicking its header (chevron rotates).
19842011
const qhead = card.querySelector('.qhead');
19852012
if (qhead) { qhead.onclick = () => card.classList.toggle('collapsed'); }
19862013

2014+
const sendBtn = card.querySelector('.qsend');
2015+
const backBtn = card.querySelector('.qback');
2016+
const stepEl = card.querySelector('.qstep');
2017+
// Only the last step sends. Before that the primary button advances — and when nothing is
2018+
// picked it says so plainly, so a question is never passed over by accident.
2019+
function render(){
2020+
if (!wizard){ return; }
2021+
card.querySelectorAll('.qblock').forEach((b) => { b.classList.toggle('cur', +b.dataset.qi === step); });
2022+
const last = step === qs.length - 1;
2023+
card.classList.toggle('laststep', last);
2024+
backBtn.hidden = step === 0;
2025+
stepEl.textContent = (step + 1) + ' of ' + qs.length;
2026+
const picked = state[step].sel.size > 0;
2027+
sendBtn.textContent = last ? 'Send answers' : (picked ? 'Next question' : 'Skip this one');
2028+
sendBtn.classList.toggle('ghost', !last && !picked);
2029+
}
2030+
19872031
card.querySelectorAll('.qopt').forEach((btn) => {
19882032
btn.onclick = () => {
19892033
const qi = +btn.dataset.q, oi = +btn.dataset.o, st = state[qi];
@@ -1992,20 +2036,29 @@
19922036
card.querySelectorAll('.qopt[data-q="' + qi + '"]').forEach((b) => {
19932037
b.classList.toggle('sel', st.sel.has(+b.dataset.o));
19942038
});
2039+
render();
19952040
};
19962041
});
1997-
card.querySelector('.qsend').onclick = () => {
2042+
backBtn.onclick = () => { if (step > 0){ step--; render(); } };
2043+
sendBtn.onclick = () => {
2044+
if (wizard && step < qs.length - 1){ step++; render(); scrollIfStuck(); return; }
19982045
const answers = qs.map((q, qi) => ({
19992046
header: q.header || '', question: q.question || '',
20002047
selected: [...state[qi].sel].sort((a, b) => a - b).map((oi) => ((q.options || [])[oi] || {}).label || '')
20012048
}));
20022049
const notes = card.querySelector('.qnotes').value.trim();
20032050
vscode.postMessage({ type: 'questionsResponse', id: m.id, answers, notes });
2004-
card.classList.add('answered'); // freeze as a visible record
2051+
// Answered and out of the way: the card folds to a single "Answers recorded" line so the
2052+
// transcript moves on, and unfolds to the full record — every question with what was picked.
2053+
card.classList.add('answered', 'collapsed');
2054+
card.classList.remove('wizard');
20052055
card.querySelectorAll('.qopt').forEach((b) => { b.disabled = true; });
20062056
card.querySelector('.qnotes').disabled = true;
2057+
const title = card.querySelector('.qtitle');
2058+
if (title){ title.innerHTML = codicon('check') + ' Answers recorded — click to review'; }
20072059
card.querySelector('.qbtns').innerHTML = '<span class="qverdict">' + codicon('check') + ' Answers recorded</span>';
20082060
};
2061+
render();
20092062
}
20102063

20112064
// Non-blocking applied-edit card (the agent already changed the file; you review after).
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Behavioural tests for the ask_user card — run: node test/askWizard.test.js
3+
*
4+
* Several questions used to render stacked in one card with a single "Send answers" button. It
5+
* was easy to answer the first, not notice the rest, and send a half-filled form — the agent then
6+
* acted on defaults the user never chose. The card now shows ONE question at a time.
7+
*
8+
* The invariants worth protecting, all of them clicked through here against the real function
9+
* extracted from media/chat.html:
10+
* - nothing is posted until the LAST question — the button advances before that,
11+
* - passing over a question is deliberate: with nothing picked the button reads "Skip this one",
12+
* - Back returns to a previous question with its selection intact,
13+
* - the posted payload keeps every question in order, skipped ones included as empty,
14+
* - a single question keeps the plain card (no step chrome, sends straight away).
15+
*--------------------------------------------------------------------------------------------*/
16+
// @ts-check
17+
'use strict';
18+
19+
const assert = require('assert');
20+
const fs = require('fs');
21+
const path = require('path');
22+
23+
const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8');
24+
25+
function extract(name) {
26+
const start = html.indexOf('function ' + name + '(');
27+
assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()');
28+
const end = html.indexOf('\n }', start);
29+
assert.ok(end >= 0, 'no closing brace found for ' + name + '()');
30+
return html.slice(start, end + 4);
31+
}
32+
33+
// ── a fake DOM with just enough attribute support to drive the card ────────────────────────────
34+
// The card is built as one innerHTML string and then wired by class, so the parser records every
35+
// tag's classes and data-* attributes and serves them back through querySelector(All).
36+
class El {
37+
constructor(tag) {
38+
this.tagName = (tag || 'div').toUpperCase();
39+
this.children = [];
40+
this._html = '';
41+
this._nodes = [];
42+
this.dataset = {};
43+
this.hidden = false;
44+
this.disabled = false;
45+
this.value = '';
46+
this.textContent = '';
47+
this.onclick = null;
48+
this.classList = {
49+
_s: new Set(),
50+
add: (...c) => c.forEach((x) => this.classList._s.add(x)),
51+
remove: (...c) => c.forEach((x) => this.classList._s.delete(x)),
52+
contains: (c) => this.classList._s.has(c),
53+
toggle: (c, force) => {
54+
const on = force === undefined ? !this.classList._s.has(c) : !!force;
55+
if (on) { this.classList._s.add(c); } else { this.classList._s.delete(c); }
56+
return on;
57+
}
58+
};
59+
}
60+
set className(v) { this.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)); }
61+
get className() { return [...this.classList._s].join(' '); }
62+
set innerHTML(v) {
63+
this._html = String(v);
64+
this._nodes = [];
65+
for (const t of this._html.matchAll(/<(\w+)([^>]*)>/g)) {
66+
const attrs = t[2];
67+
if (!/class="/.test(attrs)) { continue; }
68+
const el = new El(t[1]);
69+
el.className = /class="([^"]*)"/.exec(attrs)[1];
70+
for (const d of attrs.matchAll(/data-([\w-]+)="([^"]*)"/g)) { el.dataset[d[1]] = d[2]; }
71+
if (/\shidden(?=[\s>/])/.test(attrs + ' ')) { el.hidden = true; }
72+
// text up to the next tag — enough for the labels the card ships in its markup
73+
el.textContent = (/^([^<]*)/.exec(this._html.slice(t.index + t[0].length)) || ['', ''])[1].trim();
74+
this._nodes.push(el);
75+
}
76+
}
77+
get innerHTML() { return this._html; }
78+
appendChild(c) { this.children.push(c); return c; }
79+
_match(sel) {
80+
const m = /^\.([\w-]+)(?:\[([\w-]+)="([^"]*)"\])?$/.exec(String(sel).trim());
81+
assert.ok(m, 'fake DOM cannot parse selector: ' + sel);
82+
const [, cls, attr, val] = m;
83+
return this._nodes.filter((n) => n.classList.contains(cls)
84+
&& (!attr || n.dataset[attr.replace(/^data-/, '')] === val));
85+
}
86+
querySelector(sel) { return this._match(sel)[0] || null; }
87+
querySelectorAll(sel) { return this._match(sel); }
88+
}
89+
90+
function newCard(questions) {
91+
const log = new El('div');
92+
const posted = [];
93+
const sandbox = {};
94+
const preamble = 'const codicon = (n) => "<i:" + n + ">";\n'
95+
+ 'const esc = (s) => String(s == null ? "" : s);\n'
96+
+ 'const clearEmpty = () => {}; const clearStatus = () => {}; const closeGroup = () => {};\n'
97+
+ 'const scrollIfStuck = () => {}; let agentBubble = null;\n';
98+
new Function('document', 'log', 'vscode', preamble + extract('addQuestions') + '\nthis.addQuestions = addQuestions;')
99+
.call(sandbox, { createElement: (t) => new El(t) }, log, { postMessage: (msg) => posted.push(msg) });
100+
/** @type {any} */ (sandbox).addQuestions({ id: 'q1', questions });
101+
const card = log.children[0];
102+
return {
103+
card, posted,
104+
send: card.querySelector('.qsend'),
105+
back: card.querySelector('.qback'),
106+
step: card.querySelector('.qstep'),
107+
notes: card.querySelector('.qnotes'),
108+
pick: (qi, oi) => card.querySelectorAll('.qopt[data-q="' + qi + '"]')[oi].onclick(),
109+
curIndex: () => card.querySelectorAll('.qblock').findIndex((b) => b.classList.contains('cur'))
110+
};
111+
}
112+
113+
const Q = (header, ...labels) => ({ header, question: header + '?', options: labels.map((l) => ({ label: l })) });
114+
const THREE = [Q('Target', 'A', 'B'), Q('Content', 'C', 'D'), Q('Style', 'E', 'F')];
115+
116+
let n = 0;
117+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
118+
119+
// ── 1. one at a time ───────────────────────────────────────────────────────────────────────────
120+
test('several questions open on the first one alone', () => {
121+
const c = newCard(THREE);
122+
assert.ok(c.card.classList.contains('wizard'));
123+
assert.strictEqual(c.curIndex(), 0, 'only the first block is current');
124+
assert.strictEqual(c.step.textContent, '1 of 3');
125+
assert.ok(c.back.hidden, 'nothing to go back to yet');
126+
});
127+
128+
test('with nothing picked the button offers to SKIP, not to send', () => {
129+
const c = newCard(THREE);
130+
assert.strictEqual(c.send.textContent, 'Skip this one');
131+
assert.ok(c.send.classList.contains('ghost'), 'and it steps down to a quiet style');
132+
});
133+
134+
test('picking an option turns it into the advance button', () => {
135+
const c = newCard(THREE);
136+
c.pick(0, 1);
137+
assert.strictEqual(c.send.textContent, 'Next question');
138+
assert.ok(!c.send.classList.contains('ghost'));
139+
});
140+
141+
// ── 2. nothing escapes early ───────────────────────────────────────────────────────────────────
142+
test('the button ADVANCES on every question but the last — no early post', () => {
143+
const c = newCard(THREE);
144+
c.pick(0, 0); c.send.onclick();
145+
assert.deepStrictEqual(c.posted, [], 'nothing sent after question 1');
146+
assert.strictEqual(c.curIndex(), 1);
147+
assert.strictEqual(c.step.textContent, '2 of 3');
148+
assert.ok(!c.back.hidden, 'Back appears once there is somewhere to go back to');
149+
150+
c.pick(1, 0); c.send.onclick();
151+
assert.deepStrictEqual(c.posted, [], 'nothing sent after question 2');
152+
assert.strictEqual(c.send.textContent, 'Send answers', 'only the last step sends');
153+
assert.ok(c.card.classList.contains('laststep'), 'and the free-text box appears with it');
154+
});
155+
156+
test('the final step posts every answer, in order', () => {
157+
const c = newCard(THREE);
158+
c.pick(0, 1); c.send.onclick();
159+
c.pick(1, 0); c.send.onclick();
160+
c.pick(2, 1); c.send.onclick();
161+
assert.strictEqual(c.posted.length, 1);
162+
assert.strictEqual(c.posted[0].type, 'questionsResponse');
163+
assert.deepStrictEqual(c.posted[0].answers.map((a) => a.selected), [['B'], ['C'], ['F']]);
164+
assert.deepStrictEqual(c.posted[0].answers.map((a) => a.header), ['Target', 'Content', 'Style']);
165+
});
166+
167+
test('a skipped question posts as empty — recorded, not silently defaulted', () => {
168+
const c = newCard(THREE);
169+
c.send.onclick(); // "Skip this one"
170+
c.pick(1, 1); c.send.onclick();
171+
c.pick(2, 0); c.send.onclick();
172+
assert.deepStrictEqual(c.posted[0].answers.map((a) => a.selected), [[], ['D'], ['E']]);
173+
});
174+
175+
// ── 3. going back ──────────────────────────────────────────────────────────────────────────────
176+
test('Back returns to the previous question with its answer still selected', () => {
177+
const c = newCard(THREE);
178+
c.pick(0, 1); c.send.onclick();
179+
c.back.onclick();
180+
assert.strictEqual(c.curIndex(), 0);
181+
assert.strictEqual(c.step.textContent, '1 of 3');
182+
assert.strictEqual(c.send.textContent, 'Next question', 'the earlier pick is still held');
183+
assert.ok(c.back.hidden, 'and Back retires at the start again');
184+
});
185+
186+
test('changing a mind on the way back is what gets posted', () => {
187+
const c = newCard(THREE);
188+
c.pick(0, 0); c.send.onclick();
189+
c.back.onclick(); c.pick(0, 1); // switch A → B
190+
c.send.onclick();
191+
c.pick(1, 0); c.send.onclick();
192+
c.pick(2, 0); c.send.onclick();
193+
assert.deepStrictEqual(c.posted[0].answers[0].selected, ['B']);
194+
});
195+
196+
// ── 4. the shapes that must not gain step chrome ───────────────────────────────────────────────
197+
test('a single question keeps the plain card and sends straight away', () => {
198+
const c = newCard([Q('Target', 'A', 'B')]);
199+
assert.ok(!c.card.classList.contains('wizard'), 'no step chrome for one question');
200+
assert.strictEqual(c.send.textContent, 'Send answers');
201+
c.pick(0, 0); c.send.onclick();
202+
assert.strictEqual(c.posted.length, 1);
203+
assert.deepStrictEqual(c.posted[0].answers[0].selected, ['A']);
204+
});
205+
206+
test('multiSelect collects several picks before advancing', () => {
207+
const c = newCard([{ header: 'Pick', question: 'Which?', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] },
208+
Q('Then', 'X', 'Y')]);
209+
c.pick(0, 0); c.pick(0, 2);
210+
assert.strictEqual(c.send.textContent, 'Next question');
211+
c.send.onclick();
212+
c.pick(1, 0); c.send.onclick();
213+
assert.deepStrictEqual(c.posted[0].answers[0].selected, ['A', 'C']);
214+
});
215+
216+
// ── 5. the record left behind ──────────────────────────────────────────────────────────────────
217+
test('once sent, the card folds itself away and says so', () => {
218+
const c = newCard(THREE);
219+
c.pick(0, 0); c.send.onclick();
220+
c.pick(1, 0); c.send.onclick();
221+
c.pick(2, 0); c.send.onclick();
222+
assert.ok(c.card.classList.contains('collapsed'), 'the finished card gets out of the way');
223+
assert.ok(c.card.classList.contains('answered'));
224+
assert.ok(/Answers recorded/.test(c.card.querySelector('.qtitle').innerHTML),
225+
'and the header — the only thing still on screen — states the outcome');
226+
});
227+
228+
test('unfolding it shows every question as a frozen record', () => {
229+
const c = newCard(THREE);
230+
c.pick(0, 0); c.send.onclick();
231+
c.pick(1, 0); c.send.onclick();
232+
c.pick(2, 0); c.send.onclick();
233+
assert.ok(!c.card.classList.contains('wizard'), 'all questions revealed, not just the last one');
234+
assert.ok(c.card.querySelectorAll('.qopt').every((b) => b.disabled), 'answers are no longer editable');
235+
assert.ok(/Answers recorded/.test(c.card.querySelector('.qbtns').innerHTML));
236+
});
237+
238+
console.log('askWizard: ' + n + ' tests passed');

0 commit comments

Comments
 (0)