From a113489a4a4139cfab416fa1fa6d444d747760f6 Mon Sep 17 00:00:00 2001 From: Trae User Date: Wed, 16 Sep 2026 18:28:28 +0800 Subject: [PATCH 1/7] feat(web): refine AskUserQuestion output prototype --- .../ask-user-question-prototype.html | 378 ++++++++++++++++++ web/src/App.jsx | 3 +- web/src/components/QuestionFeedbackCard.jsx | 29 +- web/src/components/QuestionPicker.jsx | 7 +- web/src/lib/agentBrowserArchitecture.test.js | 3 + 5 files changed, 403 insertions(+), 17 deletions(-) create mode 100644 design-prototypes/ask-user-question-prototype.html 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/web/src/App.jsx b/web/src/App.jsx index 3e878882..7ddedffb 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1978,8 +1978,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/QuestionFeedbackCard.jsx b/web/src/components/QuestionFeedbackCard.jsx index 9a4d73ee..6e765eb5 100644 --- a/web/src/components/QuestionFeedbackCard.jsx +++ b/web/src/components/QuestionFeedbackCard.jsx @@ -1,8 +1,9 @@ // AskUserQuestion 提交/取消/插话后的反馈卡(设计稿样式): -// - submit:顶部居中绿色对勾圆 + 「全部提交完成」+ 逐题 Q&A 清单(未作答灰字) -// - cancel:顶部居中灰圆叉 + 「已取消全部回答」 +// - submit:左对齐绿色对勾圆 + 「全部提交完成」+ 逐题 Q&A 清单(未作答灰字) +// - cancel:左对齐灰圆叉 + 「已取消全部回答」 // - interject:单行灰字提示「已改为直接输入,取消作答」(TUI/IM 等通道把问题 // 以「用户改为直接输入」收掉时落盘的 interjected 标记) +// 结果态卡片无投影,与对话流气泡融合(设计稿 .ask-box:has(.ask-summary/.ask-canceled))。 import { VsIcon } from './Icon.jsx'; export function QuestionFeedbackCard({ feedback }) { @@ -29,32 +30,32 @@ export function QuestionFeedbackCard({ feedback }) {
{cancelled ? ( -
- - +
+ + -

已取消全部回答

+

已取消全部回答

) : (
-
- - +
+ + -

全部提交完成

+

全部提交完成

{summary.length > 0 && ( -
    +
      {summary.map((item, index) => ( -
    • +
    • {index + 1}. {item?.question} {item?.multiSelect ? '(多选)' : ''}
      -
      +
      {item?.notAnswered ? ( 未作答 ) : ( diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index 3f58d42f..1ee41c4f 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -313,6 +313,8 @@ export function QuestionPicker({ request, onResolve, onFeedback, 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 (
      {(answer.custom || '').length}/{MAX_CUSTOM_LENGTH} diff --git a/web/src/lib/agentBrowserArchitecture.test.js b/web/src/lib/agentBrowserArchitecture.test.js index 1ad1dd4e..9f92ee9b 100644 --- a/web/src/lib/agentBrowserArchitecture.test.js +++ b/web/src/lib/agentBrowserArchitecture.test.js @@ -468,6 +468,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, / Date: Wed, 16 Sep 2026 21:03:12 +0800 Subject: [PATCH 2/7] fix(web): align AskUserQuestion interaction --- ...ask-user-question-interaction-decisions.md | 44 ++++++ web/scripts/i18n-en-overrides.mjs | 1 + web/src/components/ChatView.jsx | 145 ++++++++---------- web/src/components/QuestionPicker.jsx | 44 +++--- web/src/components/ToolBlock.jsx | 70 ++++++++- web/src/i18n/sourceCatalog.generated.js | 4 +- .../composerEditabilityArchitecture.test.js | 23 ++- web/src/lib/questionPickerLayout.test.js | 43 ++++++ web/src/lib/runTests.js | 1 + 9 files changed, 250 insertions(+), 125 deletions(-) create mode 100644 docs/ask-user-question-interaction-decisions.md create mode 100644 web/src/lib/questionPickerLayout.test.js 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 87f5800a..cd5165a4 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -26,6 +26,7 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '操作未完成:': 'Action did not complete: ', '建议状态更新失败。': 'Could not refresh suggestion status.', '已取消全部回答': 'All answers cancelled', + '用户已取消回答': 'User cancelled the questions', '仅显示 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/components/ChatView.jsx b/web/src/components/ChatView.jsx index 49f880a5..e1d16443 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -48,7 +48,6 @@ import '../styles/side-chat.css'; import { GitSessionPill } from './GitSessionPill.jsx'; import { LspIndicator } from './LspIndicator.jsx'; import { QuestionPicker } from './QuestionPicker.jsx'; -import { QuestionFeedbackCard } from './QuestionFeedbackCard.jsx'; import { PermissionCard } from './PermissionCard.jsx'; import { StickyUserContext } from './StickyUserContext.jsx'; import { SessionContentLoading } from './SessionContentLoading.jsx'; @@ -94,7 +93,6 @@ import { updateQueuedInputContent, } from '../lib/chatInputQueue.js'; import { findStickyUserContext, sameStickyUserContext, scrollTopForStickySourceRow } from '../lib/stickyUserContext.js'; -import { lastAskUserQuestionItem, questionFeedbackForItem } from '../lib/questionFeedback.js'; import { loadTranscriptHistory, useSessionTranscript } from '../lib/sessionTranscript.js'; import { createSingleWriterStore } from '../lib/singleWriterStore.js'; import { projectCollapsedTranscriptItems } from '../lib/transcriptProjection.js'; @@ -591,9 +589,6 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable const subagentTasks = useSubagentTasks(sid, { onSpawnStart: openSubagentPanelForSpawn, }); - // 提交/取消 AskUserQuestion 后,在消息流中跟随 AskUserQuestion 消息展示的 - // 反馈卡(全部提交完成 / 已取消全部回答)。 - const [questionFeedback, setQuestionFeedback] = useState(null); // 当前视图可见的待答问题。提问挂起期间 composer dock 由提问框整体替换 // (方案 A),所以它只驱动渲染,不再参与 submit 的分支判定。 const questionForView = useMemo(() => { @@ -4283,32 +4278,14 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable subagentTasks.tasks, ]); - const resolveQuestion = useCallback((feedback) => { - if (feedback) setQuestionFeedback(feedback); + const resolveQuestion = useCallback(() => { onQuestionResolve?.(); requestAnimationFrame(() => inputRef.current?.focus()); }, [onQuestionResolve]); - const handleQuestionFeedback = useCallback((feedback) => { - if (feedback) setQuestionFeedback(feedback); - }, []); - - // 反馈卡按 item 就地派生:每条 AskUserQuestion 工具消息用自己落盘的元数据 - // 生成卡片。不缓存锚点 id —— 回合结束时 transcript self-heal 会用新 id 覆写 - // 最近一轮,任何缓存的锚点都会失效并让卡片消失。 - const latestAskUserQuestionItemId = useMemo(() => { - const host = lastAskUserQuestionItem(rawItems); - return host ? String(host.id ?? '') : ''; - }, [rawItems]); - - const renderFeedbackAfterQuestion = useCallback((it) => { - if (!questionFeedback && !it?.tool?.askUserQuestionResult) return null; - const feedback = questionFeedbackForItem(it, { - transient: questionFeedback, - allowTransient: String(it?.id ?? '') === latestAskUserQuestionItemId, - }); - return feedback ? : null; - }, [latestAskUserQuestionItemId, questionFeedback]); + // Results are rendered from persisted ToolBlock metadata; retain the callback + // prop for picker compatibility without creating a transient feedback card. + const handleQuestionFeedback = useCallback(() => {}, []); const sidePanelMounted = showSidePanel; const sidePanelNavigationCollapsed = sidePanelCollapsed || sidePanelListCollapsed; @@ -4804,47 +4781,55 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable

      {homeProjectTitle}

      - setExpertPickerOpen(true)} - history={composerHistory} - value={composerValue} - onChange={handleComposerChange} - onSubmit={submit} - disabled={!!questionForView} - submitting={homeSubmitting} - placeholder="向 ACECode 描述任务,或输入 / 命令..." - {...composerInputProps} - fileDropManagedExternally - onFileDragActiveChange={setChatFileDropActive} - sessionControls={{ - model: homeModelLabel, - modelOptions, - selectedModelName: homeModelName, - modelLoad: homeModelLoad, - modelSwitching, - modelRefreshing, - onModelChange: changeComposerModel, - onRefreshModels: refreshSessionModels, - onOpenModelSettings, - tokenBudget: homeTokenBudget, - permissionMode, - permissionSwitching, - onPermissionModeChange: changeComposerPermissionMode, - }} - /> + {questionForView ? ( + + ) : ( + setExpertPickerOpen(true)} + history={composerHistory} + value={composerValue} + onChange={handleComposerChange} + onSubmit={submit} + submitting={homeSubmitting} + placeholder="向 ACECode 描述任务,或输入 / 命令..." + {...composerInputProps} + fileDropManagedExternally + onFileDragActiveChange={setChatFileDropActive} + sessionControls={{ + model: homeModelLabel, + modelOptions, + selectedModelName: homeModelName, + modelLoad: homeModelLoad, + modelSwitching, + modelRefreshing, + onModelChange: changeComposerModel, + onRefreshModels: refreshSessionModels, + onOpenModelSettings, + tokenBudget: homeTokenBudget, + permissionMode, + permissionSwitching, + onPermissionModeChange: changeComposerPermissionMode, + }} + /> + )}
      @@ -4947,9 +4932,6 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable
      - {questionForView && ( - - )} {createProjectOpen && ( ( (turnFileListPlacement.before.get(it.id) || []).map((set) => (
      )} - {questionForView && ( - - )} - ) : (
      - {!questionForView ? ( + {questionForView ? ( + + ) : ( <> - ) : null} + )}
      )} normalizeQuestionRequest(request), [request]); const { questions } = normalized; const [answers, setAnswers] = useState(() => makeInitialAnswers(questions)); @@ -108,7 +107,6 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const cancel = useCallback(() => { connection.sendQuestionAnswer(buildQuestionCancelPayload(normalized)); - onFeedback?.({ kind: 'cancel' }); resolve(); }, [normalized, onFeedback, resolve]); @@ -122,7 +120,6 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const state = getNavigationState(currentIndex, questions, answers); if (!state.canSubmit) return; connection.sendQuestionAnswer(buildQuestionAnswerPayload(normalized, questions, answers)); - onFeedback?.({ kind: 'submit', summary: buildQuestionSummary(questions, answers) }); resolve(); }, [answers, currentIndex, normalized, onFeedback, questions, resolve]); @@ -322,7 +319,7 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' tabIndex={-1} onKeyDown={onKeyDown} aria-label="AskUserQuestion" - className="mx-2.5 mb-2 shrink min-h-0 w-full rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col" + className={clsx('mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col', className)} >
      @@ -383,16 +380,7 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = '
      - {collapsed ? ( - - ) : ( + {collapsed ? null : ( <>
      {question.options.map((opt, index) => { @@ -416,16 +404,18 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' style={SELECTABLE_OPTION_STYLE} className={clsx( 'group flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition', - (selected || focused) - ? 'bg-surface-hi' - : 'hover:bg-surface-hi', + selected + ? 'bg-accent-bg border border-accent text-accent' + : focused + ? 'bg-accent-bg border border-accent' + : 'border border-transparent hover:bg-accent-bg hover:border-accent', )} > @@ -482,14 +472,18 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = '
      @@ -517,7 +511,8 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' {(answer.custom || '').length}/{MAX_CUSTOM_LENGTH}
      -
      +
      +
      )} diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index ad4afe8c..6a0a0751 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -117,6 +117,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,7 +193,7 @@ function taskCompleteDisplayText(summary, output) { export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessionRunning = true }) { - useTranslation(); + const { t: translate } = useTranslation(); const [expanded, setExpanded] = useState(false); const contextIdRef = useRef(''); if (!contextIdRef.current) { @@ -339,6 +374,39 @@ 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; return (
      { 'homeSubmitting 进 disabled 会让主页输入框在建会话期间变成只读', ); - // 主页 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\}/); assert.match(chatView, /submitting=\{homeSubmitting\}/); @@ -71,16 +66,16 @@ run('提问挂起时 composer 整体让位给提问框,不留插话入口', () = // 提问框承担提问期间唯一的交互面:提交/取消经 resolveQuestion 回流,结果由 // onFeedback 落成反馈卡。 - 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(''), +); +assert.match(homeComposer, /questionForView\s*\?\s*\(/); +assert.match(homeComposer, /'); +const dockEnd = chatView.indexOf('\s* Date: Wed, 16 Sep 2026 22:20:23 +0800 Subject: [PATCH 3/7] fix(web): restore AskUserQuestion picker styling --- web/src/components/QuestionPicker.jsx | 26 +++++++++--------------- web/src/lib/questionPickerLayout.test.js | 5 +++++ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index ae760828..22fd1e8e 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -340,15 +340,9 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { tabIndex={-1} onKeyDown={onKeyDown} aria-label="AskUserQuestion" - className="mx-2.5 mb-2 shrink min-h-0 rounded-lg border border-border bg-surface outline-none overflow-hidden flex flex-col" - + className="mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col" > -
      - +
      {originLabel && (
      @@ -357,7 +351,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { )} {!collapsed && (
      {question.text} @@ -409,7 +403,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { {collapsed ? null : ( <> -
      +
      {question.options.map((opt, index) => { const selected = answer.selected?.includes(opt.value); const focused = activeOptionIndex === index; @@ -433,7 +427,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { }} style={SELECTABLE_OPTION_STYLE} className={clsx( - 'group flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition', + 'group flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition', selected ? 'bg-accent-bg border border-accent text-accent' : focused @@ -444,7 +438,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { > - + {opt.label} {opt.recommended && ( @@ -502,7 +496,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
      ]*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(toolBlock, /const isAskUserQuestionResult = askUserQuestionResult/); assert.match(toolBlock, /translate\('用户已取消回答'\)/); From 311c9c0035b14394edbed9525a9d222909b2e07b Mon Sep 17 00:00:00 2001 From: Trae User Date: Wed, 16 Sep 2026 23:13:07 +0800 Subject: [PATCH 4/7] fix(web): simplify question option hover --- web/src/components/QuestionPicker.jsx | 4 ++-- web/src/lib/questionPickerLayout.test.js | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index 22fd1e8e..93e18259 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -432,7 +432,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { ? 'bg-accent-bg border border-accent text-accent' : focused ? 'bg-accent-bg border border-accent' - : 'border border-transparent hover:bg-accent-bg hover:border-accent', + : 'border border-transparent hover:bg-accent-bg', )} > @@ -501,7 +501,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { ? 'bg-accent-bg border border-accent' : focusIndex === customIndex ? 'bg-accent-bg border border-accent' - : 'border border-transparent hover:bg-accent-bg hover:border-accent', + : 'border border-transparent hover:bg-accent-bg', )} > diff --git a/web/src/lib/questionPickerLayout.test.js b/web/src/lib/questionPickerLayout.test.js index d5b6971f..97b1d8fe 100644 --- a/web/src/lib/questionPickerLayout.test.js +++ b/web/src/lib/questionPickerLayout.test.js @@ -40,6 +40,8 @@ assert.doesNotMatch(picker, /font-medium bg-fg text-bg/); assert.doesNotMatch(picker, /]*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.match(toolBlock, /const isAskUserQuestionResult = askUserQuestionResult/); assert.match(toolBlock, /translate\('用户已取消回答'\)/); From 5624579a8ec14b9b59630b34348e6fec5e573ca1 Mon Sep 17 00:00:00 2001 From: Trae User Date: Wed, 16 Sep 2026 23:52:08 +0800 Subject: [PATCH 5/7] fix(web): polish AskUserQuestion selection --- web/src/components/ChatView.jsx | 1 + web/src/components/QuestionPicker.jsx | 36 +++++++++++++------ web/src/lib/questionPickerInteraction.test.js | 14 +++++++- web/src/lib/questionPickerLayout.test.js | 9 +++-- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 94e1634c..5655281c 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -5412,6 +5412,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable request={questionForView} onResolve={resolveQuestion} originLabel={questionOriginLabel} + className="mx-2.5" /> ) : ( <> diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index 93e18259..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,7 @@ 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; @@ -194,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))); @@ -340,7 +350,7 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { tabIndex={-1} onKeyDown={onKeyDown} aria-label="AskUserQuestion" - className="mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col" + className={clsx('mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col', className)} >
      @@ -406,7 +416,8 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) {
      {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 (
      @@ -498,27 +511,30 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { className={clsx( 'flex items-center gap-3 rounded-lg px-3 py-2.5 transition', customActive - ? 'bg-accent-bg border border-accent' + ? 'bg-accent-bg border border-transparent' : focusIndex === customIndex - ? 'bg-accent-bg border border-accent' + ? 'bg-accent-bg border border-transparent' : 'border border-transparent hover:bg-accent-bg', )} > - {customActive ? ( ) : ( {customIndex + 1} )} - + { + 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 index 97b1d8fe..3a3da683 100644 --- a/web/src/lib/questionPickerLayout.test.js +++ b/web/src/lib/questionPickerLayout.test.js @@ -7,7 +7,7 @@ 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(/className="([^"]*border-border[^\"]*overflow-hidden[^\"]*)"/)?.[1] || ''; +const rootClasses = picker.match(/clsx\('([^']*rounded-\[14px\][^']*)'/)?.[1] || ''; assert.ok(rootClasses, 'QuestionPicker root classes must be discoverable'); assert.match(rootClasses, /rounded-\[14px\]/); @@ -32,8 +32,9 @@ const dockEnd = chatView.indexOf('\s* Date: Thu, 17 Sep 2026 00:31:48 +0800 Subject: [PATCH 6/7] fix(web): localize custom answer controls --- web/scripts/i18n-en-overrides.mjs | 2 ++ web/src/i18n/sourceCatalog.generated.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index b23bd6d8..df4089e6 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -50,6 +50,8 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '建议状态更新失败。': '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/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 425530d7..7774f4bd 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -630,6 +630,7 @@ export const sourceCatalogs = { "s_4ef515abfdb8690d": "安装包", "s_4f01af9093fc9e59": "无法连接到此网站", "s_4f021f59121c6827": "等待执行", + "s_4f2bf16cc26825e8": "取消自定义答案", "s_4f5b0e563a452c80": "刷新技能列表", "s_4fca07787f3ef1ec": "移除这个待发送附件?", "s_4fd4f40094d40891": "正在删除…", @@ -707,6 +708,7 @@ export const sourceCatalogs = { "s_57eb46dacc97defb": "在任务列表每一行右侧显示最近活动时间,关闭后仍可在悬停卡片里查看", "s_5804b9cca84304ff": "清空侧边聊天", "s_581454d2ca1c81c7": "连接器暂不可用", + "s_5835048333dad1d6": "选择自定义答案", "s_584f28c1077647a9": "正在取消...", "s_58cec02c603c382d": "可取消本次升级;关闭窗口只会让任务转到后台继续。", "s_58e21b8737ccbc49": "新建会话", @@ -2653,6 +2655,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_4fca07787f3ef1ec": "Remove this pending attachment?", "s_4fd4f40094d40891": "Deleting…", @@ -2730,6 +2733,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_58cec02c603c382d": "You can cancel this update. Closing the window only moves it to the background.", "s_58e21b8737ccbc49": "New session", From 610dba400b7cbb705ac41063a7b7b2d50d8350cc Mon Sep 17 00:00:00 2001 From: Trae User Date: Thu, 17 Sep 2026 10:03:05 +0800 Subject: [PATCH 7/7] fix(web): expand AskUserQuestion results by default --- web/src/components/ToolBlock.jsx | 8 +++++++- web/src/lib/questionFeedback.test.js | 4 +++- web/src/lib/questionFeedbackPersistence.test.js | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index cd3ffa02..1ec5a77a 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -194,7 +194,6 @@ function taskCompleteDisplayText(summary, output) { export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessionRunning = true }) { const { t: translate } = useTranslation(); - const [expanded, setExpanded] = useState(false); const contextIdRef = useRef(''); if (!contextIdRef.current) { contextIdRef.current = `tool-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -220,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), diff --git a/web/src/lib/questionFeedback.test.js b/web/src/lib/questionFeedback.test.js index 3d1c6631..9df59d3b 100644 --- a/web/src/lib/questionFeedback.test.js +++ b/web/src/lib/questionFeedback.test.js @@ -215,11 +215,13 @@ 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.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, /全部提交完成|(多选)/); }); diff --git a/web/src/lib/questionFeedbackPersistence.test.js b/web/src/lib/questionFeedbackPersistence.test.js index b202ef90..72053c23 100644 --- a/web/src/lib/questionFeedbackPersistence.test.js +++ b/web/src/lib/questionFeedbackPersistence.test.js @@ -257,7 +257,8 @@ await run('历史问答保留多选标记和完整答案', () => { const html = renderQuestionToolForTest(item); assert.match(html, /data-question-feedback="submit"/); assert.match(html, /data-ask-user-question-result="true"/); - assert.doesNotMatch(html, /A, B\nC/, '答案默认折叠,只在用户展开活动行后显示'); + assert.match(html, /data-desktop-tool-expanded="true"/); + assert.match(html, /A, B\nC/, '历史问答结果默认展开并保留完整答案'); }); console.log('questionFeedbackPersistence tests passed');