+
{question.options.map((opt, index) => {
const selected = answer.selected?.includes(opt.value);
- const focused = focusIndex === index;
+ const focused = activeOptionIndex === index;
+ const copied = copiedIndex === index;
return (
-
+
);
})}
-
+
+
-
- {customIndex + 1}
-
-
- 其他
- setCustom(event.target.value)}
- placeholder="输入自定义答案"
- className="h-7 w-full rounded-md border border-border bg-surface px-2 text-[12px] text-fg outline-none focus:border-accent"
- />
-
-
- {answer.customSelected ? (
-
+
+ {customActive ? (
+
) : (
-
+ {customIndex + 1}
)}
-
-
-
-
-
- <
-
-
- {nav.current}/{nav.total}
+ setCustom(event.target.value)}
+ onBlur={() => setEditingCustom(false)}
+ placeholder="输入你的答案"
+ maxLength={MAX_CUSTOM_LENGTH}
+ className="min-w-0 flex-1 h-9 bg-transparent text-[14px] text-fg outline-none placeholder:text-fg-mute placeholder:text-[13px]"
+ />
+
+ {(answer.custom || '').length}/{MAX_CUSTOM_LENGTH}
+
+
+
+
+ 取消
+
+ {nav.currentAnswered || nav.isLast ? (
+ submitCurrent(currentIndex) : undefined)}
+ className="h-8 px-3 rounded-lg text-[13px] font-medium bg-fg text-bg hover:opacity-90 transition flex items-center gap-1.5 whitespace-nowrap"
+ >
+ {primaryBtnLabel}
+
+ {primaryKeyHint}
+
+
+ ) : (
+
+ 跳过
+
+ )}
-
- >
-
-
-
- 数字选择 · Enter 确认 · Esc 取消
-
-
- {nav.isLast ? '提交' : '下一题'}
-
>
)}
diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx
index 62100f00..8d4ff1b0 100644
--- a/web/src/components/ToolBlock.jsx
+++ b/web/src/components/ToolBlock.jsx
@@ -28,6 +28,7 @@ import {
import { AttachmentStrip } from './AttachmentStrip.jsx';
import { ActivityLine } from './ActivityLine.jsx';
import { CopyableCodeFrame } from './CopyableCodeFrame.jsx';
+import { QuestionFeedbackCard } from './QuestionFeedbackCard.jsx';
import { ToolSummaryIcon, VsIcon } from './Icon.jsx';
import { toast } from './Toast.jsx';
import * as Diff2Html from 'diff2html';
@@ -110,35 +111,6 @@ function MetricList({ metrics }) {
);
}
-function ClampedQuestionText({ children, className = '' }) {
- const ref = useRef(null);
- const [clamped, setClamped] = useState(false);
- const text = String(children || '');
-
- useEffect(() => {
- const el = ref.current;
- if (!el) return undefined;
- const measure = () => {
- setClamped((el.scrollHeight - el.clientHeight) > 1);
- };
- measure();
- if (typeof ResizeObserver === 'undefined') return undefined;
- const observer = new ResizeObserver(measure);
- observer.observe(el);
- return () => observer.disconnect();
- }, [text]);
-
- return (
-
- {text}
-
- );
-}
-
function askUserQuestionText(result) {
const items = Array.isArray(result?.items) ? result.items : [];
return items
@@ -185,59 +157,6 @@ function taskCompleteDisplayText(summary, output) {
return outputText || '完成';
}
-function AskUserQuestionResultCard({ result, toolContextAttrs }) {
- const [collapsed, setCollapsed] = useState(false);
- const items = Array.isArray(result?.items)
- ? result.items.filter((item) => item && (item.question || item.answer))
- : [];
- if (items.length === 0) return null;
- const fullText = askUserQuestionText({ items });
-
- return (
-
-
setCollapsed((value) => !value)}
- aria-expanded={!collapsed}
- title={collapsed ? fullText : '收起'}
- >
-
-
-
- 已确认 {formatCount(items.length, 'items')}
-
- {collapsed ? '展开' : '收起'}
-
-
- {!collapsed && (
-
- {items.map((item, index) => (
-
-
- Q
-
- {item.question}
-
-
-
- A
-
- {item.answer}
-
-
-
- ))}
-
- )}
-
- );
-}
-
export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessionRunning = true }) {
useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -396,36 +315,6 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio
return () => window.removeEventListener(DESKTOP_CONTEXT_ACTION_EVENT, handler);
}, [onReviewToggle]);
- const questionFeedback = questionFeedbackForTool(entry);
- if (questionFeedback?.kind === 'cancel') {
- return (
-
- );
- }
- if (questionFeedback?.kind === 'interject') {
- return (
-
- );
- }
- if (questionFeedback?.kind === 'submit') {
- return (
-
- );
- }
-
if (isTaskComplete) {
return (
)}
+ {questionFeedback && \s*
/)?.[0] || '';
- assert.ok(sessionComposer, '未找到会话 composer 挂载点');
- assert.doesNotMatch(
- sessionComposer,
- /disabled=\{/,
- '会话 composer 不能因待回答的问题变成只读,否则用户无法插话',
- );
+ assert.match(chatView, /
\s*\{!questionForView \? \(/);
+ assert.doesNotMatch(chatView, /请先回答上方问题/);
assert.match(chatView, /submitting=\{composerSubmitting\}/);
assert.match(chatView, /submitting=\{homeSubmitting\}/);
- assert.match(
- chatView,
- /placeholder=\{questionForView \? '回答上方问题,或直接输入插话(将取消作答,交给 AI 继续)' : undefined\}/,
- '提问挂起时 placeholder 要说明「直接输入 = 插话并取消作答」',
- );
- assert.doesNotMatch(chatView, /请先回答上方问题/);
});
-run('提问挂起时的提交走插话端点,问题已结束才退回普通路径', () => {
+run('提问挂起时 composer 整体让位给提问框,不留插话入口', () => {
const chatView = source('components/ChatView.jsx');
- const submit = chatView.slice(
- chatView.indexOf('const submit = useCallback((text) => {'),
- chatView.indexOf('const drainQueuedInput = useCallback('),
- );
- assert.ok(submit, '未找到 submit');
- // 结束锚点用「自动新建会话」注释:submit 里前面的 desktop_feedback 分支也有
- // 一处 `if (!sid) {`,直接搜它会切到插话分支之前。
- const homeCreateIndex = submit.indexOf('// 自动新建会话');
- assert.ok(homeCreateIndex > 0, '未找到新建会话分支');
- const interject = submit.slice(
- submit.indexOf('if (sid && !isBuiltin && questionForView?.request_id) {'),
- homeCreateIndex,
- );
- assert.ok(interject, '未找到提问插话分支');
- // 插话必须发到问题所属会话(后台任务的问题路由回子会话),携带 request_id。
- assert.ok(interject.includes("const targetSid = questionForView.session_id || sid;"));
- assert.ok(interject.includes('api.interjectQuestion(targetSid, interjectPayload)'));
- assert.ok(interject.includes('request_id: requestId,'));
- // 绝不能走打断(会 abort 回合、丢 标记)或普通排队。
- assert.ok(!interject.includes('api.interruptTurn('));
- assert.ok(!interject.includes('api.steerTurn('));
- // 只有 NO_PENDING_QUESTION(问题已在别处结束)才退回普通路径,其它错误提示用户。
- assert.ok(interject.includes("if (e?.code === 'NO_PENDING_QUESTION') {"));
- assert.ok(interject.includes('fallbackToOrdinaryPath();'));
- assert.ok(
- interject.indexOf('fallbackToOrdinaryPath();') > interject.indexOf("e?.code === 'NO_PENDING_QUESTION'"),
- '退回普通路径只能在 NO_PENDING_QUESTION 分支里',
- );
- // 插话分支要先于「无会话 → 新建会话」与「busy → 排队」两个分支。
- assert.ok(submit.indexOf('questionForView?.request_id') < homeCreateIndex);
- assert.ok(submit.indexOf('questionForView?.request_id') < submit.indexOf('if (busy && !isBuiltin) {'));
- // 排队卡片的「插话」在提问挂起时同样走提问插话,只有问题已结束才退回立即打断。
+ // 提问框承担提问期间唯一的交互面:提交/取消经 resolveQuestion 回流,反馈
+ // 由共享 ToolBlock 按 tool_end 的持久化结果渲染。
+ assert.match(chatView, /\{questionForView && \(\s*');
+ assert.ok(dockStart > 0, '未找到会话 composer dock');
+ const dock = chatView.slice(dockStart, chatView.indexOf('
', dockStart));
+ assert.match(dock, /\{!questionForView \? \(/);
+ assert.ok(dock.includes('
{
+ const chatView = source('components/ChatView.jsx');
const guideFlow = chatView.slice(
chatView.indexOf('const guideQueued = useCallback((queuedId) => {'),
chatView.indexOf('const executeBuiltinCommand = useCallback('),
diff --git a/web/src/lib/questionFeedback.js b/web/src/lib/questionFeedback.js
index c6af708f..c0cd6308 100644
--- a/web/src/lib/questionFeedback.js
+++ b/web/src/lib/questionFeedback.js
@@ -1,20 +1,60 @@
-// Feedback is derived from durable question metadata, including renamed tools
-// and history pages that no longer contain the original assistant call.
-export function questionFeedbackForTool(entry) {
- if (entry?.isDone !== true) return null;
- const result = entry.askUserQuestionResult;
+// AskUserQuestion 提交/取消/插话反馈卡(全部提交完成 / 已取消全部回答 /
+// 已改为直接输入)的派生逻辑。
+//
+// 卡片必须能在会话中持久展示,所以数据来源是「已落盘的 AskUserQuestion 工具
+// 消息元数据」(tool_end 的 metadata.ask_user_question_result),而不是组件里的
+// 临时 state。这一点很关键:回合结束时的 transcript self-heal 会用全新的 item id
+// 覆写最近一轮(见 lib/transcriptSelfHeal.js 的 withFreshIds),任何缓存下来的
+// 锚点 id 都会失效 —— 卡片随之消失。按 item 就地派生就没有这个问题。
+//
+// 判定不看工具名:历史分页可能只剩工具结果消息,或者工具被改名(改名后的调用
+// 名不再是 AskUserQuestion)。唯一可靠的判据是落盘元数据本身,它只由
+// AskUserQuestion 写出 —— 这也是「取消结果不被折叠进历史活动」的前提。
+//
+const NOT_ANSWERED_TEXT = 'Not answered';
+
+function toText(value, fallback = '') {
+ if (typeof value === 'string') return value;
+ if (value == null) return fallback;
+ return String(value);
+}
+
+// C++ 元数据使用 snake_case,历史规范化后使用 camelCase,两种都接受。
+function summaryFromResultItems(resultItems) {
+ if (!Array.isArray(resultItems)) return [];
+ return resultItems
+ .map((item) => {
+ const question = toText(item?.question);
+ const answer = toText(item?.answer);
+ const trimmed = answer.trim();
+ const notAnswered = item?.not_answered === true
+ || item?.notAnswered === true
+ || !trimmed
+ || trimmed === NOT_ANSWERED_TEXT;
+ return {
+ question,
+ multiSelect: item?.multi_select === true || item?.multiSelect === true,
+ answer: notAnswered ? '' : answer,
+ notAnswered,
+ };
+ })
+ .filter((item) => item.question || item.answer);
+}
+
+export function questionFeedbackForTool(tool) {
+ if (!tool || tool.isDone !== true) return null;
+ const result = tool.askUserQuestionResult;
if (!result || typeof result !== 'object' || Array.isArray(result)) return null;
- if (result.cancelled === true) return { kind: 'cancel', items: [] };
+ // 显式取消优先于插话标记:两者不会同时落盘,这里只固定判定顺序。
+ if (result.cancelled === true) return { kind: 'cancel' };
// 插话取消作答:用户没选项,而是直接输入了一条消息(紧跟其后的 user 气泡)。
- if (result.interjected === true) return { kind: 'interject', items: [] };
- if (entry.success === false) return null;
- const items = Array.isArray(result.items)
- ? result.items.filter((item) => item && typeof item === 'object'
- && !Array.isArray(item) && (item.question || item.answer))
- : [];
- return items.length > 0 ? { kind: 'submit', items } : null;
+ if (result.interjected === true) return { kind: 'interject' };
+ if (tool.success === false) return null;
+ const summary = summaryFromResultItems(result.items);
+ return summary.length > 0 ? { kind: 'submit', summary } : null;
}
+// 只按当前工具结果派生,避免连续提问或切换会话后误用上一题的答案。
export function questionFeedbackForItem(item) {
return item?.kind === 'tool' ? questionFeedbackForTool(item.tool) : null;
}
diff --git a/web/src/lib/questionFeedback.test.js b/web/src/lib/questionFeedback.test.js
index 57e53135..b6a2861c 100644
--- a/web/src/lib/questionFeedback.test.js
+++ b/web/src/lib/questionFeedback.test.js
@@ -1,3 +1,7 @@
+// 覆盖 lib/questionFeedback.js:AskUserQuestion 反馈卡必须从已落盘的工具消息
+// 元数据派生,从而在会话中持久展示 —— 包括回合结束后 transcript self-heal
+// 用新 id 覆写最近一轮的情况(这是「卡片随会话输出完成而消失」的根因)。
+
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
@@ -5,113 +9,318 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { parseSync } from '@babel/core';
import { transformWithEsbuild } from 'vite';
+import { createTranscriptState, loadTranscriptHistory, reduceTranscriptEvent } from './sessionTranscript.js';
import * as format from './format.js';
import { compactOneLinePreview } from './compactMessagePreview.js';
import { createdFileSource } from './createdFileSource.js';
import { normalizeAttachmentList } from './messageAttachments.js';
import { fallbackToolSummary } from './toolSummaryFallback.js';
-import { questionFeedbackForItem, questionFeedbackForTool } from './questionFeedback.js';
+import {
+ questionFeedbackForItem,
+ questionFeedbackForTool,
+} from './questionFeedback.js';
-function run(name, fn) {
- fn();
- console.log(`[pass] ${name}`);
-}
+// 编译真实 QuestionFeedbackCard 用于渲染断言。该组件只依赖 VsIcon(图标),其余
+// 全在测试里替身化;data-question-feedback 属性是反馈卡机制的可测试锚点。
+const feedbackCardSource = readFileSync(
+ new URL('../components/QuestionFeedbackCard.jsx', import.meta.url),
+ 'utf8',
+);
+const feedbackCardAst = parseSync(feedbackCardSource, {
+ configFile: false,
+ babelrc: false,
+ parserOpts: { plugins: ['jsx'] },
+});
+const feedbackCardBody = feedbackCardAst.program.body
+ .filter((node) => node.type !== 'ImportDeclaration')
+ .map((node) => node.declaration || node);
+const feedbackCardTransformed = await transformWithEsbuild(
+ feedbackCardBody.map((node) => feedbackCardSource.slice(node.start, node.end)).join('\n'),
+ 'QuestionFeedbackCard.jsx',
+ { loader: 'jsx', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment' },
+);
+const { QuestionFeedbackCard } = vm.runInNewContext(
+ `${feedbackCardTransformed.code}; ({ QuestionFeedbackCard });`,
+ { React, ...React, VsIcon: () => null },
+);
-// Compile the real shared ToolBlock, including its feedback branch and Q/A
-// card. Only unrelated icon/markdown surfaces are substituted in this fixture.
-const source = readFileSync(new URL('../components/ToolBlock.jsx', import.meta.url), 'utf8');
-const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } });
-const body = ast.program.body.filter((node) => node.type !== 'ImportDeclaration')
+// 编译共享 ToolBlock,保证测试经过生产调用路径,不单独拼接反馈卡。
+const toolSource = readFileSync(new URL('../components/ToolBlock.jsx', import.meta.url), 'utf8');
+const toolAst = parseSync(toolSource, {
+ configFile: false,
+ babelrc: false,
+ parserOpts: { plugins: ['jsx'] },
+});
+const toolBody = toolAst.program.body
+ .filter((node) => node.type !== 'ImportDeclaration')
.map((node) => node.declaration || node);
-const transformed = await transformWithEsbuild(
- body.map((node) => source.slice(node.start, node.end)).join('\n'),
+const toolTransformed = await transformWithEsbuild(
+ toolBody.map((node) => toolSource.slice(node.start, node.end)).join('\n'),
'ToolBlock.jsx',
{ loader: 'jsx', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment' },
);
-const { ToolBlock } = vm.runInNewContext(`${transformed.code}; ({ ToolBlock });`, {
+const { ToolBlock } = vm.runInNewContext(`${toolTransformed.code}; ({ ToolBlock });`, {
React, ...React, ...format,
compactOneLinePreview, createdFileSource, normalizeAttachmentList,
- fallbackToolSummary, questionFeedbackForTool,
+ fallbackToolSummary, questionFeedbackForTool, QuestionFeedbackCard,
useTranslation() {},
renderMarkdown: () => '',
VsIcon: () => null,
ToolSummaryIcon: () => null,
- ActivityLine: ({ label }) => React.createElement('span', null, label),
+ ActivityLine: ({ label }) => React.createElement('span', { 'data-tool-activity': true }, label),
});
-export function renderQuestionToolForTest(entry) {
- return renderToStaticMarkup(React.createElement(ToolBlock, { entry, sessionRunning: false }));
+export function renderQuestionToolForTest(item) {
+ return renderToStaticMarkup(React.createElement(ToolBlock, {
+ entry: item.tool,
+ sessionRunning: false,
+ }));
+}
+
+function lastAskUserQuestionItem(items) {
+ return items.findLast((item) => item.kind === 'tool' && item.tool?.tool === 'AskUserQuestion');
+}
+
+async function run(name, fn) {
+ try {
+ await fn();
+ console.log(`[pass] ${name}`);
+ } catch (error) {
+ console.error(`[fail] ${name}`);
+ throw error;
+ }
+}
+
+function load(messages) {
+ return loadTranscriptHistory(createTranscriptState({ title: 's1' }), {
+ messages,
+ events: [],
+ }).state;
+}
+
+function askToolMessage(metadata, content = 'User has answered your questions') {
+ return {
+ id: 't1',
+ role: 'tool',
+ content,
+ tool: 'AskUserQuestion',
+ tool_call_id: 'call-ask',
+ ts: 2,
+ metadata,
+ };
+}
+
+function userMessage() {
+ return { id: 'u1', role: 'user', content: '随机问我', ts: 1 };
}
-const answered = {
- isDone: true, success: true, tool: 'AskUserQuestion',
- askUserQuestionResult: { items: [{ question: 'Which path?', answer: 'src/index.js' }] },
+const SUBMIT_METADATA = {
+ tool_success: true,
+ ask_user_question_result: {
+ items: [
+ { question: '你最喜欢的语言?', answer: 'Rust', multi_select: false },
+ { question: '目标平台?', answer: 'Windows, Linux', multi_select: true },
+ ],
+ },
};
-run('共享 ToolBlock 为成功答案渲染且仅渲染一张确认卡', () => {
- const html = renderQuestionToolForTest(answered);
+const CANCEL_METADATA = {
+ tool_success: false,
+ ask_user_question_result: { cancelled: true, items: [] },
+};
+
+run('提交后可恢复出 tool item,并派生「全部提交完成」卡', () => {
+ const state = load([userMessage(), askToolMessage(SUBMIT_METADATA)]);
+ const item = lastAskUserQuestionItem(state.items);
+ assert.ok(item, 'AskUserQuestion 工具消息必须恢复成 tool item');
+ const card = questionFeedbackForItem(item);
+ assert.ok(card, '提交后必须能派生反馈卡');
+ assert.equal(card.kind, 'submit');
+ assert.equal(card.summary.length, 2);
+ assert.equal(card.summary[0].question, '你最喜欢的语言?');
+ assert.equal(card.summary[0].answer, 'Rust');
+ assert.equal(card.summary[0].notAnswered, false);
+ assert.equal(card.summary[1].answer, 'Windows, Linux');
+ assert.equal(card.summary[1].multiSelect, true, '多选标记必须随卡片一起持久化');
+});
+
+run('取消后仍能恢复出 tool item,并派生「已取消全部回答」卡', () => {
+ const state = load([
+ userMessage(),
+ askToolMessage(CANCEL_METADATA, '[Error] User declined to answer questions.'),
+ ]);
+ const item = lastAskUserQuestionItem(state.items);
+ assert.ok(item, '取消的 AskUserQuestion 工具消息必须恢复成 tool item');
+ const card = questionFeedbackForItem(item);
+ assert.ok(card, '取消后必须能派生反馈卡(会话输出完成后依旧存在)');
+ assert.equal(card.kind, 'cancel');
+});
+
+run('item id 变化不影响反馈卡(回合结束 self-heal 覆写后不丢失)', () => {
+ const state = load([userMessage(), askToolMessage(SUBMIT_METADATA)]);
+ const item = lastAskUserQuestionItem(state.items);
+ const renamed = { ...item, id: 4242 };
+ assert.equal(questionFeedbackForItem(renamed).kind, 'submit');
+ const cancelledItem = lastAskUserQuestionItem(
+ load([userMessage(), askToolMessage(CANCEL_METADATA)]).items,
+ );
+ assert.equal(questionFeedbackForItem({ ...cancelledItem, id: 4243 }).kind, 'cancel');
+});
+
+run('未作答题在反馈卡上标记 notAnswered', () => {
+ const state = load([
+ userMessage(),
+ askToolMessage({
+ tool_success: true,
+ ask_user_question_result: {
+ items: [{ question: '目标平台?', answer: 'Not answered', multi_select: false }],
+ },
+ }),
+ ]);
+ const card = questionFeedbackForItem(lastAskUserQuestionItem(state.items));
+ assert.equal(card.summary[0].notAnswered, true);
+ assert.equal(card.summary[0].answer, '');
+});
+
+run('连续提问时待答工具不复用上一题的提交或取消反馈', () => {
+ for (const metadata of [SUBMIT_METADATA, CANCEL_METADATA]) {
+ let state = load([userMessage(), askToolMessage(metadata)]);
+ state = reduceTranscriptEvent(state, {
+ type: 'tool_start',
+ payload: { tool: 'AskUserQuestion', tool_call_id: 'call-next' },
+ seq: state.lastSeq + 1,
+ }).state;
+ const pending = lastAskUserQuestionItem(state.items);
+ assert.equal(pending.tool.isDone, false);
+ assert.equal(questionFeedbackForItem(pending), null);
+ assert.doesNotMatch(renderQuestionToolForTest(pending), /data-question-feedback/);
+ const previous = state.items.find((item) => item.kind === 'tool');
+ assert.equal((renderQuestionToolForTest(previous).match(/data-question-feedback=/g) || []).length, 1);
+
+ state = reduceTranscriptEvent(state, {
+ type: 'tool_end',
+ payload: {
+ tool: 'AskUserQuestion',
+ tool_call_id: 'call-next',
+ success: true,
+ metadata: { ask_user_question_result: { items: [{ question: 'Next question?', answer: 'Next answer' }] } },
+ },
+ seq: state.lastSeq + 1,
+ }).state;
+ const html = renderQuestionToolForTest(lastAskUserQuestionItem(state.items));
+ assert.equal((html.match(/data-question-feedback="submit"/g) || []).length, 1);
+ assert.match(html, /Next question\?/);
+ assert.match(html, /Next answer/);
+ assert.doesNotMatch(html, /Rust|已取消全部回答/);
+ }
+});
+
+run('共享工具行后紧跟一张反馈卡,无需 ChatView 回调', () => {
+ const item = lastAskUserQuestionItem(load([userMessage(), askToolMessage(SUBMIT_METADATA)]).items);
+ const html = renderQuestionToolForTest(item);
assert.equal((html.match(/data-question-feedback="submit"/g) || []).length, 1);
- assert.match(html, /Which path\?/);
- assert.match(html, /src\/index\.js/);
- assert.match(html, /aria-expanded="true"/);
- assert.doesNotMatch(html, /data-question-feedback="cancel"/);
+ assert.ok(html.indexOf('data-tool-activity') < html.indexOf('data-question-feedback'));
+ assert.match(html, /全部提交完成/);
+ assert.match(html, /(多选)/);
});
-run('共享 ToolBlock 为失败状态的显式取消渲染取消卡并忽略残留答案', () => {
- const entry = {
- ...answered, success: false,
- askUserQuestionResult: { cancelled: true, items: answered.askUserQuestionResult.items },
- };
- assert.deepEqual(questionFeedbackForTool(entry), { kind: 'cancel', items: [] });
- const html = renderQuestionToolForTest(entry);
- assert.equal((html.match(/data-question-feedback="cancel"/g) || []).length, 1);
- assert.match(html, /已取消全部回答/);
- assert.doesNotMatch(html, /data-question-feedback="submit"|ace-qa-question|已确认/);
- assert.match(html, /data-desktop-tool-toggle="false"/);
+run('切换会话后只渲染新会话自己的问答结果', () => {
+ renderQuestionToolForTest(lastAskUserQuestionItem(load([userMessage(), askToolMessage(SUBMIT_METADATA)]).items));
+ const pending = reduceTranscriptEvent(createTranscriptState({ title: 'other session' }), {
+ type: 'tool_start',
+ payload: { tool: 'AskUserQuestion', tool_call_id: 'other-call' },
+ seq: 1,
+ });
+ assert.doesNotMatch(renderQuestionToolForTest(lastAskUserQuestionItem(pending.state.items)), /data-question-feedback|Rust/);
+});
+
+run('会话级取最近一次提问对应的那条工具消息', () => {
+ const state = load([
+ userMessage(),
+ askToolMessage(SUBMIT_METADATA),
+ { id: 'u2', role: 'user', content: '再来一次', ts: 3 },
+ {
+ ...askToolMessage(CANCEL_METADATA, '[Error] User declined to answer questions.'),
+ id: 't2',
+ ts: 4,
+ },
+ ]);
+ const item = lastAskUserQuestionItem(state.items);
+ assert.equal(item.messageId, 't2', '应取最后一条提问对应的消息');
+ assert.equal(questionFeedbackForItem(item).kind, 'cancel');
});
-run('运行中、普通失败和无结构化结果不展示答题成功反馈', () => {
- for (const entry of [
- { ...answered, isDone: false },
- { ...answered, success: false },
- { ...answered, askUserQuestionResult: null },
- { ...answered, askUserQuestionResult: { items: [] } },
- ]) assert.equal(questionFeedbackForTool(entry), null);
- const html = renderQuestionToolForTest({ ...answered, success: false });
- assert.doesNotMatch(html, /data-question-feedback/);
- assert.equal(questionFeedbackForItem({ kind: 'msg', tool: answered }), null);
+run('运行中、普通失败和无结构化结果都不出反馈卡', () => {
+ const answered = {
+ kind: 'tool',
+ tool: {
+ isDone: true,
+ success: true,
+ tool: 'AskUserQuestion',
+ askUserQuestionResult: { items: [{ question: 'Q?', answer: 'A' }] },
+ },
+ };
+ for (const tool of [
+ { ...answered.tool, isDone: false },
+ { ...answered.tool, success: false },
+ { ...answered.tool, askUserQuestionResult: null },
+ { ...answered.tool, askUserQuestionResult: { items: [] } },
+ ]) {
+ assert.equal(questionFeedbackForItem({ ...answered, tool }), null);
+ assert.doesNotMatch(renderQuestionToolForTest({ ...answered, tool }), /data-question-feedback/);
+ }
+ // 非工具条目一律不派生反馈。
+ assert.equal(questionFeedbackForItem({ kind: 'msg', tool: answered.tool }), null);
});
-run('工具改名或缺少调用名时结构化取消仍可渲染', () => {
+run('工具改名或历史页缺少调用名时结构化结果仍渲染反馈卡', () => {
for (const tool of ['', 'request_input']) {
- const entry = { isDone: true, success: false, tool, askUserQuestionResult: { cancelled: true, items: [] } };
- assert.equal(questionFeedbackForItem({ kind: 'tool', tool: entry })?.kind, 'cancel');
- assert.match(renderQuestionToolForTest(entry), /data-question-feedback="cancel"/);
+ const item = {
+ kind: 'tool',
+ tool: {
+ isDone: true,
+ success: false,
+ tool,
+ askUserQuestionResult: { cancelled: true, items: [] },
+ },
+ };
+ assert.equal(questionFeedbackForItem(item)?.kind, 'cancel');
+ assert.match(renderQuestionToolForTest(item), /data-question-feedback="cancel"/);
}
});
-// 触发场景:AskUserQuestion 挂起时用户直接输入插话,daemon 落盘
+// 触发场景:TUI/IM 通道里 AskUserQuestion 挂起时用户直接输入插话,daemon 落盘
// ask_user_question_result={interjected:true, items:[]} 且 success=true。
-// 期望:渲染「已改为直接输入,取消作答」卡而不是取消卡,也不是 Q/A 确认卡;
+// 期望:渲染「已改为直接输入,取消作答」卡,而不是取消卡,也不是 Q/A 确认卡;
// 历史页重载后(工具改名、无调用名)同样可恢复。
-run('共享 ToolBlock 为插话取消作答渲染专属反馈卡', () => {
- const entry = {
- isDone: true, success: true, tool: 'AskUserQuestion',
- askUserQuestionResult: { interjected: true, items: [] },
+run('插话取消作答渲染专属反馈卡,且改名后仍可恢复', () => {
+ const item = {
+ kind: 'tool',
+ tool: {
+ isDone: true,
+ success: true,
+ tool: 'AskUserQuestion',
+ askUserQuestionResult: { interjected: true, items: [] },
+ },
};
- assert.deepEqual(questionFeedbackForTool(entry), { kind: 'interject', items: [] });
- const html = renderQuestionToolForTest(entry);
+ assert.equal(questionFeedbackForItem(item)?.kind, 'interject');
+ const html = renderQuestionToolForTest(item);
assert.equal((html.match(/data-question-feedback="interject"/g) || []).length, 1);
assert.ok(html.includes('已改为直接输入'));
assert.doesNotMatch(html, /data-question-feedback="cancel"|data-question-feedback="submit"|已取消全部回答/);
- assert.match(html, /data-desktop-tool-toggle="false"/);
for (const tool of ['', 'request_input']) {
- const renamed = { ...entry, tool };
- assert.equal(questionFeedbackForItem({ kind: 'tool', tool: renamed })?.kind, 'interject');
+ const renamed = { ...item, tool: { ...item.tool, tool } };
+ assert.equal(questionFeedbackForItem(renamed)?.kind, 'interject');
}
- // 显式取消优先级仍高于插话标记(两者不会同时落盘,守住判定顺序即可)。
+ // 显式取消优先于插话标记(两者不会同时落盘,守住判定顺序即可)。
assert.equal(
- questionFeedbackForTool({ ...entry, askUserQuestionResult: { cancelled: true, interjected: true, items: [] } })?.kind,
+ questionFeedbackForItem({
+ ...item,
+ tool: {
+ ...item.tool,
+ askUserQuestionResult: { cancelled: true, interjected: true, items: [] },
+ },
+ })?.kind,
'cancel',
);
});
diff --git a/web/src/lib/questionFeedbackPersistence.test.js b/web/src/lib/questionFeedbackPersistence.test.js
index bf089179..a98e6479 100644
--- a/web/src/lib/questionFeedbackPersistence.test.js
+++ b/web/src/lib/questionFeedbackPersistence.test.js
@@ -77,7 +77,7 @@ function renderedFeedbackSequence(items, options = {}) {
sequence.push(`${item.kind}:${item.role || ''}`);
}
if (item.kind === 'tool') {
- const html = renderQuestionToolForTest(item.tool);
+ const html = renderQuestionToolForTest(item);
for (const match of html.matchAll(/data-question-feedback="(submit|cancel)"/g)) {
sequence.push(`card:${match[1]}`);
}
@@ -243,7 +243,7 @@ await run('缺少调用或工具被改名的取消结果不会折叠进历史活
const projected = projectCollapsedTranscriptItems(state.items);
const feedbackItems = projected.filter((item) => item.kind === 'tool');
assert.equal(feedbackItems.length, 1);
- assert.match(renderQuestionToolForTest(feedbackItems[0].tool), /data-question-feedback="cancel"/);
+ assert.match(renderQuestionToolForTest(feedbackItems[0]), /data-question-feedback="cancel"/);
assert.equal(renderedFeedbackSequence(state.items).filter((entry) => entry === 'card:cancel').length, 1);
}
});
@@ -254,7 +254,7 @@ await run('历史问答保留多选标记和完整答案', () => {
const state = loadMessages(turn);
const item = projectCollapsedTranscriptItems(state.items).find((entry) => entry.kind === 'tool');
assert.equal(item.tool.askUserQuestionResult.items[0].multiSelect, true);
- assert.match(renderQuestionToolForTest(item.tool), /A, B\nC/);
+ assert.match(renderQuestionToolForTest(item), /A, B\nC/);
});
console.log('questionFeedbackPersistence tests passed');
diff --git a/web/src/lib/questionPicker.js b/web/src/lib/questionPicker.js
index 43bdfc06..ffaef5cf 100644
--- a/web/src/lib/questionPicker.js
+++ b/web/src/lib/questionPicker.js
@@ -1,5 +1,7 @@
// AskUserQuestion picker 的纯逻辑 helper。
// 保持与 daemon question_answer 协议兼容,供 React 组件与 Node 单测复用。
+// 覆盖单选/多选、自定义草稿与选中态分离、跳过(Not answered)、导航、
+// 末题防误提交、Enter 目标优先级等规则。
function toText(value, fallback = '') {
if (typeof value === 'string') return value;
@@ -31,6 +33,7 @@ export function normalizeQuestionRequest(request = {}) {
return {
...normalized,
description: toText(opt?.description),
+ recommended: !!opt?.recommended,
};
}),
};
@@ -39,7 +42,8 @@ export function normalizeQuestionRequest(request = {}) {
}
export function makeInitialAnswers(questions = []) {
- return questions.map(() => ({ selected: [], custom: '', customSelected: false }));
+ // skipped:true 表示该题被用户显式跳过(Not answered)。
+ return questions.map(() => ({ selected: [], custom: '', customSelected: false, skipped: false }));
}
export function isQuestionAnswered(answer = {}) {
@@ -47,23 +51,42 @@ export function isQuestionAnswered(answer = {}) {
(!!answer.customSelected && toText(answer.custom).trim().length > 0);
}
+// 是否"未作答"(供按钮文案:未作答 -> 跳过/提交 判定、Not answered 标记)。
+export function isQuestionSkipped(answer = {}) {
+ return !!answer.skipped && !isQuestionAnswered(answer);
+}
+
export function allQuestionsAnswered(questions = [], answers = []) {
return questions.length > 0 && questions.every((_, index) => isQuestionAnswered(answers[index]));
}
+// 推荐项优先级:带 recommended 标记的第一个选项;否则回退第一个选项。
+export function recommendedOptionIndex(question = {}) {
+ const options = Array.isArray(question.options) ? question.options : [];
+ const first = options.find((opt) => opt.recommended);
+ if (first) return options.findIndex((opt) => opt.recommended);
+ return options.length > 0 ? 0 : -1;
+}
+
export function getNavigationState(currentIndex, questions = [], answers = []) {
const total = questions.length;
const currentAnswered = isQuestionAnswered(answers[currentIndex]);
const isLast = total > 0 && currentIndex >= total - 1;
+ // 未作答且非末题时允许「跳过」。
+ const canSkip = total > 0 && !currentAnswered && !isLast;
return {
total,
current: total === 0 ? 0 : currentIndex + 1,
isLast,
currentAnswered,
- allAnswered: allQuestionsAnswered(questions, answers),
+ canSkip,
+ // 末题提交需要 Ctrl+Enter 防误触:单按主按钮在非末题进下一题。
+ // 末题始终可提交(即使整批未答/跳过,一并记 Not answered)。
+ canSubmit: total > 0 && isLast,
canGoPrev: currentIndex > 0,
canGoNext: total > 0 && !isLast && currentAnswered,
- canSubmit: total > 0 && isLast && allQuestionsAnswered(questions, answers),
+ // 非末题提交(进下一题)需要已作答;末题提交需要已作答(由 canSubmit 决定)。
+ canCommitCurrent: total > 0 && !isLast && currentAnswered,
};
}
@@ -71,13 +94,18 @@ export function toggleAnswerSelection(answer = {}, value, multiSelect) {
const selected = Array.isArray(answer.selected) ? answer.selected : [];
const textValue = toText(value);
if (!textValue) return { ...answer, selected };
- if (!multiSelect) return { ...answer, selected: [textValue], customSelected: false };
+ if (!multiSelect) {
+ // 单选:选中该项并取消自定义选中态,保留自定义草稿文字(灰显、不进 payload)。
+ return { ...answer, selected: [textValue], customSelected: false, skipped: false };
+ }
const hasValue = selected.includes(textValue);
return {
...answer,
selected: hasValue
? selected.filter((item) => item !== textValue)
: [...selected, textValue],
+ customSelected: answer.customSelected,
+ skipped: false,
};
}
@@ -86,13 +114,24 @@ export function selectAnswerCustom(answer = {}, multiSelect = false) {
...answer,
selected: multiSelect && Array.isArray(answer.selected) ? answer.selected : [],
customSelected: true,
+ skipped: false,
};
}
export function setAnswerCustom(answer = {}, custom, multiSelect = false) {
+ // 输入非空时激活自定义;为空时仍保持选中态但视为未作答(见 isQuestionAnswered)。
return { ...selectAnswerCustom(answer, multiSelect), custom: toText(custom) };
}
+export function unselectAnswerCustom(answer = {}) {
+ // 取消自定义选中态:保留草稿文字(灰显),不再作为答案。
+ return { ...answer, customSelected: false };
+}
+
+export function skipAnswer(answer = {}) {
+ return { ...answer, skipped: true };
+}
+
export function hasSelectedTextWithin(target, selection) {
if (!target || !selection || selection.isCollapsed || selection.rangeCount <= 0) return false;
try {
@@ -117,6 +156,8 @@ export function buildQuestionAnswerPayload(request = {}, questions = [], answers
};
const custom = answer.customSelected ? toText(answer.custom).trim() : '';
if (custom) out.custom_text = custom;
+ // 显式跳过的未答题不写入 selected/custom,只标记 not_answered。
+ if (!isQuestionAnswered(answer) && answer.skipped) out.not_answered = true;
return out;
}),
};
@@ -124,6 +165,28 @@ export function buildQuestionAnswerPayload(request = {}, questions = [], answers
return payload;
}
+export function buildQuestionSummary(questions = [], answers = []) {
+ // 提交反馈展示用:逐题汇总题干与答案。未作答/跳过的题标记 not_answered。
+ return questions.map((q, index) => {
+ const answer = answers[index] || {};
+ const selected = (Array.isArray(answer.selected) ? answer.selected : [])
+ .map((item) => toText(item));
+ const chosen = selected.map((value) => {
+ const option = (Array.isArray(q.options) ? q.options : []).find((opt) => toText(opt?.value) === value);
+ return option ? toText(option.label, value) : value;
+ });
+ const custom = answer.customSelected ? toText(answer.custom).trim() : '';
+ if (custom) chosen.push(custom);
+ const answered = isQuestionAnswered(answer);
+ return {
+ question: toText(q.text),
+ multiSelect: !!q.multiSelect,
+ answer: answered ? chosen.filter(Boolean).join('、') : '',
+ notAnswered: !answered,
+ };
+ });
+}
+
export function buildQuestionCancelPayload(request = {}) {
const payload = {
request_id: toText(request.requestId || request.request_id),
@@ -132,4 +195,4 @@ export function buildQuestionCancelPayload(request = {}) {
};
if (!payload.session_id) delete payload.session_id;
return payload;
-}
+}
\ No newline at end of file
diff --git a/web/src/lib/questionPicker.test.js b/web/src/lib/questionPicker.test.js
index b81f4651..b7b1b675 100644
--- a/web/src/lib/questionPicker.test.js
+++ b/web/src/lib/questionPicker.test.js
@@ -5,14 +5,19 @@ import {
allQuestionsAnswered,
buildQuestionAnswerPayload,
buildQuestionCancelPayload,
+ buildQuestionSummary,
getNavigationState,
hasSelectedTextWithin,
isQuestionAnswered,
+ isQuestionSkipped,
makeInitialAnswers,
normalizeQuestionRequest,
+ recommendedOptionIndex,
selectAnswerCustom,
toggleAnswerSelection,
setAnswerCustom,
+ skipAnswer,
+ unselectAnswerCustom,
} from './questionPicker.js';
function run(name, fn) {
@@ -90,7 +95,7 @@ run('单选改填其他时清除普通选项且仅提交自定义答案', () =>
});
});
-run('单选选中空白其他时立即清除普通选项并禁止推进和提交', () => {
+run('单选选中空白其他时立即清除普通选项并禁止推进(末题仍可提交)', () => {
for (const custom of ['', ' ']) {
const answers = makeInitialAnswers(request.questions);
answers[0] = { ...toggleAnswerSelection(answers[0], 'fix-bug', false), custom };
@@ -100,7 +105,8 @@ run('单选选中空白其他时立即清除普通选项并禁止推进和提交
assert.deepEqual(answers[0].selected, []);
assert.equal(isQuestionAnswered(answers[0]), false);
assert.equal(getNavigationState(0, request.questions, answers).canGoNext, false);
- assert.equal(getNavigationState(1, request.questions, answers).canSubmit, false);
+ // 末题即使未作答也始终可提交(整批一并记 Not answered)。
+ assert.equal(getNavigationState(1, request.questions, answers).canSubmit, true);
}
});
@@ -180,13 +186,16 @@ run('取消 payload 不包含部分答案', () => {
});
});
-run('未回答问题禁用推进和提交', () => {
+run('未回答问题禁用推进;非末题不可提交,末题始终可提交', () => {
const answers = makeInitialAnswers(request.questions);
const state = getNavigationState(0, request.questions, answers);
assert.equal(isQuestionAnswered(answers[0]), false);
assert.equal(allQuestionsAnswered(request.questions, answers), false);
assert.equal(state.canGoNext, false);
- assert.equal(state.canSubmit, false);
+ assert.equal(state.canSubmit, false); // 非末题不可提交(首题)
+
+ // 末题即使未答也始终可提交(一并记 Not answered)。
+ assert.equal(getNavigationState(1, request.questions, answers).canSubmit, true);
});
run('答案卡片内有鼠标选区时保留选区', () => {
@@ -208,3 +217,112 @@ run('折叠选区不会阻止答案点击', () => {
};
assert.equal(hasSelectedTextWithin({}, selection), false);
});
+
+run('末题未作答也可提交(整批一并记 Not answered);作答后同样可提交', () => {
+ const answers = makeInitialAnswers(request.questions);
+ // 末题无论是否作答,canSubmit 恒为 true。
+ assert.equal(getNavigationState(1, request.questions, answers).canSkip, false);
+ assert.equal(getNavigationState(1, request.questions, answers).canSubmit, true);
+
+ answers[1] = toggleAnswerSelection(answers[1], 'docs', true);
+ assert.equal(getNavigationState(1, request.questions, answers).canSubmit, true);
+});
+
+run('已作答后不再允许跳过', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = toggleAnswerSelection(answers[0], 'fix-bug', false);
+ assert.equal(getNavigationState(0, request.questions, answers).canSkip, false);
+});
+
+run('跳过记为未作答并在 payload 带 not_answered', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = skipAnswer(answers[0]);
+ assert.equal(isQuestionSkipped(answers[0]), true);
+ assert.equal(isQuestionAnswered(answers[0]), false);
+ const payload = buildQuestionAnswerPayload(request, request.questions, answers);
+ assert.deepEqual(payload.answers[0], {
+ question_id: 'q1',
+ selected: [],
+ not_answered: true,
+ });
+});
+
+run('整批跳过(mask-all)末题统一收卷全部记 not_answered', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = skipAnswer(answers[0]);
+ answers[1] = skipAnswer(answers[1]);
+ const payload = buildQuestionAnswerPayload(request, request.questions, answers);
+ assert.deepEqual(payload.answers[0], { question_id: 'q1', selected: [], not_answered: true });
+ assert.deepEqual(payload.answers[1], { question_id: 'q2', selected: [], not_answered: true });
+});
+
+run('跳过被作答后视为已作答', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = toggleAnswerSelection(answers[0], 'fix-bug', false);
+ assert.equal(isQuestionSkipped(answers[0]), false);
+});
+
+run('单选选中预设保留自定义草稿文字但不作为答案', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = setAnswerCustom(answers[0], ' 独立草稿 ');
+ answers[0] = toggleAnswerSelection(answers[0], 'fix-bug', false);
+ assert.equal(answers[0].custom, ' 独立草稿 ');
+ assert.equal(answers[0].customSelected, false);
+ assert.deepEqual(buildQuestionAnswerPayload(request, request.questions, answers).answers[0], {
+ question_id: 'q1',
+ selected: ['fix-bug'],
+ });
+});
+
+run('取消自定义选中态保留草稿且不作为答案', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = setAnswerCustom(answers[0], '草稿');
+ answers[0] = unselectAnswerCustom(answers[0]);
+ assert.equal(answers[0].customSelected, false);
+ assert.equal(isQuestionAnswered(answers[0]), false);
+ assert.deepEqual(buildQuestionAnswerPayload(request, request.questions, answers).answers[0], {
+ question_id: 'q1',
+ selected: [],
+ });
+});
+
+run('推荐项索引:优先推荐标记,否则回退第一个', () => {
+ const withRec = { options: [
+ { label: 'a' },
+ { label: 'b', recommended: true },
+ { label: 'c' },
+ ] };
+ assert.equal(recommendedOptionIndex(withRec), 1);
+ assert.equal(recommendedOptionIndex({ options: [{ label: 'x' }] }), 0);
+ assert.equal(recommendedOptionIndex({ options: [] }), -1);
+});
+
+run('题干汇总:选中项展示 label 并标记已作答', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = toggleAnswerSelection(answers[0], 'fix-bug', false);
+ answers[1] = toggleAnswerSelection(answers[1], 'tests', true);
+ answers[1] = toggleAnswerSelection(answers[1], 'docs', true);
+ const summary = buildQuestionSummary(request.questions, answers);
+ assert.equal(summary.length, 2);
+ assert.equal(summary[0].question, '你想做什么?');
+ assert.equal(summary[0].answer, '修复 bug');
+ assert.equal(summary[0].notAnswered, false);
+ assert.equal(summary[1].answer, '测试、文档');
+ assert.equal(summary[1].multiSelect, true);
+});
+
+run('题干汇总:自定义激活写入答案文本', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = setAnswerCustom(answers[0], ' 其它需求 ');
+ const summary = buildQuestionSummary(request.questions, answers);
+ assert.equal(summary[0].answer, '其它需求');
+ assert.equal(summary[0].notAnswered, false);
+});
+
+run('题干汇总:未作答/跳过标记 notAnswered', () => {
+ const answers = makeInitialAnswers(request.questions);
+ answers[0] = skipAnswer(answers[0]);
+ const summary = buildQuestionSummary(request.questions, answers);
+ assert.equal(summary[0].notAnswered, true);
+ assert.equal(summary[0].answer, '');
+});
diff --git a/web/src/lib/questionPickerInteraction.test.js b/web/src/lib/questionPickerInteraction.test.js
new file mode 100644
index 00000000..1328a750
--- /dev/null
+++ b/web/src/lib/questionPickerInteraction.test.js
@@ -0,0 +1,209 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import React from 'react';
+import { parseSync } from '@babel/core';
+import { transformWithEsbuild } from 'vite';
+import { clsx } from './format.js';
+import * as helpers from './questionPicker.js';
+
+// Compile the production component and exercise its rendered event handlers.
+// Hooks retain state across renders; effects and timers use a deterministic clock.
+const source = readFileSync(new URL('../components/QuestionPicker.jsx', import.meta.url), 'utf8');
+const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } });
+const body = ast.program.body.filter((node) => node.type !== 'ImportDeclaration')
+ .map((node) => node.declaration || node);
+const transformed = await transformWithEsbuild(body.map((node) => source.slice(node.start, node.end)).join('\n'), 'QuestionPicker.jsx', {
+ loader: 'jsx', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment',
+});
+
+function harness(questions) {
+ const slots = [], sent = [], timers = new Map();
+ let cursor = 0, dirty = false, timerId = 0, effects = [], tree;
+ let request = { request_id: 'r1', session_id: 's1', questions };
+ const changed = (before, after) => !before || after.some((value, index) => value !== before[index]);
+ const Component = vm.runInNewContext(`${transformed.code}; QuestionPicker;`, {
+ React, clsx, ...helpers, VsIcon: () => null,
+ connection: { sendQuestionAnswer: (payload) => sent.push(JSON.parse(JSON.stringify(payload))) },
+ window: { getSelection: () => null },
+ requestAnimationFrame: (callback) => callback(),
+ setTimeout: (callback) => { timers.set(++timerId, callback); return timerId; },
+ clearTimeout: (id) => timers.delete(id),
+ useCallback: (callback) => callback,
+ useState(initial) {
+ const index = cursor++;
+ if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial;
+ return [slots[index], (next) => {
+ const value = typeof next === 'function' ? next(slots[index]) : next;
+ if (value !== slots[index]) { slots[index] = value; dirty = true; }
+ }];
+ },
+ useRef(initial) {
+ const index = cursor++;
+ if (!(index in slots)) slots[index] = { current: initial };
+ return slots[index];
+ },
+ useMemo(factory, deps) {
+ const index = cursor++;
+ if (changed(slots[index]?.deps, deps)) slots[index] = { deps, value: factory() };
+ return slots[index].value;
+ },
+ useEffect(callback, deps) {
+ const index = cursor++;
+ if (changed(slots[index]?.deps, deps)) {
+ const previous = slots[index];
+ slots[index] = { deps };
+ effects.push(() => { previous?.cleanup?.(); slots[index].cleanup = callback(); });
+ }
+ },
+ });
+ function render(nextRequest) {
+ if (nextRequest) request = nextRequest;
+ do {
+ cursor = 0; dirty = false; effects = [];
+ tree = Component({ request });
+ effects.forEach((effect) => effect());
+ } while (dirty);
+ return tree;
+ }
+ return {
+ render, sent, timers,
+ unmount: () => slots.forEach((slot) => slot?.cleanup?.()),
+ };
+}
+
+function nodes(element) {
+ return React.isValidElement(element)
+ ? [element, ...React.Children.toArray(element.props.children).flatMap(nodes)] : [];
+}
+const rows = (tree) => nodes(tree).filter((node) => node.props.onMouseDown);
+const input = (tree) => nodes(tree).find((node) => node.type === 'input');
+const button = (tree, label) => nodes(tree).find((node) => node.type === 'button' && node.props['aria-label'] === label);
+function event(overrides = {}) {
+ return {
+ key: '', detail: 1, target: { tagName: 'SECTION', closest: () => null }, currentTarget: {},
+ prevented: false, preventDefault() { this.prevented = true; }, stopPropagation() {}, ...overrides,
+ };
+}
+function key(picker, name, overrides) {
+ const e = event({ key: name, ...overrides });
+ picker.render().props.onKeyDown(e);
+ return e;
+}
+const q1 = { id: 'q1', text: 'Q1', multiSelect: true, options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b', recommended: true }] };
+const q2 = { id: 'q2', text: 'Q2', options: [{ label: 'C', value: 'c' }] };
+function run(name, fn) { fn(); console.log(`[pass] ${name}`); }
+
+run('multi-select double click preserves the clicked answer before advancing', () => {
+ const picker = harness([q1, q2]);
+ rows(picker.render())[0].props.onClick(event());
+ rows(picker.render())[0].props.onMouseDown(event({ detail: 2 }));
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, ['a']);
+});
+
+run('explicit mouse confirmation preserves an already selected multi-select option', () => {
+ const picker = harness([q1, q2]);
+ rows(picker.render())[0].props.onClick(event());
+ button(rows(picker.render())[0], '选择并进入下一题').props.onClick(event());
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, ['a']);
+});
+
+run('Enter and number-key confirmation retain an already selected multi-select answer', () => {
+ for (const shortcut of ['Enter', '1']) {
+ const picker = harness([q1, q2]);
+ rows(picker.render())[0].props.onClick(event());
+ key(picker, shortcut);
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, ['a']);
+ }
+});
+
+run('double clicking a nested copy button does not advance or answer', () => {
+ const picker = harness([q1, q2]);
+ rows(picker.render())[0].props.onMouseDown(event({ detail: 2, target: { closest: () => ({ tagName: 'BUTTON' }) } }));
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.equal(picker.sent.length, 0);
+});
+
+run('Enter defaults to the recommended option and the last question waits for Ctrl+Enter', () => {
+ const picker = harness([q1]);
+ key(picker, 'Enter');
+ assert.equal(picker.sent.length, 0);
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, ['b']);
+});
+
+run('hover and explicit keyboard focus take precedence over the recommendation', () => {
+ for (const mode of ['hover', 'keyboard']) {
+ const picker = harness([q1]);
+ if (mode === 'hover') rows(picker.render())[0].props.onMouseEnter();
+ else key(picker, 'ArrowUp');
+ key(picker, 'Enter');
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, ['a']);
+ }
+});
+
+run('native button activation and collapsed Enter do not modify hidden answers', () => {
+ const picker = harness([q1]);
+ const nativeButton = { tagName: 'BUTTON', closest: () => ({ tagName: 'BUTTON' }) };
+ assert.equal(key(picker, 'Enter', { target: nativeButton }).prevented, false);
+ button(picker.render(), '折叠').props.onClick();
+ assert.equal(key(picker, 'Enter', { target: nativeButton }).prevented, false);
+ button(picker.render(), '展开').props.onClick();
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, []);
+});
+
+run('IME confirmation stays in the input; ordinary input Enter retains the submit shortcut', () => {
+ for (const composing of [{ isComposing: true }, { nativeEvent: { isComposing: true } }, { keyCode: 229 }]) {
+ const picker = harness([q1]);
+ input(picker.render()).props.onFocus();
+ input(picker.render()).props.onChange({ target: { value: '中文草稿' } });
+ assert.equal(key(picker, 'Enter', { ...composing, target: { tagName: 'INPUT' } }).prevented, false);
+ assert.equal(picker.sent.length, 0);
+ key(picker, 'Enter', { target: { tagName: 'INPUT' } });
+ assert.equal(picker.sent[0].answers[0].custom_text, '中文草稿');
+ }
+});
+
+run('IME Escape does not clear selected answers', () => {
+ const picker = harness([q1]);
+ rows(picker.render())[0].props.onClick(event());
+ key(picker, 'Escape', { isComposing: true });
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.deepEqual(picker.sent[0].answers[0].selected, ['a']);
+});
+
+run('Esc arming is isolated between requests and its timer is cleared on unmount', () => {
+ const picker = harness([q1]);
+ key(picker, 'Escape');
+ assert.equal(picker.timers.size, 1);
+ picker.render({ request_id: 'r2', session_id: 's1', questions: [q1] });
+ assert.equal(picker.timers.size, 0);
+ key(picker, 'Escape');
+ assert.equal(picker.sent.length, 0);
+ picker.unmount();
+ assert.equal(picker.timers.size, 0);
+});
+
+run('refocusing a retained multi-select custom draft reactivates it', () => {
+ const picker = harness([q1]);
+ input(picker.render()).props.onChange({ target: { value: 'retained draft' } });
+ key(picker, 'Escape');
+ input(picker.render()).props.onFocus();
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.equal(picker.sent[0].answers[0].custom_text, 'retained draft');
+ picker.unmount();
+});
+
+run('empty custom input Enter skips locally and only the last question submits', () => {
+ const picker = harness([q1, q2]);
+ input(picker.render()).props.onFocus();
+ key(picker, 'Enter', { target: { tagName: 'INPUT' } });
+ assert.equal(picker.sent.length, 0);
+ key(picker, 'Enter', { ctrlKey: true });
+ assert.equal(picker.sent[0].answers[0].not_answered, true);
+});
diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js
index 3e9248ed..21af05cd 100644
--- a/web/src/lib/runTests.js
+++ b/web/src/lib/runTests.js
@@ -1,4 +1,5 @@
import './questionPicker.test.js';
+import './questionPickerInteraction.test.js';
import './questionFeedback.test.js';
import './questionFeedbackPersistence.test.js';
import './pendingQuestions.test.js';