`;
if (q.header) html += ``;
html += `
${escapeHtml(questionText)}
`;
if (options.length > 0) {
html += '
';
+ let hasFreeText = false;
options.forEach((option) => {
const label = typeof option?.label === 'string' ? option.label : String(option || '');
+ if (qMultiple && isFreeTextOption(label)) { hasFreeText = true; return; }
const description = typeof option?.description === 'string' ? option.description : '';
const selected = answer === label || (typeof answer === 'string' && answer.split(', ').includes(label));
const tag = isInteractive ? 'button' : 'div';
const actionClass = isInteractive ? ' ask-question-option-action' : '';
const dataAttrs = isInteractive ? ` type="button" data-question="${escapeHtml(questionText)}" data-answer="${escapeHtml(label)}"` : '';
- html += `<${tag} class="ask-question-option${selected ? ' selected' : ''}${actionClass}"${dataAttrs}>`;
+ const multiSelectClass = qMultiple ? ' ask-question-multiselect' : '';
+ html += `<${tag} class="ask-question-option${selected ? ' selected' : ''}${actionClass}${multiSelectClass}"${dataAttrs}>`;
html += `
${selected ? '✓ ' : ''}${escapeHtml(label)}
`;
if (description) html += `
${escapeHtml(description)}
`;
html += `${tag}>`;
});
+ if (!qMultiple || hasFreeText) {
+ html += '
';
+ html += ` `;
+ html += '
';
+ }
html += '
';
}
if (answer) {
diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css
index f4d1a65..3125f04 100644
--- a/internal/ui/live_templates/styles/session.css
+++ b/internal/ui/live_templates/styles/session.css
@@ -1544,11 +1544,51 @@
background: color-mix(in srgb, var(--accent) 16%, var(--body-bg));
}
+ .ask-question-option.multiselect {
+ cursor: pointer;
+ border-style: dashed;
+ }
+
+ .ask-question-option.multiselect.selected::before {
+ content: '☑ ';
+ color: var(--accent);
+ }
+
+ .ask-question-option.multiselect:not(.selected)::before {
+ content: '☐ ';
+ color: var(--dim);
+ }
+
.ask-question-option-label {
color: var(--text);
font-weight: bold;
}
+ .ask-question-freetext {
+ padding: 4px 0;
+ }
+
+ .ask-question-freetext-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 6px 10px;
+ font-size: 12px;
+ font-family: inherit;
+ color: var(--text);
+ background: var(--body-bg);
+ border: 1px dashed var(--dim);
+ border-radius: 4px;
+ outline: none;
+ }
+
+ .ask-question-freetext-input:focus {
+ border-color: var(--accent);
+ }
+
+ .ask-question-freetext-input::placeholder {
+ color: var(--muted);
+ }
+
.ask-question-option-desc,
.ask-question-hint {
color: var(--muted);
diff --git a/web/src/session/chat/chat-composer-runner.js b/web/src/session/chat/chat-composer-runner.js
index 5dec121..3767c70 100644
--- a/web/src/session/chat/chat-composer-runner.js
+++ b/web/src/session/chat/chat-composer-runner.js
@@ -673,15 +673,30 @@ export function runChatComposer({
const parts = [];
card.querySelectorAll('.ask-question-block').forEach(block => {
const questionText = block.dataset.questionText || '';
- const sel = block.querySelector('.ask-question-option-action.selected');
- if (sel && questionText) parts.push(`"${questionText}" = "${sel.dataset.answer || ''}"`);
+ const blockMultiple = block.dataset.multiple === 'true';
+ const blockFreeText = block.dataset.freetext === 'true';
+ if (blockMultiple) {
+ const selected = block.querySelectorAll('.ask-question-option-action.selected');
+ const answers = Array.from(selected).map(sel => sel.dataset.answer || '');
+ // Check freetext input
+ const freetextInput = block.querySelector('.ask-question-freetext-input');
+ if (freetextInput && freetextInput.value.trim()) answers.push(freetextInput.value.trim());
+ if (answers.length > 0 && questionText) {
+ parts.push(`"${questionText}" = "${answers.join(', ')}"`);
+ }
+ } else {
+ const sel = block.querySelector('.ask-question-option-action.selected');
+ if (sel && questionText) parts.push(`"${questionText}" = "${sel.dataset.answer || ''}"`);
+ }
});
if (parts.length === 0) return;
card.querySelectorAll('.ask-question-option-action').forEach(b => { b.disabled = true; });
+ card.querySelectorAll('.ask-question-freetext-input').forEach(i => { i.disabled = true; });
submitBtn.disabled = true;
const sent = await sendChatMessage(parts.join('\n'), []);
if (!sent) {
card.querySelectorAll('.ask-question-option-action').forEach(b => { b.disabled = false; });
+ card.querySelectorAll('.ask-question-freetext-input').forEach(i => { i.disabled = false; });
submitBtn.disabled = false;
}
return;
@@ -696,8 +711,10 @@ export function runChatComposer({
const block = option.closest('.ask-question-block');
const questionCount = parseInt(card?.dataset.questionCount || '1', 10);
- if (questionCount === 1) {
- // Single question: send immediately
+ const qMultiple = block?.dataset.multiple === 'true';
+
+ if (questionCount === 1 && !qMultiple) {
+ // Single question, single select: send immediately
const question = option.dataset.question || 'Question';
const answer = option.dataset.answer || option.textContent.trim();
option.disabled = true;
@@ -706,8 +723,10 @@ export function runChatComposer({
return;
}
- // Multi-question: mark selection, show submit button
- if (block) {
+ // Multi-question or multi-select: toggle selection
+ if (qMultiple) {
+ option.classList.toggle('selected');
+ } else if (block) {
block.querySelectorAll('.ask-question-option-action').forEach(b => b.classList.remove('selected'));
option.classList.add('selected');
}
@@ -715,6 +734,47 @@ export function runChatComposer({
if (actions) actions.style.display = '';
});
+ // Freetext input: submit on Enter
+ document.addEventListener('keydown', (event) => {
+ if (event.key !== 'Enter') return;
+ const freetextInput = event.target.closest?.('.ask-question-freetext-input');
+ if (!freetextInput || freetextInput.disabled) return;
+ event.preventDefault();
+ const value = freetextInput.value.trim();
+ if (!value) return;
+ const block = freetextInput.closest('.ask-question-block');
+ const card = freetextInput.closest('.ask-question-card');
+ const questionText = block?.dataset.questionText || 'Question';
+ const blockMultiple = block?.dataset.multiple === 'true';
+ freetextInput.disabled = true;
+ card?.querySelectorAll('.ask-question-option-action').forEach(b => { b.disabled = true; });
+ card?.querySelectorAll('.ask-question-freetext-input:not([disabled])').forEach(i => { i.disabled = true; });
+ const submitBtn = card?.querySelector('.ask-question-submit-btn');
+ if (submitBtn) submitBtn.disabled = true;
+ if (blockMultiple) {
+ // Multi-select: collect all selected + freetext
+ const parts = [];
+ card.querySelectorAll('.ask-question-block').forEach(blk => {
+ const qText = blk.dataset.questionText || '';
+ const blkMulti = blk.dataset.multiple === 'true';
+ if (blkMulti) {
+ const sel = blk.querySelectorAll('.ask-question-option-action.selected');
+ const answers = Array.from(sel).map(s => s.dataset.answer || '');
+ const fInput = blk.querySelector('.ask-question-freetext-input');
+ if (fInput && fInput.value.trim()) answers.push(fInput.value.trim());
+ if (answers.length > 0 && qText) parts.push(`"${qText}" = "${answers.join(', ')}"`);
+ } else {
+ const opt = blk.querySelector('.ask-question-option-action.selected');
+ if (opt && qText) parts.push(`"${qText}" = "${opt.dataset.answer || ''}"`);
+ }
+ });
+ if (parts.length > 0) sendChatMessage(parts.join('\n'), []);
+ } else {
+ // Single question freetext
+ sendChatMessage(`"${questionText}" = "${value}"`, []);
+ }
+ });
+
let workerStatusInflight = false;
let workerStatusPending = false;
let lastWorkerState = null;
diff --git a/web/src/session/chat/chat-composer-runner.test.js b/web/src/session/chat/chat-composer-runner.test.js
index 8ef990b..dd78ae6 100644
--- a/web/src/session/chat/chat-composer-runner.test.js
+++ b/web/src/session/chat/chat-composer-runner.test.js
@@ -538,4 +538,89 @@ describe('chat composer runner', () => {
const popover = dom.window.document.getElementById('pi-chat-context-popover');
expect(popover.querySelector('.pi-popover-limit').textContent).toBe('1.2M'); // 1,234,567 formats to 1.2M
});
+
+
+ describe('AskUserQuestion multiSelect', () => {
+ it('toggles selection on multi-select option click instead of sending immediately', () => {
+ const html = `
+
`;
+ const dom = new JSDOM(html, { url: 'https://example.test' });
+ const sendChat = vi.fn(async () => new Response(JSON.stringify({ status: 'queued' }), { status: 200 }));
+ runChatComposer({
+ documentImpl: dom.window.document,
+ windowImpl: dom.window,
+ chatApi: { getWorkerStatus: () => Promise.resolve(new Response('{}', { status: 500 })), cancelChat: vi.fn(), sendChat },
+ chatSelectors: { THINKING_LEVELS: [] },
+ modelSelector: { setupModelSelector: vi.fn() },
+ thinkingSelector: { setupThinkingLevelSelector: vi.fn() },
+ setIntervalImpl: () => {}
+ });
+ dom.window.document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
+ const optA = dom.window.document.querySelector('[data-answer="A"]');
+ optA.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ expect(sendChat).not.toHaveBeenCalled();
+ expect(optA.classList.contains('selected')).toBe(true);
+ optA.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ expect(optA.classList.contains('selected')).toBe(false);
+ });
+
+ it('collects multi-select answers with comma-separated values', async () => {
+ const html = `
+
+
+ React
+ Vue
+ Svelte
+
+
+
Send answers
+
+
+
+ `;
+ const dom = new JSDOM(html, { url: 'https://example.test' });
+ const sendChat = vi.fn(async () => new Response(JSON.stringify({ status: 'queued' }), { status: 200 }));
+ runChatComposer({
+ documentImpl: dom.window.document,
+ windowImpl: dom.window,
+ chatApi: { getWorkerStatus: () => Promise.resolve(new Response('{}', { status: 500 })), cancelChat: vi.fn(), sendChat },
+ chatSelectors: { THINKING_LEVELS: [] },
+ modelSelector: { setupModelSelector: vi.fn() },
+ thinkingSelector: { setupThinkingLevelSelector: vi.fn() },
+ setIntervalImpl: () => {}
+ });
+ dom.window.document.dispatchEvent(new dom.window.Event('DOMContentLoaded'));
+ const submitBtn = dom.window.document.querySelector('.ask-question-submit-btn');
+ submitBtn.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
+ // Allow async sendChatMessage to resolve
+ await new Promise(r => setTimeout(r, 0));
+ expect(sendChat).toHaveBeenCalledTimes(1);
+ const call = sendChat.mock.calls[0];
+ expect(call[0]).toBe('s1'); // sessionId
+ const body = call[1]; // FormData
+ expect(body.get('message')).toBe('"Pick many" = "React, Vue"');
+ });
+ });
});
diff --git a/web/src/session/live/live-renderer.js b/web/src/session/live/live-renderer.js
index 6fb103b..52e677b 100644
--- a/web/src/session/live/live-renderer.js
+++ b/web/src/session/live/live-renderer.js
@@ -137,7 +137,16 @@ export function createLiveRenderer({ documentImpl = document, markedImpl = marke
var qaCancelled = result && result.details && result.details.cancelled === true;
var qaFailed = !!(result && result.isError);
var qaInteractive = !result || qaFailed || qaCancelled;
- var qaMulti = questions.length > 1;
+ // Detect free-text options: option labels that represent custom/free-text input
+ function isFreeTextOption(label) {
+ if (label === 'Type something.') return true;
+ var lower = label.toLowerCase();
+ if (lower.indexOf('其他') >= 0 || lower.indexOf('other') >= 0) return true;
+ if (lower.indexOf('自定义') >= 0 || lower.indexOf('custom') >= 0) return true;
+ if (lower.indexOf('自行输入') >= 0 || lower.indexOf('自由输入') >= 0 || lower.indexOf('enter your') >= 0) return true;
+ return false;
+ }
+ var qaMulti = questions.length > 1 || questions.some(function(q) { return q.multiSelect === true; });
html = '