diff --git a/internal/ui/ask_user_question_render_test.go b/internal/ui/ask_user_question_render_test.go index ce3c493..ae8f4db 100644 --- a/internal/ui/ask_user_question_render_test.go +++ b/internal/ui/ask_user_question_render_test.go @@ -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) { diff --git a/internal/ui/live_templates/export/app/50-render-entry.js b/internal/ui/live_templates/export/app/50-render-entry.js index 8109f4b..18d66e5 100644 --- a/internal/ui/live_templates/export/app/50-render-entry.js +++ b/internal/ui/live_templates/export/app/50-render-entry.js @@ -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 = `
`; html += '
Question for you
'; @@ -128,6 +128,16 @@ function renderAskUserQuestionTool(args, result) { html += '
waiting for response
'; } + // 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 += '
No question payload provided.
'; } @@ -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 += `
`; + const qMultiple = q.multiSelect === true; + html += `
`; if (q.header) html += `
${escapeHtml(String(q.header))}
`; 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 += ``; }); + 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 = `
+
+
+ + + +
+
+
+
+
+
+ + +
+ + +
+
`; + 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 = '
'; html += '
'; html += '
Question for you
'; @@ -154,23 +163,35 @@ export function createLiveRenderer({ documentImpl = document, markedImpl = marke var questionText = typeof q.question === 'string' ? q.question : 'Question '+(qi+1); var answer = qaAnswers[questionText]; var options = Array.isArray(q.options) ? q.options : []; - html += '
'; + var qMultiple = q.multiSelect === true; + html += '
'; if (q.header) html += '
'+escapeHtml(String(q.header))+'
'; html += '
'+escapeHtml(questionText)+'
'; if (options.length > 0) { html += '
'; + // For multiSelect: detect Type something. option (will be rendered as freetext input) + var hasFreeText = false; options.forEach(function(opt) { var label = (opt && typeof opt.label === 'string') ? opt.label : String(opt||''); + // Skip free-text options for multi-select (render as freetext input instead) + if (qMultiple && isFreeTextOption(label)) { hasFreeText = true; return; } var desc = (opt && typeof opt.description === 'string') ? opt.description : ''; var sel = answer === label || (typeof answer === 'string' && answer.split(', ').indexOf(label) >= 0); var tag = qaInteractive ? 'button' : 'div'; - var cls = 'ask-question-option'+(sel?' selected':'')+(qaInteractive?' ask-question-option-action':''); + var multiCls = qMultiple ? ' ask-question-multiselect' : ''; + var cls = 'ask-question-option'+(sel?' selected':'')+(qaInteractive?' ask-question-option-action':'')+multiCls; var dAttrs = qaInteractive ? ' type="button" data-question="'+escapeHtml(questionText)+'" data-answer="'+escapeHtml(label)+'"' : ''; html += '<'+tag+' class="'+cls+'"'+dAttrs+'>'; html += '
'+(sel?'✓ ':'')+escapeHtml(label)+'
'; if (desc) html += '
'+escapeHtml(desc)+'
'; html += ''; }); + // Freetext input: always for single-select, or when multi-select has free-text option + if (!qMultiple || hasFreeText) { + html += '
'; + html += ''; + html += '
'; + } html += '
'; } if (answer) html += '
Answer: '+escapeHtml(String(answer))+'
'; diff --git a/web/src/session/live/live-renderer.test.js b/web/src/session/live/live-renderer.test.js index 9a6ae01..2c98358 100644 --- a/web/src/session/live/live-renderer.test.js +++ b/web/src/session/live/live-renderer.test.js @@ -22,4 +22,30 @@ describe('live renderer', () => { expect(html).toContain('$ echo ok'); expect(html).toContain('ok'); }); + + it('renders data-multiple="true" when multiSelect is true', () => { + const dom = new JSDOM(''); + const renderer = createLiveRenderer({ documentImpl: dom.window.document, markedImpl: marked }); + const html = renderer.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ + question: 'Pick many', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }] + }] + } + }] + } + }, []); + expect(html).toContain('data-multiple="true"'); + expect(html).toContain('ask-question-multiselect'); + }); }); diff --git a/web/src/session/render/session-entry-renderer.js b/web/src/session/render/session-entry-renderer.js index c056620..2071d00 100644 --- a/web/src/session/render/session-entry-renderer.js +++ b/web/src/session/render/session-entry-renderer.js @@ -139,7 +139,16 @@ export function createSessionEntryRenderer({ 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); + // 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; + } let html = `
`; html += '
Question for you
'; @@ -161,23 +170,35 @@ export function createSessionEntryRenderer({ const questionText = typeof q.question === 'string' ? q.question : `Question ${qIndex + 1}`; const answer = answers[questionText]; const options = Array.isArray(q.options) ? q.options : []; - html += `
`; + const qMultiple = q.multiSelect === true; + html += `
`; if (q.header) html += `
${escapeHtml(String(q.header))}
`; html += `
${escapeHtml(questionText)}
`; if (options.length > 0) { html += '
'; + // For multiSelect: detect Type something. option (will be rendered as freetext input) + let hasFreeText = false; options.forEach((option) => { const label = typeof option?.label === 'string' ? option.label : String(option || ''); + // Skip free-text options for multi-select (render as freetext input instead) + 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 += ``; }); + // Freetext input: always for single-select, or when multi-select has Type something. + if (!qMultiple || hasFreeText) { + html += '
'; + html += ``; + html += '
'; + } html += '
'; } if (answer) { diff --git a/web/src/session/render/session-entry-renderer.test.js b/web/src/session/render/session-entry-renderer.test.js index a6691d1..c864d93 100644 --- a/web/src/session/render/session-entry-renderer.test.js +++ b/web/src/session/render/session-entry-renderer.test.js @@ -72,6 +72,80 @@ describe('session entry renderer', () => { expect(url).toContain('targetId=target'); }); + it('renders data-multiple="true" on a question block when multiSelect is true', () => { + const r = renderer(); + const html = r.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ + question: 'Pick frameworks', + multiSelect: true, + options: [ + { label: 'React' }, + { label: 'Vue' }, + { label: 'Svelte' } + ] + }] + } + }] + } + }); + expect(html).toContain('data-multiple="true"'); + expect(html).toContain('ask-question-multiselect'); + }); + + it('renders data-multiple="false" by default when multiSelect is absent', () => { + const r = renderer(); + const html = r.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }] + } + }] + } + }); + expect(html).toContain('data-multiple="false"'); + expect(html).not.toContain('ask-question-multiselect'); + }); + + it('shows submit button for single question with multiSelect', () => { + const r = renderer(); + const html = r.renderEntry({ + id: 'q1', + type: 'message', + message: { + role: 'assistant', + content: [{ + type: 'toolCall', + id: 'call-1', + name: 'ask_user_question', + arguments: { + questions: [{ + question: 'Pick many', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }] + }] + } + }] + } + }); + expect(html).toContain('ask-question-submit-btn'); + }); + it('copies with clipboard feedback', async () => { const dom = new JSDOM(''); const writeText = vi.fn(() => Promise.resolve());