diff --git a/design-prototypes/ask-user-question-prototype.html b/design-prototypes/ask-user-question-prototype.html new file mode 100644 index 00000000..578f2eb1 --- /dev/null +++ b/design-prototypes/ask-user-question-prototype.html @@ -0,0 +1,378 @@ + + + + + + AskUserQuestion 展开与折叠原型 + + + +
+ + +
+
+ + + + AskUserQuestion 交互讨论 + + + +
+ +
+
+
请先确认这次改动中问答卡片的交互偏好。
+
+
A
+
+
我会通过 AskUserQuestion 收集选择,并在工具输出中保留提交结果。
+ + + + +
+
+
+ +
+
+
+
+
+
AskUserQuestion
+
提交后默认如何展示?(可多选)
+
5 个问题待回答
+
+ + 1 / 5 + + +
+
+ + + +
+
40/500
+
+
+ + +
+
+
+
+
+
+
+
+ + + + + + diff --git a/docs/ask-user-question-interaction-decisions.md b/docs/ask-user-question-interaction-decisions.md new file mode 100644 index 00000000..d665e3fc --- /dev/null +++ b/docs/ask-user-question-interaction-decisions.md @@ -0,0 +1,44 @@ +# AskUserQuestion interaction decisions + +This document records the agreed prototype changes for ACECode Desktop/Web. It describes the intended user-visible behavior; it is not an implementation plan. + +## Pending question + +- The pending question replaces the normal composer in the same composer dock. It must not overlay or coexist with the composer. +- Its left and right edges align exactly with the normal composer card. +- It uses the main UI font-size tokens and the composer's compact visual density rather than fixed, oversized typography. +- The header allows the question text to wrap while progress and navigation controls remain visible. +- The header and footer remain fixed when content is tall; only the options region scrolls. +- The footer uses compact, right-aligned actions. +- When collapsed, the pending question becomes one header row containing the pending-question count, current progress, and expand control. There is no second “继续回答” row. +- Desktop does not expose a separate “直接输入” action while a question is pending. The user must cancel the question or answer through the custom option. + +## Submitted answers + +- A completed AskUserQuestion uses the existing `AskUserQuestion` activity/tool line as its only result header. +- Submitted answers are collapsed by default, including after history reload. +- The entire activity line toggles the answer details and remains keyboard accessible through the existing activity-line interaction. +- Expanded answers appear as a compact inline list directly beneath the activity line. +- The inline list has no independent result heading, summary row, rounded card, success badge, or outer border. +- Questions remain in their original order. Skipped questions are retained and display the muted text “未作答”. +- The independent feedback-card presentation, including “全部提交完成” and “已回答 4/5”, is removed. + +## Cancelled answer + +- Explicit cancellation keeps a non-expandable `AskUserQuestion` activity/tool line. +- Muted plain text “用户已取消回答” appears directly beneath the activity line. +- Results produced by interjection on another interaction surface use the same Desktop/Web presentation text, while the persisted domain outcome remains an interjection. +- Cancellation has no card, large icon, or expand control. + +## Unchanged behavior + +The redesign does not change single-select, multi-select, custom-answer, skip, question navigation, copy, or keyboard semantics. + +## Validation matrix + +Validate all states in: + +- normal and near-minimum chat-pane widths; +- compact, default, and large UI font-size modes; +- light and dark themes; +- long questions, many options, long submitted answers, and skipped questions. diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 286392d0..e8c6d441 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -49,6 +49,9 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '操作未完成:': 'Action did not complete: ', '建议状态更新失败。': 'Could not refresh suggestion status.', '已取消全部回答': 'All answers cancelled', + '用户已取消回答': 'User cancelled the questions', + '选择自定义答案': 'Select custom answer', + '取消自定义答案': 'Unselect custom answer', '仅显示 file_edit / file_write / apply_patch 工具的改动': 'Show only changes made by the file_edit / file_write / apply_patch tools', '国庆节': 'National Day', '下载国庆节主题': 'Download National Day theme', diff --git a/web/src/App.jsx b/web/src/App.jsx index 0dfb4af3..a793ecca 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1990,8 +1990,7 @@ export function App() { && !configRecoveryBlocking && !guidedTourPreparing && !guidedTourRun - && !sessionNavigationPending - && !visibleQuestionReq; + && !sessionNavigationPending; const resolveVisibleQuestion = () => { if (!visibleQuestionReq?.request_id) return; setQuestionReqs((prev) => closePendingQuestionRequest(prev, visibleQuestionReq, { diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 7161c556..a7f60fca 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -4470,6 +4470,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable requestAnimationFrame(() => inputRef.current?.focus()); }, [onQuestionResolve]); + const sidePanelMounted = showSidePanel; const sidePanelNavigationCollapsed = sidePanelCollapsed || sidePanelListCollapsed; const previewScope = useMemo( @@ -4943,51 +4944,58 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable

{homeProjectTitle}

- setExpertPickerOpen(true)} - history={composerHistory} - historyEntries={composerHistoryEntries} - value={composerValue} - onChange={handleComposerChange} - onSubmit={submit} - disabled={!!questionForView} - submitting={homeSubmitting || reasoningSwitching} - placeholder="向 ACECode 描述任务,或输入 / 命令..." - {...composerInputProps} - fileDropManagedExternally - onFileDragActiveChange={setChatFileDropActive} - sessionControls={{ - model: homeModelLabel, - modelOptions, - selectedModelName: homeModelName, - modelLoad: homeModelLoad, - modelSwitching: modelSwitching || reasoningSwitching, - modelRefreshing, - reasoningOptions: composerReasoningOptions(selectedHomeModel, homeReasoningEffort), - reasoningDisabled: busy || homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing, - onReasoningChange: changeComposerReasoning, - onModelChange: changeComposerModel, - onRefreshModels: refreshSessionModels, - onOpenModelSettings, - tokenBudget: homeTokenBudget, - permissionMode, - permissionSwitching, - onPermissionModeChange: changeComposerPermissionMode, - }} - /> + {questionForView ? ( + + ) : ( + setExpertPickerOpen(true)} + history={composerHistory} + historyEntries={composerHistoryEntries} + value={composerValue} + onChange={handleComposerChange} + onSubmit={submit} + submitting={homeSubmitting || reasoningSwitching} + placeholder="向 ACECode 描述任务,或输入 / 命令..." + {...composerInputProps} + fileDropManagedExternally + onFileDragActiveChange={setChatFileDropActive} + sessionControls={{ + model: homeModelLabel, + modelOptions, + selectedModelName: homeModelName, + modelLoad: homeModelLoad, + modelSwitching: modelSwitching || reasoningSwitching, + modelRefreshing, + reasoningOptions: composerReasoningOptions(selectedHomeModel, homeReasoningEffort), + reasoningDisabled: busy || homeSubmitting || composerSubmitting || reasoningSwitching || modelSwitching || modelRefreshing, + onReasoningChange: changeComposerReasoning, + onModelChange: changeComposerModel, + onRefreshModels: refreshSessionModels, + onOpenModelSettings, + tokenBudget: homeTokenBudget, + permissionMode, + permissionSwitching, + onPermissionModeChange: changeComposerPermissionMode, + }} + /> + )}
@@ -5090,9 +5098,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable
- {questionForView && ( - - )} + {createProjectOpen && ( )} - {questionForView && ( - - )} ) : (
- {!questionForView ? ( + {questionForView ? ( + + ) : ( <> - ) : null} + )}
)} )} + ); } diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index 2c97a46a..1c4a3e1c 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -16,6 +16,7 @@ import { selectAnswerCustom, setAnswerCustom, toggleAnswerSelection, + unselectAnswerCustom, } from '../lib/questionPicker.js'; const READABLE_TEXT_STYLE = { overflowWrap: 'anywhere', wordBreak: 'break-word' }; @@ -53,7 +54,8 @@ async function copyText(text) { } } -export function QuestionPicker({ request, onResolve, originLabel = '' }) { +export function QuestionPicker({ request, onResolve, originLabel = '', className = '' }) { + const normalized = useMemo(() => normalizeQuestionRequest(request), [request]); const { questions } = normalized; const [answers, setAnswers] = useState(() => makeInitialAnswers(questions)); @@ -193,6 +195,15 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { updateAnswer(currentIndex, (item) => setAnswerCustom(item, next, isMulti)); }, [currentIndex, isMulti, updateAnswer]); + const toggleCustom = useCallback(() => { + updateAnswer(currentIndex, (item) => item.customSelected + ? unselectAnswerCustom(item) + : selectAnswerCustom(item, isMulti)); + setFocusIndex(customIndex); + setEditingCustom(false); + focusSoon(rootRef); + }, [currentIndex, customIndex, isMulti, updateAnswer]); + const moveFocus = useCallback((delta) => { const count = optionCount + 1; setFocusIndex(Math.min(count - 1, Math.max(0, activeOptionIndex + delta))); @@ -330,6 +341,8 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { : '跳过'; const primaryKeyHint = nav.isLast ? 'Ctrl + Enter' : 'Enter'; const customActive = !!answer.customSelected && answer.custom.trim().length > 0; + // 草稿态:输入了自定义内容但未选中自定义项(例如单选时改选预设选项),文字变灰。 + const customDraft = !answer.customSelected && (answer.custom || '').trim().length > 0; return (
-
- +
{originLabel && (
@@ -353,7 +361,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { )} {!collapsed && (
{question.text} @@ -403,21 +411,13 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
- {collapsed ? ( - - ) : ( + {collapsed ? null : ( <> -
+
{question.options.map((opt, index) => { const selected = answer.selected?.includes(opt.value); - const focused = activeOptionIndex === index; + const focused = focusIndex === index; + const hovered = hoverIndex === index; const copied = copiedIndex === index; return (
@@ -459,7 +464,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { )} - + {opt.label} {opt.recommended && ( @@ -504,24 +509,32 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
- {customActive ? ( ) : ( {customIndex + 1} )} - + setEditingCustom(false)} placeholder="输入你的答案" maxLength={MAX_CUSTOM_LENGTH} - className="min-w-0 flex-1 h-7 bg-transparent text-[13px] text-fg outline-none placeholder:text-fg-mute placeholder:text-[13px]" + className={clsx( + 'min-w-0 flex-1 h-9 bg-transparent text-[14px] outline-none placeholder:text-fg-mute placeholder:text-[13px]', + customDraft ? 'text-fg-mute font-normal' : 'text-fg', + )} + /> {(answer.custom || '').length}/{MAX_CUSTOM_LENGTH}
-
+
+
+ @@ -565,7 +584,6 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { 跳过 )} -
)} diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index c3af4937..1ec5a77a 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -119,6 +119,41 @@ function askUserQuestionText(result) { .join('\n\n'); } +function askUserQuestionItems(result) { + if (!Array.isArray(result?.items)) return []; + return result.items + .filter((item) => item && (item.question || item.answer || item.not_answered || item.notAnswered)) + .map((item) => { + const answer = String(item.answer ?? ''); + const unanswered = item.not_answered === true + || item.notAnswered === true + || !answer.trim() + || answer.trim() === 'Not answered'; + return { + question: String(item.question ?? ''), + answer: unanswered ? '' : answer, + unanswered, + }; + }); +} + +function AskUserQuestionDetails({ items }) { + return ( +
+ {items.map((item, index) => ( +
+
+ {index + 1}. {item.question} +
+
+ {item.unanswered ? 未作答 : item.answer} +
+
+ ))} +
+ ); +} + function joinTooltipParts(...parts) { const text = parts .map((part) => String(part || '').trim()) @@ -158,8 +193,7 @@ function taskCompleteDisplayText(summary, output) { } export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessionRunning = true }) { - useTranslation(); - const [expanded, setExpanded] = useState(false); + const { t: translate } = useTranslation(); const contextIdRef = useRef(''); if (!contextIdRef.current) { contextIdRef.current = `tool-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -185,6 +219,13 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio attachments = [], askUserQuestionResult = null, } = entry || {}; + const shouldExpandAskResult = isDone + && success !== false + && askUserQuestionItems(askUserQuestionResult).length > 0; + const [expanded, setExpanded] = useState(shouldExpandAskResult); + useEffect(() => { + if (shouldExpandAskResult) setExpanded(true); + }, [shouldExpandAskResult]); const attachmentItems = useMemo(() => normalizeAttachmentList(attachments), [attachments]); const genericSummary = useMemo( () => fallbackToolSummary(tool || 'tool', args), @@ -339,6 +380,42 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio // 完成态与运行态共用 ActivityLine;详情仍由 ToolBlock 自己负责。 if (isDone) { + const isAskUserQuestionResult = askUserQuestionResult + && typeof askUserQuestionResult === 'object' + && !Array.isArray(askUserQuestionResult); + const askItems = isAskUserQuestionResult ? askUserQuestionItems(askUserQuestionResult) : []; + const askCancelled = isAskUserQuestionResult + && (askUserQuestionResult.cancelled === true || askUserQuestionResult.interjected === true); + const askSubmitted = isAskUserQuestionResult && success !== false && askItems.length > 0; + if (askCancelled || askSubmitted) { + const askExpanded = expanded; + return ( +
+ } + label={completedSummary.verb || title || tool || 'AskUserQuestion'} + detail={completedSummary.object || ''} + trailing={liveElapsed > 0 ? {formatElapsed(liveElapsed)} : null} + preserveLabel + expandable={!askCancelled && askItems.length > 0} + expanded={askExpanded} + onToggle={!askCancelled && askItems.length > 0 ? toggleExpanded : undefined} + title={askCancelled ? undefined : (askExpanded ? '收起详情' : '展开详情')} + ariaLabel={askCancelled ? undefined : (askExpanded ? '收起详情' : '展开详情')} + /> + {askCancelled ? ( +
{translate('用户已取消回答')}
+ ) : askExpanded ? : null} +
+ ); + } const ok = !!success; const questionFeedback = questionFeedbackForTool(entry); if (questionFeedback) { diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index b7992cfd..5b068a7a 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -685,6 +685,7 @@ export const sourceCatalogs = { "s_4ef515abfdb8690d": "安装包", "s_4f01af9093fc9e59": "无法连接到此网站", "s_4f021f59121c6827": "等待执行", + "s_4f2bf16cc26825e8": "取消自定义答案", "s_4f5b0e563a452c80": "刷新技能列表", "s_4f61317e9307ad3e": "访问被拒", "s_4fca07787f3ef1ec": "移除这个待发送附件?", @@ -771,6 +772,7 @@ export const sourceCatalogs = { "s_57eb46dacc97defb": "在任务列表每一行右侧显示最近活动时间,关闭后仍可在悬停卡片里查看", "s_5804b9cca84304ff": "清空侧边聊天", "s_581454d2ca1c81c7": "连接器暂不可用", + "s_5835048333dad1d6": "选择自定义答案", "s_584f28c1077647a9": "正在取消...", "s_5868e5f7632072d9": "拒绝 {{p0}}", "s_58cec02c603c382d": "可取消本次升级;关闭窗口只会让任务转到后台继续。", @@ -972,6 +974,7 @@ export const sourceCatalogs = { "s_70a0e592c4ca41e3": "加载摘要生成配置失败", "s_70a3058229d0c848": "路径校验失败", "s_70a988a36d385370": "内置工具", + "s_70b6defe7781ee56": "用户已取消回答", "s_70f8d5c04664331c": "选中的目录会作为新的工作区添加到侧栏。", "s_710e98e25c8d52d6": "取消选中会话的归档", "s_710fba34d5940db6": "决策", @@ -1533,7 +1536,6 @@ export const sourceCatalogs = { "s_b1c27820fec23edb": "高", "s_b1c2e6b21639d656": "不隔离网络", "s_b20243e0bf17cccc": "钩子放行", - "s_b22b499ae69d15cc": "继续回答", "s_b25adad93000204a": "计划文件为空", "s_b2749206c4e01861": "旁路提问失败", "s_b2888f5f93782bb7": "加载会话失败:", @@ -2899,6 +2901,7 @@ export const sourceCatalogs = { "s_4ef515abfdb8690d": "Installation package", "s_4f01af9093fc9e59": "Cannot connect to this site", "s_4f021f59121c6827": "Waiting for execution", + "s_4f2bf16cc26825e8": "Unselect custom answer", "s_4f5b0e563a452c80": "Refresh skill list", "s_4f61317e9307ad3e": "Access denied", "s_4fca07787f3ef1ec": "Remove this pending attachment?", @@ -2985,6 +2988,7 @@ export const sourceCatalogs = { "s_57eb46dacc97defb": "Show the last active time on the right of each task row; when off, it is still available in the hover card", "s_5804b9cca84304ff": "Clear side chat", "s_581454d2ca1c81c7": "Connector is temporarily unavailable", + "s_5835048333dad1d6": "Select custom answer", "s_584f28c1077647a9": "Cancelling...", "s_5868e5f7632072d9": "Denied {{p0}}", "s_58cec02c603c382d": "You can cancel this update. Closing the window only moves it to the background.", @@ -3186,6 +3190,7 @@ export const sourceCatalogs = { "s_70a0e592c4ca41e3": "Could not load summary generation settings", "s_70a3058229d0c848": "Path validation failed", "s_70a988a36d385370": "Built-in tools", + "s_70b6defe7781ee56": "User cancelled the questions", "s_70f8d5c04664331c": "The selected folder will be added to the sidebar as a new workspace.", "s_710e98e25c8d52d6": "Unarchive selected sessions", "s_710fba34d5940db6": "Decision", @@ -3747,7 +3752,6 @@ export const sourceCatalogs = { "s_b1c27820fec23edb": "High", "s_b1c2e6b21639d656": "no network isolation", "s_b20243e0bf17cccc": "Allowed by hook", - "s_b22b499ae69d15cc": "Continue answering", "s_b25adad93000204a": "Plan file is empty", "s_b2749206c4e01861": "Side chat failed", "s_b2888f5f93782bb7": "Failed to load session:", diff --git a/web/src/lib/agentBrowserArchitecture.test.js b/web/src/lib/agentBrowserArchitecture.test.js index 80a0e164..376eac7c 100644 --- a/web/src/lib/agentBrowserArchitecture.test.js +++ b/web/src/lib/agentBrowserArchitecture.test.js @@ -533,6 +533,9 @@ run('application state explicitly gates the native Agent Browser surface', () => assert.match(app, /&& !searchOpen/); assert.match(app, /&& !updateDialogOpen/); assert.match(app, /&& !desktopCloseDialogOpen/); + // 提问待答时不关闭原生表面:QuestionPicker 内联在左侧聊天列,不覆盖右侧预览区, + // 若一并关闭会把浏览器预览(原生 WebView 子窗口)整体隐藏。 + assert.doesNotMatch(app, /&& !visibleQuestionReq/); assert.match(app, /nativeSurfacesVisible=\{nativeSurfacesVisible\}/); assert.match(chatView, /nativeSurfacesVisible = true/); assert.match(chatView, / { 'reasoning mutation must disable submission without making the editor read-only', ); - // 主页 composer 的 disabled 只剩「有待回答的问题」这一个来源(主页没有会话 - // 可以承接插话);会话 composer 在提问挂起时**根本不渲染** —— dock 整体换成 - // 提问框,所以它既不需要 disabled,也不该留「请先回答上方问题」这类旧提示。 - assert.equal( - (chatView.match(/disabled=\{!!questionForView\}/g) || []).length, - 1, - '只有主页 composer 由 questionForView 决定只读', - ); - assert.match(chatView, /
\s*\{!questionForView \? \(/); + // 主页与会话 composer 都在提问挂起时**根本不渲染** —— dock 整体换成 + // 提问框,所以它们不需要 disabled,也不该留「请先回答上方问题」这类旧提示。 + assert.doesNotMatch(chatView, /disabled=\{!!questionForView\}/); + assert.match(chatView, /
\s*\{questionForView \? \(/); + assert.doesNotMatch(chatView, /请先回答上方问题/); assert.match(chatView, /submitting=\{composerSubmitting \|\| reasoningSwitching\}/); assert.match(chatView, /submitting=\{homeSubmitting \|\| reasoningSwitching\}/); @@ -77,17 +73,18 @@ run('提问挂起时 composer 整体让位给提问框,不留插话入口', () = // 提问框承担提问期间唯一的交互面:提交/取消经 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(' ({ t: (text) => text }), renderMarkdown: () => '', VsIcon: () => null, ToolSummaryIcon: () => null, @@ -215,13 +215,15 @@ run('连续提问时待答工具不复用上一题的提交或取消反馈', () } }); -run('共享工具行后紧跟一张反馈卡,无需 ChatView 回调', () => { +run('共享工具行默认展开已提交结果,仍保留折叠入口', () => { 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.ok(html.indexOf('data-tool-activity') < html.indexOf('data-question-feedback')); - assert.match(html, /全部提交完成/); - assert.match(html, /(多选)/); + assert.match(html, /data-ask-user-question-result="true"/); + assert.match(html, /data-desktop-tool-expanded="true"/); + assert.match(html, /你最喜欢的语言\?|Rust|目标平台\?|Windows, Linux/); + assert.match(html, /data-tool-activity/); + assert.doesNotMatch(html, /全部提交完成|(多选)/); }); run('切换会话后只渲染新会话自己的问答结果', () => { @@ -291,9 +293,9 @@ run('工具改名或历史页缺少调用名时结构化结果仍渲染反馈卡 // 触发场景:TUI/IM 通道里 AskUserQuestion 挂起时用户直接输入插话,daemon 落盘 // ask_user_question_result={interjected:true, items:[]} 且 success=true。 -// 期望:渲染「已改为直接输入,取消作答」卡,而不是取消卡,也不是 Q/A 确认卡; +// 期望:与显式取消使用同一行内展示文案,但保留 interject 数据标记; // 历史页重载后(工具改名、无调用名)同样可恢复。 -run('插话取消作答渲染专属反馈卡,且改名后仍可恢复', () => { +run('插话取消作答复用取消展示,且改名后仍可恢复', () => { const item = { kind: 'tool', tool: { @@ -306,8 +308,8 @@ run('插话取消作答渲染专属反馈卡,且改名后仍可恢复', () => { 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.ok(html.includes('用户已取消回答')); + assert.doesNotMatch(html, /data-question-feedback="cancel"|data-question-feedback="submit"|全部提交完成/); for (const tool of ['', 'request_input']) { const renamed = { ...item, tool: { ...item.tool, tool } }; assert.equal(questionFeedbackForItem(renamed)?.kind, 'interject'); diff --git a/web/src/lib/questionFeedbackPersistence.test.js b/web/src/lib/questionFeedbackPersistence.test.js index a98e6479..72053c23 100644 --- a/web/src/lib/questionFeedbackPersistence.test.js +++ b/web/src/lib/questionFeedbackPersistence.test.js @@ -254,7 +254,11 @@ 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), /A, B\nC/); + const html = renderQuestionToolForTest(item); + assert.match(html, /data-question-feedback="submit"/); + assert.match(html, /data-ask-user-question-result="true"/); + assert.match(html, /data-desktop-tool-expanded="true"/); + assert.match(html, /A, B\nC/, '历史问答结果默认展开并保留完整答案'); }); console.log('questionFeedbackPersistence tests passed'); diff --git a/web/src/lib/questionPickerInteraction.test.js b/web/src/lib/questionPickerInteraction.test.js index 1328a750..5a5c65f4 100644 --- a/web/src/lib/questionPickerInteraction.test.js +++ b/web/src/lib/questionPickerInteraction.test.js @@ -189,10 +189,22 @@ run('Esc arming is isolated between requests and its timer is cleared on unmount assert.equal(picker.timers.size, 0); }); +run('multi-select custom answer can be unchecked while retaining a muted draft', () => { + const picker = harness([q1]); + input(picker.render()).props.onChange({ target: { value: 'retained draft' } }); + button(picker.render(), '取消自定义答案').props.onClick(); + const draftInput = input(picker.render()); + assert.match(draftInput.props.className, /text-fg-mute/); + assert.equal(draftInput.props.value, 'retained draft'); + key(picker, 'Enter', { ctrlKey: true }); + assert.equal(picker.sent[0].answers[0].custom_text, undefined); + picker.unmount(); +}); + 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'); + button(picker.render(), '取消自定义答案').props.onClick(); input(picker.render()).props.onFocus(); key(picker, 'Enter', { ctrlKey: true }); assert.equal(picker.sent[0].answers[0].custom_text, 'retained draft'); diff --git a/web/src/lib/questionPickerLayout.test.js b/web/src/lib/questionPickerLayout.test.js new file mode 100644 index 00000000..3a3da683 --- /dev/null +++ b/web/src/lib/questionPickerLayout.test.js @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const picker = fs.readFileSync(path.join(srcRoot, 'components/QuestionPicker.jsx'), 'utf8'); +const chatView = fs.readFileSync(path.join(srcRoot, 'components/ChatView.jsx'), 'utf8'); +const toolBlock = fs.readFileSync(path.join(srcRoot, 'components/ToolBlock.jsx'), 'utf8'); +const rootClasses = picker.match(/clsx\('([^']*rounded-\[14px\][^']*)'/)?.[1] || ''; + +assert.ok(rootClasses, 'QuestionPicker root classes must be discoverable'); +assert.match(rootClasses, /rounded-\[14px\]/); +assert.match(rootClasses, /ace-shadow-lg/); +assert.doesNotMatch( + rootClasses, + /(?:^|\s)w-full(?:\s|$)/, + 'QuestionPicker margins must fit inside the chat column instead of overflowing its width', +); + +const homeComposer = chatView.slice( + chatView.indexOf('
'), +); +assert.match(homeComposer, /questionForView\s*\?\s*\(/); +assert.match(homeComposer, /'); +const dockEnd = chatView.indexOf('\s*]*aria-hidden="true">[\s\S]*?M20 15a3/); +assert.match(picker, /min-h-11 shrink-0 px-4 py-2/); +assert.match(picker, /group flex items-center gap-3 rounded-lg px-3 py-2\.5/); +assert.match(picker, /border border-transparent hover:bg-accent-bg/); +assert.doesNotMatch(picker, /hover:border-accent/); +assert.doesNotMatch(picker, /bg-accent-bg border border-accent/); +assert.match(picker, /const focused = focusIndex === index/); +assert.match(picker, /const hovered = hoverIndex === index/); +assert.match(picker, /hovered\s*\?\s*'border border-transparent bg-accent-bg'/); + +assert.match(toolBlock, /const isAskUserQuestionResult = askUserQuestionResult/); +assert.match(toolBlock, /translate\('用户已取消回答'\)/); +assert.doesNotMatch(toolBlock, /String\.fromCharCode/); + +console.log('questionPickerLayout tests passed'); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index af71b8a4..a06fc839 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -1,5 +1,7 @@ import './questionPicker.test.js'; +import './questionPickerLayout.test.js'; import './questionPickerInteraction.test.js'; + import './questionFeedback.test.js'; import './questionFeedbackPersistence.test.js'; import './pendingQuestions.test.js';