Skip to content
Open
2 changes: 2 additions & 0 deletions internal/ui/ask_user_question_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ func TestAskUserQuestionToolHasDedicatedRenderer(t *testing.T) {
"renderAskUserQuestionTool(args, result)",
"ask-question-card",
"ask-question-option",
"data-multiple",
"ask-question-multiselect",
}
for _, check := range checks {
if !strings.Contains(exportJs+liveSessionCss, check) {
Expand Down
25 changes: 22 additions & 3 deletions internal/ui/live_templates/export/app/50-render-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ function renderAskUserQuestionTool(args, result) {
const questionToolFailed = result?.isError === true;
const canClick = !result || questionToolFailed;
const isInteractive = canClick || cancelled;
const isMulti = questions.length > 1;
const isMulti = questions.length > 1 || questions.some(q => q.multiSelect);

let html = `<div class="ask-question-card" data-question-count="${questions.length}">`;
html += '<div class="ask-question-title">Question for you</div>';
Expand All @@ -128,6 +128,16 @@ function renderAskUserQuestionTool(args, result) {
html += '<div class="ask-question-state pending">waiting for response</div>';
}

// Detect free-text options: option labels that represent custom/free-text input
function isFreeTextOption(label) {
if (label === 'Type something.') return true;
const lower = label.toLowerCase();
if (lower.includes('其他') || lower.includes('other')) return true;
if (lower.includes('自定义') || lower.includes('custom')) return true;
if (lower.includes('自行输入') || lower.includes('自由输入') || lower.includes('enter your')) return true;
return false;
}

if (questions.length === 0) {
html += '<div class="ask-question-text">No question payload provided.</div>';
}
Expand All @@ -136,23 +146,32 @@ function renderAskUserQuestionTool(args, result) {
const questionText = typeof q.question === 'string' ? q.question : `Question ${qIndex + 1}`;
const answer = answers[questionText];
const options = Array.isArray(q.options) ? q.options : [];
html += `<div class="ask-question-block" data-question-text="${escapeHtml(questionText)}">`;
const qMultiple = q.multiSelect === true;
html += `<div class="ask-question-block" data-question-text="${escapeHtml(questionText)}" data-multiple="${qMultiple}">`;
if (q.header) html += `<div class="ask-question-header">${escapeHtml(String(q.header))}</div>`;
html += `<div class="ask-question-text">${escapeHtml(questionText)}</div>`;
if (options.length > 0) {
html += '<div class="ask-question-options">';
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 += `<div class="ask-question-option-label">${selected ? '✓ ' : ''}${escapeHtml(label)}</div>`;
if (description) html += `<div class="ask-question-option-desc">${escapeHtml(description)}</div>`;
html += `</${tag}>`;
});
if (!qMultiple || hasFreeText) {
html += '<div class="ask-question-freetext">';
html += `<input type="text" class="ask-question-freetext-input" placeholder="Type something..."${isInteractive ? '' : ' disabled'} data-question="${escapeHtml(questionText)}">`;
html += '</div>';
}
html += '</div>';
}
if (answer) {
Expand Down
40 changes: 40 additions & 0 deletions internal/ui/live_templates/styles/session.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
72 changes: 66 additions & 6 deletions web/src/session/chat/chat-composer-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -706,15 +723,58 @@ 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');
}
const actions = card?.querySelector('.ask-question-actions');
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;
Expand Down
85 changes: 85 additions & 0 deletions web/src/session/chat/chat-composer-runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<div class="ask-question-card" data-question-count="1">
<div class="ask-question-block" data-question-text="Pick many" data-multiple="true">
<div class="ask-question-options">
<button class="ask-question-option ask-question-option-action ask-question-multiselect" type="button" data-question="Pick many" data-answer="A">A</button>
<button class="ask-question-option ask-question-option-action ask-question-multiselect" type="button" data-question="Pick many" data-answer="B">B</button>
</div>
</div>
<div class="ask-question-actions" style="display:none"><button type="button" class="ask-question-submit-btn">Send answers</button></div>
</div>
<form id="pi-chat-composer" data-chat-available="true" data-session-id="s1">
<div class="pi-chat-shell">
<textarea id="pi-chat-message"></textarea>
<input id="pi-chat-images"><button id="pi-chat-attach"></button>
<div id="pi-chat-attachments"></div>
<button id="pi-chat-cancel" style="display:none"></button>
<button id="pi-chat-send"></button><span id="pi-chat-status"></span>
</div>
</form>`;
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 = `<div class="ask-question-card" data-question-count="1">
<div class="ask-question-block" data-question-text="Pick many" data-multiple="true">
<div class="ask-question-options">
<button class="ask-question-option ask-question-option-action ask-question-multiselect selected" type="button" data-question="Pick many" data-answer="React">React</button>
<button class="ask-question-option ask-question-option-action ask-question-multiselect selected" type="button" data-question="Pick many" data-answer="Vue">Vue</button>
<button class="ask-question-option ask-question-option-action ask-question-multiselect" type="button" data-question="Pick many" data-answer="Svelte">Svelte</button>
</div>
</div>
<div class="ask-question-actions"><button type="button" class="ask-question-submit-btn">Send answers</button></div>
</div>
<form id="pi-chat-composer" data-chat-available="true" data-session-id="s1">
<div class="pi-chat-shell">
<textarea id="pi-chat-message"></textarea>
<input id="pi-chat-images"><button id="pi-chat-attach"></button>
<div id="pi-chat-attachments"></div>
<button id="pi-chat-cancel" style="display:none"></button>
<button id="pi-chat-send"></button><span id="pi-chat-status"></span>
</div>
</form>`;
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"');
});
});
});
Loading