From c998f336193deacb9281688aae3dce078c9d70c3 Mon Sep 17 00:00:00 2001 From: Trae User Date: Tue, 15 Sep 2026 20:56:24 +0800 Subject: [PATCH 1/2] feat(web): align AskUserQuestion with TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web 端提问框重做,保持「方案 A」:提问挂起期间 composer dock 整体让位给提问框, 提交/取消后恢复;master 的 composer「直接输入 = 插话」分支随之成为不可达代码, 一并移除(daemon 插话端点、排队卡片的插话按钮与转录 interjected 标记保留)。 反馈卡改为按 AskUserQuestion 工具消息的落盘元数据就地派生:回合结束的 transcript self-heal 会用新 id 覆写最近一轮,缓存锚点会让卡片消失,因此取消 标记(cancelled)、插话标记(interjected)与每题 multi_select 都必须落盘; daemon 回填答案时派生 not_answered(与 TUI 同规则),让「跳过」在模型侧呈现为 Not answered。判定不看工具名 —— 历史页可能只剩工具结果消息或工具已改名。 - questionPicker.js:导航、非末题跳过、末题防误提交、草稿与选中态分离、Enter 优先级 - QuestionPicker:低饱和配色、黑底白勾序号、hover 复制/回车、内联自定义答案、 折叠态,Tab/数字键/方向键/Enter/Ctrl+Enter/Esc 快捷键 - QuestionFeedbackCard:提交/取消/插话三种形态;ToolBlock 不再渲染旧结果卡 - 测试:questionPicker / questionFeedback 持久化与改名·插话覆盖、 composerEditabilityArchitecture 守「提交在途不改 disabled」与 composer 让位契约、 tests/tool/ask_user_question_tool_test.cpp 断言 cancelled 与 multi_select 验证:web `pnpm test` 与 `pnpm build`(含 lookbehind 兼容检查)通过; C++ `*AskUserQuestion*`、`AskUserQuestionPrompter.*`、 `AgentLoopQuestionInterjection.*` 共 54 个用例通过。 --- docs/design/web-ask-user-question-design.md | 322 ++++++++++++ .../index.html | 399 ++++++++++++++ ...9-13-web-ask-user-question-requirements.md | 243 +++++++++ .../.openspec.yaml | 2 + .../proposal.md | 32 ++ .../specs/web-ask-user-question/spec.md | 58 +++ .../redesign-web-ask-user-question/tasks.md | 41 ++ src/agent_loop.cpp | 4 + src/tool/ask_user_question_tool.cpp | 28 +- src/tool/ask_user_question_tool.hpp | 3 +- tests/tool/ask_user_question_tool_test.cpp | 24 + web/scripts/i18n-en-overrides.mjs | 13 + web/src/components/ChatView.jsx | 113 ++-- web/src/components/QuestionFeedbackCard.jsx | 72 +++ web/src/components/QuestionPicker.jsx | 493 ++++++++++++------ web/src/components/ToolBlock.jsx | 111 ---- web/src/components/TranscriptItems.jsx | 3 + web/src/i18n/sourceCatalog.generated.js | 42 +- .../composerEditabilityArchitecture.test.js | 79 +-- web/src/lib/questionFeedback.js | 106 +++- web/src/lib/questionFeedback.test.js | 312 ++++++++--- .../lib/questionFeedbackPersistence.test.js | 8 +- web/src/lib/questionPicker.js | 73 ++- web/src/lib/questionPicker.test.js | 126 ++++- 24 files changed, 2191 insertions(+), 516 deletions(-) create mode 100644 docs/design/web-ask-user-question-design.md create mode 100644 docs/prototypes/web-ask-user-question-alignment/index.html create mode 100644 docs/specs/2026-09-13-web-ask-user-question-requirements.md create mode 100644 openspec/changes/redesign-web-ask-user-question/.openspec.yaml create mode 100644 openspec/changes/redesign-web-ask-user-question/proposal.md create mode 100644 openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md create mode 100644 openspec/changes/redesign-web-ask-user-question/tasks.md create mode 100644 web/src/components/QuestionFeedbackCard.jsx diff --git a/docs/design/web-ask-user-question-design.md b/docs/design/web-ask-user-question-design.md new file mode 100644 index 00000000..b86e580d --- /dev/null +++ b/docs/design/web-ask-user-question-design.md @@ -0,0 +1,322 @@ +# Web AskUserQuestion 设计文档 + +**日期:** 2026-09-14 +**设计系统:** Apple Design System +**关联规格:** [WEB AskUserQuestion 对齐 TUI 需求规格](../specs/2026-09-13-web-ask-user-question-requirements.md) +**原型文件:** `ask-user-question-prototype.design` + +--- + +## 1. 设计概览 + +AskUserQuestion(提问框)是 Web 端用于向用户收集结构化答案的组件。设计遵循 Apple Design System 的克制、简洁、高质感原则,整体采用低饱和中性配色,强调内容而非装饰。 + +### 设计原则 + +| 原则 | 说明 | +|------|------| +| **克制弱化** | 不使用高饱和品牌色,主操作采用深灰/黑色,降低视觉压迫感 | +| **高效优先** | 为高频用户设计键盘捷径,Enter 一键完成选择并下一题 | +| **渐进发现** | 回车图标作为视觉暗示,引导用户发现键盘快捷键,而非直接展示说明 | +| **防误操作** | 末题提交需 Ctrl+Enter,防止连续答题时误触提交全部 | + +--- + +## 2. 视觉规范 + +### 2.1 颜色 + +| Token | 值 | 用途 | +|-------|----|------| +| `--label` | `#000000` | 主标题、选项标题 | +| `--secondary-label` | `#86868B` / `rgba(0,0,0,0.5)` | 说明文字、序号、字符计数 | +| `--tertiary-label` | `rgba(0,0,0,0.3)` | 折叠态提示、辅助信息 | +| `--system-background` | `#FFFFFF` | 提问框背景 | +| `--secondary-system-background` | `#F5F5F7` | 悬停行背景、输入框背景 | +| `--separator` | `rgba(0,0,0,0.08)` | 分隔线、边框 | +| `--fill-secondary` | `rgba(0,0,0,0.1)` | 次要按钮背景、badge 未选中态 | +| `--selected-bg` | `rgba(0,0,0,0.04)` | 选中行浅底色 | +| `--label-dark` | `#FFFFFF` | 选中 badge 文字(白勾) | +| `--system-green` | `#34C759` | 复制成功对勾 | +| `--primary-btn-bg` | `#1D1D1F` | 主操作按钮背景(低饱和深灰) | + +### 2.2 字体 + +| 用途 | 字号 | 字重 | 行高 | +|------|------|------|------| +| 题目标题 | 17px | 600 (semibold) | 1.4 | +| 选项标题 | 15px | 600 (semibold) | 1.4 | +| 选项说明 | 13px | 400 (regular) | 1.5 | +| 底部按钮 | 14px | 500 (medium) | 1 | +| 导航序号 | 13px | 500 (medium) | 1 | +| 快捷键提示 | 10px | 500 (medium) | 1.2 | +| 字符计数 | 12px | 400 (regular) | 1 | + +字体族:`-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif` + +### 2.3 间距 & 尺寸 + +| 属性 | 值 | +|------|----| +| 提问框宽度 | **满宽**(`w-full`,与 composer 同宽,仅保留左右外边距 `mx-2.5`)——早期 640px 定宽会让提问框窄于会话宽度,已废弃 | +| 提问框圆角 | 14px | +| 提问框阴影 | `0 4px 24px rgba(0,0,0,0.08)` | +| 选项行内边距 | 14px 16px | +| 选项行间距 | 0(以分隔线区分) | +| 序号徽章尺寸 | 24px × 24px | +| 底部按钮高度 | 32px | +| 底部按钮圆角 | 8px | +| 底部按钮内边距 | 0 12px | +| 底部区域布局 | 与选项列表同一滚动容器内,分隔线之下**独占一行、右对齐**,`px-1.5` | + +### 2.4 动效 + +| 动效 | 时长 | 缓动函数 | +|------|------|---------| +| 选中态切换 | 160ms | `cubic-bezier(0.32, 0.72, 0, 1)` | +| 选项行 hover | 120ms | ease | +| 题目切换(淡入淡出) | 淡出 180ms / 淡入 280ms | `cubic-bezier(0.32, 0.72, 0, 1)` | +| 折叠/展开 | 240ms | `cubic-bezier(0.32, 0.72, 0, 1)` | +| 复制成功反馈 | 显示 1.5s 后恢复 | — | + +--- + +## 3. 组件规范 + +### 3.1 提问框整体 + +``` +┌─────────────────────────────────────────────────┐ +│ 题目标题 ‹ 2/5 › ⌄ │ ← 头部 + 导航 +├─────────────────────────────────────────────────┤ +│ ○ 选项标题 │ +│ 选项说明文字 复制 ↵│ ← 选项行(悬停态) +├─────────────────────────────────────────────────┤ +│ ○ 选项标题 [推荐] │ +│ 选项说明文字 │ +├─────────────────────────────────────────────────┤ +│ ○ 输入你的答案 0/500 │ ← 自定义输入行 +├─────────────────────────────────────────────────┤ +│ [取消] [提交↵] │ ← 底部操作 +└─────────────────────────────────────────────────┘ +``` + +### 3.2 选项行 + +**未选中态:** +- 序号徽章:灰圈 + 灰色数字 +- 标题:黑色加粗 +- 说明:灰色常规 +- 操作按钮:隐藏 + +**悬停态:** +- 行背景:`rgba(0,0,0,0.03)` 浅灰 +- 操作按钮从右侧淡入:`复制`(胶囊形,白底灰边)+ `回车图标`(无外框,灰色) +- 说明文字透明度微降(为操作按钮让位) + +**选中态:** +- 序号徽章:原地变为黑底白对勾(平滑过渡) +- 行背景:`rgba(0,0,0,0.04)` 浅灰 +- 操作按钮 hover 时仍可显示 + +**可点击区域:** 整行可点击,不局限于 badge 或文字 + +### 3.3 自定义输入行 + +- 序号徽章:与预设行同款圆形(**不使用虚线外框**——早期虚线方案已废弃) +- 与预设选项之间用一条浅分隔线区分 +- 输入框:无边框、无背景,内联于行内 +- placeholder:`输入你的答案`,灰色,默认显示;**聚焦后仍然显示**(不会因聚焦而消失),仅在输入内容后消失 +- 字符计数:右对齐,灰色 `0/500` +- 输入非空 → 自动进入选中态(badge 变黑底白勾,行底变浅灰) +- 清空输入 → 自动退出选中态;取消自定义选中态时**保留草稿文字**(灰化,不进 payload) + +### 3.4 底部按钮 + +**布局:** 位于选项列表同一滚动容器内、以分隔线隔开,**独占一行并右对齐**(`justify-end` + `px-1.5`)。早期方案与自定义输入行同行,实测会被行内 hover 操作按钮遮挡、且「跳过」溢出屏幕,故独立成行。 + +**按钮类型:** + +| 按钮 | 样式 | 快捷键提示 | +|------|------|-----------| +| 取消 | 浅灰底 + 深灰文字 | 无 | +| 跳过 | 浅灰底 + 深灰文字 | 无 | +| 提交(非末题) | 深灰底 + 白色文字 | `Enter` | +| 提交(末题) | 深灰底 + 白色文字 | `Ctrl + Enter` | + +**快捷键提示样式:** +- 位置:按钮文字右侧 +- 样式:胶囊形小标签,背景为半透明白色/黑色 +- 字号:10px,字重 500 +- 透明度:60% + +**按钮状态切换规则:** +- 非末题未作答 → 跳过(无快捷键) +- 非末题已作答 → 提交(显示 Enter 提示) +- 末题 → 始终为提交,且**未作答也可点击**(显示 Ctrl+Enter 提示);`Ctrl+Enter` 是快捷键,不是提交的充分条件 + +### 3.5 导航区 + +头部右侧,从左到右: +- **上一题** `‹`:圆形图标按钮,第一题时禁用(降低透明度) +- **序号** `n / N`:灰色文字 +- **下一题** `›`:圆形图标按钮,末题不变 +- **折叠** `⌄`:圆形图标按钮,点击后折叠 + +### 3.6 折叠态 + +- 头部保留一行(约 44px):左侧 `N 个问题待回答`(灰色小字)+ 中部进度 `n / N` + 右侧折叠图标 +- **折叠时隐藏完整题干**:否则题干会占满头部并把下方的展开入口顶掉 +- 头部下方提供独立的「继续回答」按钮(左对齐,带向上箭头图标),点击即展开 +- 折叠不改变已选答案和当前题号 + +### 3.7 选项行操作按钮 + +| 按钮 | 样式 | 行为 | +|------|------|------| +| 复制 | 胶囊形,白底灰边,文字按钮 | 点击后复制选项文本到剪贴板,按钮文字变为绿色对勾,1.5s 后恢复 | +| 回车图标 | 无外框,纯图标,灰色 | 1. 键盘快捷键的视觉暗示
2. 点击等同按 Enter:选中该行 + 下一题 | + +回车图标的核心定位是**渐进式发现**:让用户看到图标后下意识去按键盘 Enter,而不是用鼠标点它。鼠标点击仅作为兜底。 + +### 3.8 反馈卡(QuestionFeedbackCard) + +提问「收卷」后在消息流中展示的结果卡,组件为 `web/src/components/QuestionFeedbackCard.jsx`。 + +``` +┌───────────────────────────────────────┐ +│ ✓ │ +│ 全部提交完成 │ +├───────────────────────────────────────┤ +│ 1. 你最喜欢的开发语言? │ +│ Rust │ +├───────────────────────────────────────┤ +│ 2. 目标平台?(多选) │ +│ Windows、Linux │ +└───────────────────────────────────────┘ +``` + +| 状态 | 视觉 | +|------|------| +| 提交卡 | 居中绿色圆形对勾 + 标题「全部提交完成」+ 逐题 Q&A 列表(序号 + 题干,题干下为答案);未作答题以灰色「未作答」呈现 | +| 取消卡 | 居中灰色圆形叉号 + 标题「已取消全部回答」 | + +**持久化契约(关键设计约束):** + +卡片是**会话里的持久记录**,不是一次性 toast。渲染位置固定在对应的 `AskUserQuestion` 工具消息之后。 + +实现上必须**按该工具消息的落盘元数据就地派生**卡片,不得使用组件内存中的临时反馈状态或缓存的 item id。原因:回合结束时的 transcript self-heal 会用全新 item id 覆写最近一轮,任何缓存的锚点都会失效,卡片随之消失(这正是「会话一旦输出完成卡片就消失」的成因)。 + +配套的落盘数据: +- 提交路径:每题 `question / answer` + `multi_select` 标记 +- 取消路径:`ask_user_question_result.cancelled = true` +- 未作答:由答案为空派生为「未作答」 + +临时反馈状态只用于 `tool_end` 回流之前的即时预览,持久化结果到位后即被覆盖。同一轮提问只渲染一张卡。 + +--- + +## 4. 交互规范 + +### 4.1 选择行为 + +**单选题:** +- 点击选项 → 选中 +- 再次点击同一选项 → 取消选中 +- 选中预设 → 自定义答案草稿保留、选中态取消(文字灰化) +- 在自定义输入框输入 → 自动取消预设选中、自定义生效 +- 清空自定义输入 → 自定义失效,恢复此前预设(如有) + +**多选题:** +- 点击选项 → 选中/取消切换 +- 自定义输入非空时生效,与预设并存 +- 可同时选中多个预设项 + +### 4.2 键盘快捷键 + +| 按键 | 场景 | 行为 | +|------|------|------| +| **Enter** | 非末题(单选 / 多选) | 选中/切换当前焦点选项 → 提交 → 进入下一题(一步到位) | +| **Enter** | 末题 | 只选中当前焦点选项,不提交(防误触) | +| **Enter** | 自定义输入框内 | 走主操作(提交 / 跳过),不换行 | +| **Ctrl + Enter** | 末题 | 提交全部答案 | +| **1–9** | 任意题目 | 选中对应序号选项**并进入下一题**;序号超出选项数时聚焦自定义输入框 | +| **Tab / Shift+Tab** | 任意题目 | 下一题 / 上一题 | +| **← / →** | 任意题目 | 上一题 / 下一题(与 Tab 等价);在自定义输入框内仅移动光标 | +| **↑ / ↓** | 选项列表 | 在选项(含自定义行)间移动焦点,焦点行浅灰高亮;在输入框内仅移动光标 | +| **Space** | 选项列表 | 选中当前焦点选项(自定义行则聚焦输入框) | +| **Esc** | 提问框(非输入态) | 第一次:取消**所有**问题选中(保留自定义草稿),开启 1.2s 连按窗口;窗口内再按:取消整个问答 | +| **Esc** | 自定义输入框内 | 退出编辑态并回到提问框,不计入连按窗口 | + +**Enter 选中目标的优先级:** +1. 当前鼠标 hover / 键盘焦点的选项(优先级最高) +2. 带有 `[推荐]` 标签的第一个选项(标签灰色,不自动选中) +3. 第一个预设选项 + +### 4.3 题目切换动效 + +采用**淡入淡出 + 微纵向位移**方案: + +- **退出**:当前题目内容淡出(opacity 1→0)同时轻微下移 6px(translateY 0→6px) +- **内容切换**:DOM 切换 +- **进入**:新题目从上方 6px 处淡入(opacity 0→1)并滑落到原位(translateY -6px→0) +- **标题和序号**:同步做 3px 的上下微跳动 + 透明度变化 +- **方向一致**:无论上一题还是下一题,退出方向始终向下、进入方向始终从上,符合表单"往下走"的心智模型 +- **GPU 加速**:使用 `will-change: opacity, transform` + +### 4.4 复制反馈 + +- 点击「复制」按钮 → 按钮文字变为绿色对勾图标 +- 仅颜色变化,无背景/边框变化,避免外框闪动 +- 1.5 秒后自动恢复为「复制」文字 +- 有 `color` 属性的平滑过渡 + +--- + +## 5. 状态清单 + +原型中包含以下 8 个静态状态卡片 + 1 个可交互导航卡片: + +| 编号 | 状态 | 说明 | +|------|------|------| +| ① | 单选 · 默认未选中 | 3个预设 + 1个自定义,底部显示「跳过」 | +| ② | 单选 · 已选中 | 第2项选中(黑底白勾),底部显示「提交 Enter」 | +| ③ | 单选 · 悬停态 | 悬停行浅灰底 + 右侧浮现复制/回车操作按钮 | +| ④ | 多选 · 已选多项 | 标题带「(可多选)」,预设项与自定义答案并存 | +| ⑤ | 单选 · 自定义草稿 | 预设已选,自定义输入保留草稿但灰化 | +| ⑥ | 折叠态 | 一行高度,显示「5 个问题待回答」+ 展开按钮 | +| ⑦ | 提交结果汇总 | 对勾图标 + 逐题答案列表,未答题标为「未作答」 | +| ⑧ | 取消反馈 | 关闭图标 + 「已取消全部回答」 | +| (可交互) | 5题导航演示 | 可前后切换,含完整 Enter 快捷键和动效 | + +--- + +## 6. 无障碍 + +- 提问框可获取焦点(tabindex="0"),键盘事件在焦点内生效 +- 所有按钮有 aria-label +- 颜色对比度符合 WCAG AA 标准 +- 选中态不仅依赖颜色,还有 badge 形状变化(数字→对勾) + +--- + +## 7. 待决事项 + +- 第一次出现时的新手引导(回车图标呼吸动效) + +> 已决策(从待决移除): +> - ↑↓ 移动焦点的视觉态 → 已实现为焦点行浅灰高亮 +> - Esc 键行为 → 已实现为两级语义(一次取消选中 / 连按取消问答),见 4.2 + +--- + +## 8. 变更记录 + +| 日期 | 变更 | 原因 | +|---|---|---| +| 2026-09-14 | 提问框由 640px 定宽改为满宽(`w-full`) | 用户反馈未铺满会话宽度 | +| 2026-09-14 | 自定义输入行去掉虚线外框 | 虚线视觉突兀,与预设行不一致 | +| 2026-09-14 | 底部按钮独立成行、右对齐 | 与自定义行同行时被 hover 按钮遮挡,「跳过」溢出屏幕 | +| 2026-09-14 | 键盘表补齐 Enter(多选统一)/ 数字键 / 方向键 / Space / Esc 两级语义 | 交互对齐 TUI 并修正与预期不符处 | +| 2026-09-14 | 折叠态隐藏题干 + 新增「继续回答」入口 | 题干遮挡展开入口 | +| 2026-09-14 | 新增 3.8 反馈卡小节与其持久化契约 | 卡片需作为会话持久记录存在,不随回合结束消失 | diff --git a/docs/prototypes/web-ask-user-question-alignment/index.html b/docs/prototypes/web-ask-user-question-alignment/index.html new file mode 100644 index 00000000..ec30e3a9 --- /dev/null +++ b/docs/prototypes/web-ask-user-question-alignment/index.html @@ -0,0 +1,399 @@ + + + + + +WEB AskUserQuestion 提问框原型 v3 + + + +
+
+

WEB AskUserQuestion 提问框原型

+

模拟:AI 收到提示词「随机问我5个问题,包含单选和多选」后生成了 5 个问题。提问期间提问框占据底部输入框位置(输入框被替换,提交/取消后恢复);悬停选项可看到"复制 / 回车"按钮,选中后左侧序号变成黑底白勾,右上角切题与折叠,右下角取消/跳过/提交。

+
+ +
+
+
AI:好的,我随机准备了 5 个问题(单选 + 多选混合,部分标注了推荐项),请逐一回答:
+ + +
+ +
+
+
+ +
+ + +
+
+
+
+ 5✓ + + 0/500 +
+
+
+ + +
+
+ +
+ + +
+
+ +
+ 回答进度: + +
+ +
+
+ + + + diff --git a/docs/specs/2026-09-13-web-ask-user-question-requirements.md b/docs/specs/2026-09-13-web-ask-user-question-requirements.md new file mode 100644 index 00000000..a83b136b --- /dev/null +++ b/docs/specs/2026-09-13-web-ask-user-question-requirements.md @@ -0,0 +1,243 @@ +# WEB AskUserQuestion 对齐 TUI 需求规格 + +**日期:** 2026-09-13 +**状态:** 需求已通过交互原型验证,设计文档见 [Web AskUserQuestion 设计文档](../design/web-ask-user-question-design.md) +**原型文件:** `design-prototypes/ask-user-question-prototype.design` +**关联文档:** [TUI AskUserQuestion 重设计需求](2026-09-07-tui-ask-user-question-requirements.md) + +## 1. 目标 + +将 ACECode Web 端的 `AskUserQuestion`(提问框)交互与 TUI 版对齐:统一单选题、多选题、自定义答案、题目切换、跳过/提交/取消的操作体验,并补齐 Web 端缺失的「回车提交」「复制选项」「选中态反馈」等能力;提问期间由提问框**替换底部输入框**,与 TUI 的「替代正常提示词输入框」一致。 + +本次对齐只改提问框自身,不改消息协议与后端语义;超时收卷等依赖跨端配置的能力留作后续。 + +## 2. 范围 + +### 本轮范围 + +- Web 端 `QuestionPicker` 提问框的布局、选项行、自定义输入、切题、折叠、底部操作按钮、复制/回车交互、选中态与反馈。 +- 单选/多选的选择语义、自定义答案草稿与选中态分离、跳过(Not answered)与提交语义。 +- 提问期间**替换底部输入框**(隐藏输入区,由提问框占据;回答完成后恢复)。 +- **键盘快捷键对齐**:`Tab` / `Shift+Tab` 切换题目、数字键 `1–9` 快速选择对应选项等,沿用 TUI 键位;**Enter 键** 用于快速选中并进入下一题,末题需 **Ctrl+Enter** 提交全部。 + +### 不在本轮范围 + +- **超时收卷与超时自动选择**:需跨协议与 daemon 改造,单列后续议题。 +- **汇总页 / 快问模式区分**:本轮不引入,不做题间汇总确认页。 +- 劫持 `Ctrl+C` 取消(避免影响浏览器复制)。 + +## 3. 产品模型 + +### 3.1 提问框模型 + +- 一次调用可携带多道题;界面**每次展示一道题**,通过右上角导航切换。 +- 右上角依次为:上一题 `‹`、当前序号 `2 / 5`、下一题 `›`、**折叠/展开** 按钮。进度只出现在右上角这一处,题目框下方不再重复显示进度。 +- 折叠后仅保留提问框头部一行(待答题数 + 进度 + 折叠按钮),并**隐藏完整题干**——否则题干会把下方的「继续回答」按钮挡住。头部下方提供独立的「继续回答」按钮,点击即可展开;内容与底部按钮在折叠期间隐藏,可随时展开。 +- 折叠态文案:`N 个问题待回答`(左侧)+ `n / N` 进度(中部)+ 折叠图标(右侧),下方为「继续回答」按钮。 + +### 3.2 底部输入框替换(方案 A:提问框占据输入框位置) + +- 提问框打开期间,底部输入框区域整体**被提问框替换**:**提问框移动到原本输入框所在的位置**,输入框及其附属控件隐藏;聊天消息在上方滚动,提问框固定在底部输入区,用户无法在提问期间输入普通消息。 +- 问答**提交完成或取消后,提问框关闭,输入框立即恢复原位**,可正常输入消息。 +- 折叠状态不影响替换:折叠只是收起提问框内容(仍占据底部输入区),直至问答结束。 + +### 3.3 支持的题型 + +| 题型 | 规则 | +|---|---| +| 单选题 | 任意时刻只能有一个有效预设选项,或一个有效自定义答案;两者互斥。 | +| 多选题 | 可同时选择多个预设项;自定义答案作为补充说明,可与预设项并存。 | + +## 4. 题目展示 + +**视觉基调:** 整体采用弱化、克制的配色:低饱和强调色、轻微的 hover 变化,避免高饱和鲜艳颜色与强对比,降低「可点击」的诱导感;强调色仅保留用于必要的状态反馈(选中态黑底白勾、主操作按钮)。 + +### 4.1 头部 + +- 主标题:题目文本(加粗),多选题在标题后附加灰色 `(可多选)`。 +- 推荐项在选项标题后显示灰色 `[推荐]` 标签,不自动选中。 + +### 4.2 选项行 + +每个预设选项行由四个部分构成(自左向右): + +| 部分 | 说明 | +|---|---| +| 序号徽章 | 圆形,未选中为灰圈 + 灰色数字;**选中后原地变成黑底白对勾**(复用同一圆形外框,仅换底色与内容)。 | +| 主标题 | 黑色加粗。 | +| 说明 | 灰色、左对齐,长文本自动换行。 | +| 操作按钮 | 默认隐藏;**鼠标悬停该行时浮现**:`复制` 与 `回车` 两个小按钮,行本身同时出现浅灰底色。 | + +- 选项行之间以浅灰细分隔线分隔。 +- 悬停时右侧说明弱化,为操作按钮让位。 + +### 4.3 自定义回答行 + +- 位于选项列表末尾,序号为「选项数 + 1」。 +- 与预设选项行之间用一条浅分隔线区分;**不使用虚线边框**(早期方案的虚线已废弃,实线/无边框与预设行保持一致的视觉基调)。 +- 行内为**内联输入框**:幽灵文字「输入你的答案」,默认无焦点时即显示;聚焦后仍显示幽灵文字(不会因聚焦而消失),输入内容后才消失;右侧显示字符计数 `0/500`。 +- **输入非空内容的瞬间,该行自动进入选中态**(序号变黑底白勾、整行浅灰底),与预设选项选中效果一致;清空输入后恢复未选中。 +- 取消自定义选中态时**保留草稿文字**(灰化、不进 payload),重新输入即恢复生效。 + +### 4.4 折叠态 + +- 折叠时提问框仅保留一行高度:左侧灰色小字提示待回答题数(如「5 个问题待回答」),右侧为「展开」胶囊按钮(带向上箭头)。 +- 折叠不改变已选答案与当前题号。 +- 点击头部右侧折叠按钮可进入折叠态,点击展开按钮恢复。 + +## 5. 选择与答案语义 + +### 5.1 单选题 + +- 点击选项 = 选中;再次点击同一选项 = 取消选中。 +- 选中任一预设选项时,取消自定义答案的**选中态**:**草稿文字保留但以灰色显示(灰掉)**,不清理、不进入最终答案;重新在输入框输入时恢复生效。 +- 在自定义输入框输入非空内容时,自动清空预设选择并使自定义答案生效。 +- 清空输入框 = 自定义答案失效(若此前已选预设,则以预设为准)。 + +> 说明:「选中态」与「草稿」分离——草稿是输入框里的文字,选中态决定它是否作为答案。这对产品用户的意义是:写了一半又改选预设项时,文字不丢,但不会被当成答案误提交。 + +### 5.2 多选题 + +- 点击选项 = 选中/取消切换,可多选。 +- 自定义输入非空时生效,与预设项**并存**作为补充答案。 + +### 5.3 有效答案判定 + +一题「已作答」当且仅当:至少选中一个预设项,**或**自定义输入处于激活态且内容非空。 + +最终提交给模型的答案 = 选中的预设项 + 激活的自定义文本;未激活的草稿、被跳过的题不进入答案。 + +## 6. 底部操作按钮 + +底部右下角按序排列两个按钮(自左向右): + +``` +[取消] [跳过 / 提交] +``` + +| 场景 | 按钮文案 | 点击行为 | +|---|---|---| +| 非末题 + 未作答 | 跳过 | 本题记为未作答(Not answered),进入下一题 | +| 非末题 + 已作答 | 提交 | 提交当前题答案,进入下一题 | +| 末题 | 提交 | 提交整个问答;未作答/跳过的题一并记为 Not answered | + +**布局(修订):** 按钮区位于选项列表同一滚动容器内、以一条分隔线与其隔开,**独占一行并右对齐**,内边距约 `px-1.5`。早期方案把按钮与自定义输入行放在同一行,实测会被行内 hover 操作按钮遮挡、且「跳过」会溢出到屏幕外,故改为独立一行右对齐。 + +- 按钮文案随当前题回答状态**实时切换**:选中选项的瞬间,「跳过」即变为「提交」。 +- **末题「提交」恒可点击**:即使整批未作答也可提交(未作答/跳过的题统一记为 Not answered)。`Ctrl + Enter` 是末题提交的快捷键,而非提交的充分条件。 +- 视觉:取消为浅灰底;跳过/提交为低饱和中性强调色底(不采用高饱和品牌蓝),均有轻微的 hover 加深底色反馈。 +- **快捷键提示**:提交按钮右侧显示灰色胶囊形快捷键标签(字号 10px)。非末题提交显示 `Enter`,末题提交显示 `Ctrl + Enter`;跳过按钮不显示快捷键提示。 + +## 7. 选项行操作(悬停浮现) + +| 按钮 | 样式 | 行为 | +|---|---|---| +| 复制 | 胶囊形,白底灰边,文字按钮 | 复制「选项文本 + 说明」到剪贴板,按钮变为绿色对勾,1.5 秒后恢复。 | +| 回车图标 | 无外框,纯图标,灰色(弱视觉层级) | **核心定位:键盘快捷键的视觉暗示**,引导用户去按物理键盘 Enter。鼠标点击作为兜底,行为与 Enter 键一致。 | + +### 7.1 Enter 键交互模型 + +Enter 键是为**快速答题的高频用户**设计的效率操作,采用"渐进式发现"模式:用户看到选项行 hover 时的回车图标,下意识去按键盘 Enter,从而获得效率提升。 + +**Enter 键行为(修订:单选与多选统一):** + +| 场景 | 行为 | +|---|---| +| 非末题(单选 / 多选) | 选中/切换当前焦点选项 → 提交当前题 → 进入下一题(一步到位) | +| 末题 | 只选中当前焦点选项,不自动提交(需 Ctrl+Enter 或点「提交」按钮) | +| 自定义输入框内 | Enter 走主操作(提交/跳过),不换行 | + +> 修订说明:早期规格写「多选按 Enter 只切换选中态、不跳转」,实测与用户预期不符(用户期望多选题同样「选中即进入下一题」),故统一为同一套 `commitEnter` 语义。 + +**Enter 选中目标的优先级:** +1. 当前鼠标 hover / 键盘焦点的选项 +2. 带有 `[推荐]` 标签的第一个选项(`[推荐]` 标签为灰色,**不自动选中**) +3. 第一个预设选项 + +**数字键 `1–9`(修订):** 选中对应序号选项**并提交进入下一题**(等同 `commitEnter`);序号超出选项数时聚焦自定义输入框。早期规格仅写「快速选择对应选项」,未包含提交/跳转语义。 + +### 7.2 末题防误提交 + +最后一题单独按 Enter **只选中答案,不提交全部**。需按 **Ctrl + Enter** 或点击「提交」按钮才会触发最终提交,防止用户连续快速答题时误触提交全部答案。末题提交按钮旁显示 `Ctrl + Enter` 快捷键提示以强化心智。 + +### 7.3 Esc 两级语义 + +| 操作 | 行为 | +|---|---| +| 第一次按 `Esc` | 取消**所有**问题的选中态(清空已选选项与自定义激活态),**保留自定义草稿文字**;并开启 1.2s 连按窗口 | +| 连按窗口内再次按 `Esc` | 拒绝作答,取消整个问答(等同点「取消」) | +| 自定义输入框内按 `Esc` | 先退出编辑态并回到提问框,不计入连按窗口 | + +### 7.4 方向键 + +| 按键 | 行为 | +|---|---| +| `↑` / `↓` | 在选项(含自定义行)之间移动焦点,焦点行浅灰高亮 | +| `←` / `→` | 切换题目,与 `Tab` / `Shift+Tab` 等价 | +| 自定义输入框内的 `↑` / `↓` / `←` / `→` | 仅移动光标,不触发上述行为 | + +## 8. 取消 + +- 点击底部「取消」:关闭提问框,并**通知 AI 全部取消回答**,界面显示取消反馈。 + +## 9. 状态反馈 + +- **选中反馈**:序号徽章黑底白勾 + 行底色变化(见 4.2 / 4.3)。 +- **提交/取消反馈**:提交后展示逐题答案汇总卡(绿色对勾 +「全部提交完成」+ 逐题 Q&A);取消后展示「已取消全部回答」卡(灰色叉号)。 +- **toast**:复制成功等轻提示。 + +### 9.1 反馈卡持久化(修订) + +反馈卡是**会话里的持久记录**,不是一次性提示。要求: + +- 卡片渲染在对应的 `AskUserQuestion` 工具消息之后,随消息留在会话流里。 +- 以下时刻卡片都必须仍然存在:回合输出结束、继续对话、切换会话再回来、刷新页面重载。 +- 同一轮提问只渲染**一张**卡(提交卡与取消卡互斥),不得出现新旧卡片重叠。 +- 实现约束:卡片内容与位置必须从该工具消息的**落盘元数据**就地派生,不得依赖组件内存中的临时状态或缓存的 item id —— 回合结束时的 transcript self-heal 会用新 id 覆写最近一轮,缓存锚点会导致卡片消失。 +- 落盘元数据需携带:取消路径的 `cancelled` 标记、每题答案、以及题型的 `multi_select` 标记(否则重载后丢失「(多选)」标注)。 + +## 10. 约束与非目标 + +- 不改 `AskUserQuestion` 工具参数 schema(题目数、每题 2–4 选项、`multiSelect` 等约束维持现状)。 +- 不改问答消息协议(`request_id / session_id / answers / cancelled` 字段名与语义不变)。 +- 允许的**增量**改动(不破坏上述契约): + - 工具结果元数据新增 `ask_user_question_result.cancelled`(取消标记)与每题 `multi_select` 标记,供 UI 在重载后还原反馈卡与题型。 + - daemon 在向模型回填的答案里派生 `not_answered`:`selected` 与 `custom_text` 均为空时置真,与 TUI `ask_question_controller.cpp` 同规则,使 Web 端跳过的题在模型侧呈现为 `Not answered`。 +- 文案使用简体中文。 + +## 11. 验收标准 + +以下行为在 Web 端提问框中可逐项复现: + +1. 多题调用时,右上角显示 `n / N` 并可前后切换;折叠后仅剩一行(待答题数 + 进度 + 折叠图标)且不显示题干,下方「继续回答」按钮可展开恢复。 +2. 单选:选中/取消切换正常;自定义输入非空后清空预设;点击预设项后自定义**草稿保留**、**选中态取消**,提交答案不含草稿。 +3. 多选:可多选预设,自定义输入可与预设并存。 +4. 选项行悬停出现浅灰底色与「复制」「回车图标」按钮;回车图标为无外框纯图标样式(弱视觉层级),定位为键盘快捷键的视觉暗示。 +5. 选中后序号徽章原地变为黑底白对勾;自定义输入非空自动进入选中态;自定义行**无虚线边框**。 +6. 底部按钮独占一行且**右对齐**、不溢出屏幕;未作答非末题为「跳过」(无快捷键提示),已作答变为「提交」(显示 `Enter` 提示),末题恒为「提交」且**未作答也可点击**(显示 `Ctrl + Enter` 提示)。 +7. 跳过/未作答的题在提交结果中记为 Not answered(「未作答」,灰色显示)。 +8. 点击「取消」关闭提问框并展示「已取消全部回答」卡。 +9. 提问框打开期间底部输入框被替换隐藏;提交或取消后立即恢复。 +10. 键盘:`Tab` / `Shift+Tab` / `←` / `→` 切换题目;数字键 `1–9` 选中对应选项并进入下一题;`↑` / `↓` 在选项间移动焦点并高亮;**Enter 键** 选中当前选项并进入下一题(单选/**多选**一致;末题只选中不提交);**Ctrl+Enter** 末题提交全部;**Esc** 一次取消所有选中、1.2s 内再按一次取消整个问答。 +11. 复制:点击「复制」按钮后按钮变为绿色对勾,1.5 秒后自动恢复,无外框闪动。 +12. 题目切换:采用淡入淡出 + 微纵向位移动效,方向一致(退出向下、进入从上),过渡流畅。 +13. **反馈卡持久化**:提交/取消后卡片出现在对应提问消息之后;会话输出结束、继续对话、切走再回来、刷新页面后卡片仍在;同一轮提问只出现一张卡,无新旧卡片重叠。 + +## 12. 后续开放决策 + +- 超时收卷与超时自动选择(依赖跨端超时配置)。 + +## 13. 变更记录 + +| 日期 | 变更 | 原因 | +|---|---|---| +| 2026-09-14 | 自定义行去掉虚线边框 | 用户反馈虚线视觉突兀,与预设行不一致 | +| 2026-09-14 | 底部按钮改为独立一行右对齐 | 与自定义行同行时被 hover 操作按钮遮挡,「跳过」溢出屏幕 | +| 2026-09-14 | 多选 Enter 与单选统一为「选中即进入下一题」 | 实测与用户预期不符 | +| 2026-09-14 | 数字键改为「选中并进入下一题」 | 补齐提交/跳转语义 | +| 2026-09-14 | 末题「提交」恒可点击 | 未作答也应能一次性收卷,`Ctrl+Enter` 只是快捷键 | +| 2026-09-14 | 新增 Esc 两级语义、方向键行为 | 键盘体验对齐 TUI | +| 2026-09-14 | 折叠态隐藏题干并新增「继续回答」按钮 | 题干会遮挡展开入口 | +| 2026-09-14 | 反馈卡改为按工具消息落盘元数据派生 | 回合结束 self-heal 换 id 会让卡片消失,需持久展示 | +| 2026-09-14 | 取消路径落 `cancelled`、提交路径落 `multi_select` | 取消卡此前无落盘依据;多选标注重载后丢失 | +| 2026-09-14 | daemon 派生 `not_answered` | 让 Web 端跳过在模型结果中呈现为 Not answered,与 TUI 一致 | diff --git a/openspec/changes/redesign-web-ask-user-question/.openspec.yaml b/openspec/changes/redesign-web-ask-user-question/.openspec.yaml new file mode 100644 index 00000000..8ba6f4d8 --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 \ No newline at end of file diff --git a/openspec/changes/redesign-web-ask-user-question/proposal.md b/openspec/changes/redesign-web-ask-user-question/proposal.md new file mode 100644 index 00000000..bfb20e07 --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/proposal.md @@ -0,0 +1,32 @@ +# Proposal: redesign-web-ask-user-question + +## Why + +Web 端 `AskUserQuestion`(`QuestionPicker`)与 TUI 版存在明显交互落差:缺少回车提交、复制选项、折叠与题目切换动效;选项行无选中态反馈(序号不随选中变成对勾);无法在非末题跳过未答题;自定义答案草稿与选中态未分离;底部输入框在提问期间只是禁用占位而非被替换。设计与规格已分别更新至 `docs/design/web-ask-user-question-design.md` 与 `docs/specs/2026-09-13-web-ask-user-question-requirements.md`。 + +本变更重做**仅 Web 端**的提问框,使其交互对齐 TUI 基线。 + +## What Changes + +- 重写 `web/src/lib/questionPicker.js` 纯逻辑层:导航(前/后/切换)、已作答与可跳过判定、跳过(Not answered)语义、提交/取消 payload、单选/多选自定义草稿与选中态分离、末题防误提交、Enter 选中优先级。 +- 重写 `web/src/components/QuestionPicker.jsx` 视觉与交互:低饱和中性配色、选中黑底白勾序号徽章、hover 浮现「复制 / 回车」操作按钮、内联自定义输入(幽灵文字、字符计数、聚焦/灰化草稿)、折叠态、底部 `取消 / 跳过 / 提交` 按钮与 Tab 切题、数字键选择、Enter/Ctrl+Enter 快捷键。 +- `ChatView` 集成:提问框打开期间**替换底部输入框**(composer dock 隐藏,提问框占据其位置),提交/取消后恢复;展示提交逐题答案汇总与取消反馈。home 与会话两个入口同步。 +- 反馈卡(`QuestionFeedbackCard`)**持久化**:卡片按 `AskUserQuestion` 工具消息的**落盘元数据就地派生**(`web/src/lib/questionFeedback.js`),渲染在该消息之后。取消路径在 `make_rejected_ask_result` 落 `cancelled` 标记,提交路径落每题 `multi_select`;daemon 在回填答案时派生 `not_answered`(与 TUI 同规则)。这样卡片在回合输出结束、继续对话、切换/重载会话后都还在 —— 回合结束的 transcript self-heal 会用新 item id 覆写最近一轮,依赖缓存锚点会让卡片消失。 +- 不修改 `AskUserQuestion` 工具参数 schema(题目数、每题 2–4 选项、`multiSelect` 约束不变),也不改 `request_id / session_id / answers / cancelled` 的字段名与语义;只做**增量**元数据字段。不带入超时收卷、汇总页、`Ctrl+C` 劫持。 + +## Capabilities + +### New Capabilities + +- `web-ask-user-question`: Web 端自适应的单选/多选提问、折叠与题目切换、内联自定义答案、复制/回车、跳过(Not answered)、Enter/Ctrl+Enter 快捷键与输入框替换契约。 + +### Modified Capabilities + +- `ask-question-policy`: Web 端从"必须全部作答才能下一步"扩展为支持非末题跳过(Not answered)与末题仅 `Ctrl+Enter` 提交;其余端行为不变。 + +## Impact + +- **Web 前端**:`web/src/components/QuestionPicker.jsx`、`web/src/components/QuestionFeedbackCard.jsx`(反馈卡)、`web/src/components/ChatView.jsx`(集成点)、`web/src/components/ToolBlock.jsx`(移除旧的结果卡渲染,避免与反馈卡重叠)、`web/src/lib/questionPicker.js`、`web/src/lib/questionFeedback.js`(反馈卡派生)、`web/src/lib/sessionTranscript.js`(归一化 `cancelled` / `multi_select`)。 +- **daemon**:`src/tool/ask_user_question_tool.cpp`(取消路径落 `cancelled`、提交与超时路径落 `multi_select`)、`src/agent_loop.cpp`(答案派生 `not_answered`)。 +- **测试**:`web/src/lib/questionPicker.test.js`、`web/src/lib/questionFeedback.test.js`(新增)、`web/src/lib/sessionTranscript.test.js`、`tests/tool/ask_user_question_tool_test.cpp`;`web/src/lib/runTests.js` 注册新测试文件。 +- **文档**:`docs/specs/2026-09-13-web-ask-user-question-requirements.md`、`docs/design/web-ask-user-question-design.md` 为验收基线,均已记录本轮决策变更。 \ No newline at end of file diff --git a/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md b/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md new file mode 100644 index 00000000..ebb3f863 --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md @@ -0,0 +1,58 @@ +# Spec: web-ask-user-question + +Web 端 AskUserQuestion 提问框的全套交互契约。验收依据合并引用 `docs/specs/2026-09-13-web-ask-user-question-requirements.md` 与 `docs/design/web-ask-user-question-design.md`(本 spec 为上两者的可测契约子集)。 + +## 模型与导航 + +- 一次调用可携带多道题,界面每次展示一道,右上角 `‹ n / N ›` 前后切换。 +- 当前题可折叠/展开;折叠态仅保留头部一行(待答题数提示 + 展开按钮),折叠不改变答案与当前题号。 + +## 答案语义 + +- 单选题:`selected` 互斥;选中预设时若自定义有草稿则保留草稿但取消选中态(灰显、不进 payload);输入非空则清空预设并激活自定义。 +- 多选题:可多个 `selected`,自定义激活时与预设并存。 +- 一题已作答当且仅当存在任一 `selected` 或自定义处于激活且内容非空。 +- payload 的 `custom_text` 仅写激活态(`customSelected`)的非空去前后空格草稿。 +- 「跳过 / 未作答」的题以空 `selected`、空 `custom_text` 上报;daemon 在序列化工具结果时按 TUI 同规则派生 `not_answered`(`selected` 与 `custom_text` 均为空时置真),使未答题在 LLM 结果中呈现为 `Not answered` 而非空串。 + +## 导航与按钮 + +- 非末题 + 未作答:主按钮「跳过」,点击记为 `Not answered` 并进下一题。 +- 非末题 + 已作答:主按钮「提交」,提交当前题并进下一题。 +- 末题:主按钮恒为「提交」(未答/跳过的题一并记为 Not answered)。 +- 底部始终提供「取消」,点击发送 `{cancelled:true}` 并触发 resolve。 +- 主按钮文案随当前题已作答状态实时切换。 + +## 收卷时机 + +- daemon 对同一 `question_id`/`request_id` 的 `question_answer` 是 **first-wins 原子**:第一份被 accepted 即关闭整个提问并结束 agent_loop 的等待,后续一律忽略(`Closed`)。 +- 因此非末题的「提交/跳过」只更新**本地答案状态并推进**,绝不向后端发送 `question_answer`;最终整批答案只在**末题「提交」**时统一 `sendQuestionAnswer` 一次。 +- 末题 `canSubmit` 恒为真(即使整批未答/跳过,一并记 Not answered);防误触由「末题单按 Enter 不提交、需 Ctrl+Enter」保证。 +- 一条请求全过程至多产生一次 `question_answer`(末题提交)与一次 `cancelled`(取消)。 + +## 键盘 + +- `1–9` 选择对应选项(`N+1` 聚焦自定义行)。 +- `↑ / ↓` 移动焦点;`Space` 选中当前焦点项 / 聚焦自定义。 +- `Enter`:单选 + 非末题且已作答 → 提交并进下一题;单选 + 末题 → 仅选中不提交(预防止误提交);多选 → 切换当前焦点项选中态。 +- `Ctrl+Enter`:末题提交全部。 +- `Tab / Shift+Tab`:切换上一题/下一题。 +- `Esc`:取消整个问答(编辑态先退出编辑)。 +- 文本输入(自定义框)内:Enter 不触发提交类快捷键(正常输入),避免误跳。 + +## Enter 选中目标优先级 + +1. 当前 hover 的选项;2. 带推荐标记的第一个选项;3. 第一个预设选项。 + +## 输入框替换 + +- 提问框打开期间,composer dock(输入区)隐藏,提问框占据其位置;提交或取消后 composer 恢复。 +- 折叠态不取消替换。 + +## 状态反馈 + +- 选中:序号徽章原地变黑底白勾;行浅底色。 +- 复制:按钮变绿色对勾,1.5s 恢复。 +- 取消/提交:展示「已取消全部回答」/逐题答案汇总。 +- 反馈卡持久化:卡片从 AskUserQuestion 工具消息的落盘元数据就地派生,随消息留在会话里。回合结束(transcript self-heal 用新 item id 覆写最近一轮)、继续对话、重载会话后卡片都必须仍然存在;不得依赖组件内存里的临时锚点或临时反馈。 +- 取消路径必须落 `cancelled` 标记、提交路径必须落 `multi_select`,否则重载后无法还原卡片与题型。 \ No newline at end of file diff --git a/openspec/changes/redesign-web-ask-user-question/tasks.md b/openspec/changes/redesign-web-ask-user-question/tasks.md new file mode 100644 index 00000000..212a06db --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/tasks.md @@ -0,0 +1,41 @@ +# Tasks: redesign-web-ask-user-question + +## 1. 纯逻辑层 `web/src/lib/questionPicker.js` + +- [x] 1.1 扩展 `.openspec.yaml`/`proposal`/`spec` 已完成:导航、跳过、Enter 优先级、草稿/输入框替换、快捷键通过可单测纯函数描述。 +- [x] 1.2 重写纯逻辑:保留 `normalizeQuestionRequest`/`makeInitialAnswers`/payload 构建;新增/调整已作答判定、导航(`canSkip`/`canSubmitCurrent`/末题防误提交)、`selectOption`/`selectCustom`/`saveCustom`、`recommendedOptionIndex`、`enterTargetIndex`。 +- [x] 1.3 保持 payload 与 daemon 协议兼容(`question_id / selected / custom_text`、`cancelled`),`custom_text` 仅写激活态非空草稿。 +- [x] 1.4 扩充 `web/src/lib/questionPicker.test.js`:跳过语义、末题仅 Ctrl+Enter、单选/多选草稿与选中态分离、Enter 目标优先级、Tab 切换、已作答/not answered 判定。 + +## 2. 组件层 `web/src/components/QuestionPicker.jsx` + +- [x] 2.1 视觉重做:低饱和中性配色、序号徽章黑底白勾、行 hover 浅底到位、内联自定义输入(幽灵文字/计数/聚焦与灰化草稿)、折叠态、底部 `取消/跳过/提交`。 +- [x] 2.2 交互:选项行 hover 浮现「复制 / 回车」按钮;复制写入剪贴板并显示 1.5s 对勾反馈;回车按钮与双击=选中并进下一题/提交。 +- [x] 2.3 键盘:`1–9`、`↑↓`、`Space`、`Enter`(单选非末题一步到位/末题仅选中、多选切换)、`Ctrl+Enter` 末题提交、`Tab/Shift+Tab` 切题、`Esc` 取消(编辑态先退编辑)。 +- [x] 2.4 提交/取消后通过 `onResolve` 释放,供上层恢复 composer 并展示汇总/取消反馈。 +- [x] 2.5 收卷时机:非末题「提交/跳过」仅本地推进,绝不逐题发送;末题统一 `sendQuestionAnswer` 一次、取消一次 `cancelled`;末题 `canSubmit` 恒为真(整批一并记 Not answered)。 + +## 3. 集成 `web/src/components/ChatView.jsx` + +- [x] 3.1 home 入口与 session 入口:提问框打开期间 composer 输入区替换为提问框,提交/取消后恢复。 +- [x] 3.2 接入提交逐题答案汇总与「已取消全部回答」反馈展示(在合适位置渲染结果/取消提示)。 +- [x] 3.3 daemon 序列化派生 `not_answered`(`src/agent_loop.cpp`):`selected` 与 `custom_text` 均为空时置真,与 TUI `ask_question_controller.cpp` 同规则,使 Web 端跳过在 LLM 结果中呈现为 `Not answered`。 +- [x] 3.4 反馈卡持久化:卡片改为按 AskUserQuestion 工具消息就地派生(`web/src/lib/questionFeedback.js`),数据源是落盘元数据而非组件临时 state —— 回合结束的 transcript self-heal 会用新 id 覆写最近一轮,缓存锚点会让卡片消失。取消路径在 `make_rejected_ask_result` 落 `cancelled` 标记、提交路径落 `multi_select`,两者都能跨重载还原。 +- [x] 3.5 历史工具身份规范化:恢复持久化 transcript 时,按 `tool_call_id` 将 `assistant.tool_calls` 中的明确工具名补到缺少名称的对应 `role:tool` 结果,使提交与取消卡在 self-heal、刷新和继续对话后仍紧跟各自的 AskUserQuestion 调用。 + +## 4. 验证 + +- [x] 4.1 `pnpm test`(自 `web/`)全绿。 +- [x] 4.2 `pnpm build` 通过,嵌入前端资源刷新。 +- [x] 4.3 反馈卡持久化回归测试:`web/src/lib/questionFeedback.test.js`(提交/取消/未作答/id 变更/临时预览)+ `tests/tool/ask_user_question_tool_test.cpp` 的 cancelled 与 multi_select 断言。 +- [x] 4.4 仅当与既有行为相关时同步更新 `docs`;不引入超时收卷、汇总页、`Ctrl+C` 劫持。 +- [x] 4.5 完整链路回归:使用真实 `assistant.tool_calls` + `role:tool` 持久化协议形状,覆盖实时提交/取消、历史重载、回合 self-heal、继续对话、调用后紧邻卡片且每次调用只出现一张。 + +## 5. 基于最新 master 重新落地 + +- [x] 5.1 冲突判定:master 的「提问挂起时直接输入 = 插话」与本次「方案 A:提问框替换 composer」互斥,按方案 A 落地 —— 提问期间 `ace-composer-dock` 不渲染 `InputBar`。 +- [x] 5.2 清理不可达路径:`ChatView.submit` 的提问插话分支随输入框一起移除(不再调用 `api.interjectQuestion`)。daemon 插话端点、排队卡片的「插话」按钮与转录 `interjected` 标记保留 —— TUI/IM 通道仍在产生该形态的落盘结果。 +- [x] 5.3 反馈卡判定与 master 对齐:`questionFeedbackForItem` 不再按工具名过滤(历史页可能只剩工具结果消息,或工具已被改名),并恢复 `interjected` 形态;`QuestionFeedbackCard` 补回「已改为直接输入,取消作答」卡。 +- [x] 5.4 `web/src/lib/composerEditabilityArchitecture.test.js` 改为守方案 A 的契约(composer 让位、无插话入口),并把排队卡片插话单独成条。 +- [x] 5.5 重新生成 i18n 源目录;Web 侧 `pnpm test`、`pnpm build`(含 lookbehind 兼容检查)全绿。安装须用 CI 同版本 pnpm 10.32.1(`npx pnpm@10.32.1 install --frozen-lockfile`)—— 本机 pnpm 12 解析该 patch 文件会失败,属工具链版本问题,与本次改动无关。 +- [x] 5.6 C++ 侧 `*AskUserQuestion*`、`AskUserQuestionPrompter.*`、`AgentLoopQuestionInterjection.*` 共 54 个用例全绿。注意:旧 `build/windows-x64-dev` 里还留着 09-13 的过期目标文件(含 `agent_loop.hpp`、`ask_user_question_prompter.hpp` 等已变动的头文件),直接跑会出现与本改动无关的堆损坏崩溃;清掉过期 obj 重编后全部通过。 \ No newline at end of file diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index d2856481..e2a3d819 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -4024,6 +4024,10 @@ bool AgentLoop::execute_tool_calls( item["question_id"] = a.question_id; item["selected"] = a.selected; item["custom_text"] = a.custom_text; + // 对齐 TUI(ask_question_controller.cpp):selected 与 + // custom_text 均为空的题视为未作答,让 Web 端「跳过」 + // 在 LLM 结果中呈现为 "Not answered" 而非空串。 + item["not_answered"] = a.selected.empty() && a.custom_text.empty(); arr.push_back(std::move(item)); } out["answers"] = std::move(arr); diff --git a/src/tool/ask_user_question_tool.cpp b/src/tool/ask_user_question_tool.cpp index 6c858522..d77c7604 100644 --- a/src/tool/ask_user_question_tool.cpp +++ b/src/tool/ask_user_question_tool.cpp @@ -224,17 +224,23 @@ std::string format_ask_answers( nlohmann::json build_ask_user_question_result_metadata( const std::vector& question_order, const std::map& answers, - const std::set* auto_selected_questions) { + const std::set* auto_selected_questions, + const std::set* multi_select_questions) { nlohmann::json items = nlohmann::json::array(); for (const auto& q : question_order) { auto it = answers.find(q); const std::string& a = (it == answers.end()) ? std::string{} : it->second; const bool auto_selected = auto_selected_questions != nullptr && auto_selected_questions->count(q) != 0; + // multi_select 与 auto_selected 一样属于 UI 展示所需的形状信息:反馈卡 + // 会在落盘消息上重建,不带上它就只能在重载后丢掉「(多选)」标注。 + const bool multi_select = multi_select_questions != nullptr && + multi_select_questions->count(q) != 0; items.push_back({ {"question", q}, {"answer", a}, {"auto_selected", auto_selected}, + {"multi_select", multi_select}, }); } return nlohmann::json{ @@ -344,6 +350,17 @@ ToolResult make_policy_denied_ask_result(const char* origin) { return r; } +// 收集多选题目文本,供 build_ask_user_question_result_metadata 在落盘元数据里 +// 标注 (多选),使反馈卡在重载后仍能还原题型。 +static std::set multi_select_question_set( + const std::vector& questions) { + std::set out; + for (const auto& q : questions) { + if (q.multi_select) out.insert(q.question); + } + return out; +} + ToolResult make_timeout_adopted_ask_result( const std::vector& questions, const std::vector& question_order, @@ -371,6 +388,8 @@ ToolResult make_timeout_adopted_ask_result( auto_selected_questions.insert(q.question); } } + const std::set multi_select_questions = + multi_select_question_set(questions); ToolResult r; r.success = true; r.output = @@ -380,7 +399,7 @@ ToolResult make_timeout_adopted_ask_result( "their first Recommended option when available, otherwise Not answered. " + format_ask_answers(question_order, answers); r.metadata = build_ask_user_question_result_metadata( - question_order, answers, &auto_selected_questions); + question_order, answers, &auto_selected_questions, &multi_select_questions); r.metadata["ask_user_question_auto"] = { {"mode", "timeout"}, {"seconds", timeout_seconds}, @@ -642,10 +661,13 @@ ToolImpl create_ask_user_question_tool_async(int max_questions) { } auto answers = parse_async_response(resp); + const std::set multi_select_questions = + multi_select_question_set(*parsed); ToolResult r; r.success = true; r.output = format_ask_answers(question_order, answers); - r.metadata = build_ask_user_question_result_metadata(question_order, answers); + r.metadata = build_ask_user_question_result_metadata( + question_order, answers, nullptr, &multi_select_questions); return r; }; diff --git a/src/tool/ask_user_question_tool.hpp b/src/tool/ask_user_question_tool.hpp index 85c565fb..bc2c28fd 100644 --- a/src/tool/ask_user_question_tool.hpp +++ b/src/tool/ask_user_question_tool.hpp @@ -44,7 +44,8 @@ std::string format_ask_answers( nlohmann::json build_ask_user_question_result_metadata( const std::vector& question_order, const std::map& answers, - const std::set* auto_selected_questions = nullptr); + const std::set* auto_selected_questions = nullptr, + const std::set* multi_select_questions = nullptr); // Build a compact UI-only Q/A transcript from ask_user_question_result // metadata. Returns empty for missing or malformed metadata. diff --git a/tests/tool/ask_user_question_tool_test.cpp b/tests/tool/ask_user_question_tool_test.cpp index 7ff0654e..ef84673e 100644 --- a/tests/tool/ask_user_question_tool_test.cpp +++ b/tests/tool/ask_user_question_tool_test.cpp @@ -381,10 +381,34 @@ TEST(AskUserQuestionFormatTest, QuoteInAnswerIsNotEscaped) { } // 场景:拒绝路径固定 ToolResult —— success=false 且 output 精确匹配。 +// metadata 必须带 cancelled 标记:「已取消全部回答」反馈卡靠它作为落盘依据, +// 才能在回合结束/会话重载后持续展示。 TEST(AskUserQuestionRejectedTest, ConstantRejectedResult) { auto r = make_rejected_ask_result(); EXPECT_FALSE(r.success); EXPECT_EQ(r.output, "[Error] User declined to answer questions."); + ASSERT_TRUE(r.metadata.is_object()); + const auto& result = r.metadata["ask_user_question_result"]; + EXPECT_TRUE(result["cancelled"].get()); + ASSERT_TRUE(result["items"].is_array()); + EXPECT_TRUE(result["items"].empty()); +} + +// 场景:多选标记必须随元数据落盘。反馈卡会在已持久化的消息上重建, +// 不落 multi_select 就会在重载后丢掉「(多选)」标注。 +TEST(AskUserQuestionFormatTest, StructuredResultMetadataPersistsMultiSelect) { + std::vector order{"Q1?", "Q2?"}; + std::map ans{ + {"Q1?", "A"}, + {"Q2?", "B, C"} + }; + std::set multi_select{"Q2?"}; + + auto meta = build_ask_user_question_result_metadata(order, ans, nullptr, &multi_select); + const auto& items = meta["ask_user_question_result"]["items"]; + ASSERT_EQ(items.size(), 2u); + EXPECT_FALSE(items[0]["multi_select"].get()); + EXPECT_TRUE(items[1]["multi_select"].get()); } // active goal 仍使用提问组件,但固定 30 秒超时,到期自动采纳 diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index b401bd46..87f5800a 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1311,4 +1311,17 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '当前模型不支持只读旁路聊天,请切换模型后重试。': 'This model does not support tool-free side chat. Switch models and try again.', '已停止': 'Stopped', + // Web AskUserQuestion 对齐 TUI(redesign-web-ask-user-question) + '{{p0}} 个问题待回答': '{{p0}} questions pending', + '跳过': 'Skip', + '(可多选)': '(Multi-select)', + '上一题 (Shift+Tab)': 'Previous (Shift+Tab)', + '下一题 (Tab)': 'Next (Tab)', + '继续回答': 'Continue answering', + '[推荐]': '[Recommended]', + '选择并进入下一题': 'Select and go to the next question', + '输入你的答案': 'Enter your answer', + '(多选)': ' (Multi)', + '全部提交完成': 'All answers submitted', + '未作答': 'Not answered', }); diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index ea062c18..49f880a5 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -48,6 +48,7 @@ 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'; @@ -93,6 +94,7 @@ 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'; @@ -589,8 +591,11 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable const subagentTasks = useSubagentTasks(sid, { onSpawnStart: openSubagentPanelForSpawn, }); - // 放在 submit 之前:提问挂起时的插话分支要读 questionForView,useCallback 的 - // deps 在渲染期求值,memo 必须先于它声明(否则 TDZ)。 + // 提交/取消 AskUserQuestion 后,在消息流中跟随 AskUserQuestion 消息展示的 + // 反馈卡(全部提交完成 / 已取消全部回答)。 + const [questionFeedback, setQuestionFeedback] = useState(null); + // 当前视图可见的待答问题。提问挂起期间 composer dock 由提问框整体替换 + // (方案 A),所以它只驱动渲染,不再参与 submit 的分支判定。 const questionForView = useMemo(() => { if (!questionRequest) return null; const reqSid = questionRequest.session_id || ''; @@ -1216,6 +1221,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable draftEditVersionRef.current += 1; composerDirtyRef.current = true; setComposerValue(next); + if (next) setQuestionFeedback(null); if (!sid) onHomeComposerDraftChange?.(homeDraftWorkspaceHash, next); }, [homeDraftWorkspaceHash, onHomeComposerDraftChange, sid]); @@ -2908,66 +2914,9 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable // 本轮产生新变更 / todo 更新后按签名机制重现。 dockAutoDismissRef.current(); } - // AskUserQuestion 挂起时直接输入 = 插话:不是排队、不是打断。daemon 把 - // 问题以「用户改为直接输入」收掉,这条消息紧跟工具结果进入同一回合,模型 - // 据此继续。问题若已在别处结束(409 NO_PENDING_QUESTION)则退回普通路径。 - // 后台任务(子会话)的问题:payload 自带 session_id,插话也路由回子会话。 - if (sid && !isBuiltin && questionForView?.request_id) { - if (composerSubmitting) return; - const targetSid = questionForView.session_id || sid; - const requestId = questionForView.request_id; - const interjectPayload = { - ...payload, - request_id: requestId, - client_message_id: - `interject-${targetSid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - }; - const fallbackToOrdinaryPath = () => { - if (busy) { - enqueueInput(payload); - clearCurrentSessionDraft({ expectedText: submittedComposerText }); - clearComposerExtras(); - toast({ kind: 'ok', text: '问题已结束,消息已加入排队' }); - return; - } - applyEvent({ type: 'busy_changed', payload: { busy: true } }, { emitEffects: false }); - sendInputOrBuiltin(sid, payload) - .then(() => { - if (payload.text.trim()) recordInputHistory(payload.text); - clearCurrentSessionDraft({ expectedText: submittedComposerText }); - clearComposerExtras(); - }) - .catch((e) => { - toast({ kind: 'err', text: '发送失败:' + (e.message || '') }); - applyEvent({ type: 'busy_changed', payload: { busy: false } }, { emitEffects: false }); - }); - }; - setComposerSubmitting(true); - api.interjectQuestion(targetSid, interjectPayload) - .then(() => { - if (payload.text.trim()) recordInputHistory(payload.text); - clearCurrentSessionDraft({ expectedText: submittedComposerText }); - clearComposerExtras(); - // 服务端会随 question_closed(interjected) 收掉问题;这里先本地收起, - // 免得 WS 往返期间再次提交撞上同一个 request_id。 - onQuestionResolve?.(); - }) - .catch((e) => { - if (e?.code === 'NO_PENDING_QUESTION') { - onQuestionResolve?.(); - fallbackToOrdinaryPath(); - return; - } - toast({ kind: 'err', text: '插话失败:' + (e?.message || '未知错误') }); - }) - .finally(() => { - setComposerSubmitting(false); - // focusChatInput 在 questionRequest 仍存在时会让位给 picker;插话后 - // picker 正在收起,直接把焦点还给输入框(与 resolveQuestion 同款)。 - requestAnimationFrame(() => inputRef.current?.focus()); - }); - return; - } + // 提问挂起期间没有插话入口:composer dock 被提问框整体替换(方案 A), + // 输入框不渲染,submit 只可能来自「没有待答问题」的那一帧渲染。这里不做 + // 提问插话分支,避免在不可达路径上保留第二套提问收尾逻辑。 if (!sid) { // 自动新建会话。普通消息由 daemon auto_start 接管;builtin 先创建 // 空会话,再走专门 command endpoint。 @@ -3118,7 +3067,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable applyEvent({ type: 'busy_changed', payload: { busy: false } }, { emitEffects: false }); }) .finally(() => setComposerSubmitting(false)); - }, [sid, busy, activeTurnId, api, homeSubmitting, recordInputHistory, enqueueInput, applyEvent, setTranscriptTitle, sendInputOrBuiltin, executeBuiltinCommand, composerSubmitting, clearCurrentSessionDraft, composerAttachments, composerContexts, composerSwarmMode, clearComposerExtras, createHomeComposerSession, persistMediaFilesToSession, restoreChatInputFocusSoon, setTailFollowFromAction, runSideQuestion, draftWorkspaceHash, homeDraftWorkspaceHash, onHomeComposerDraftAccepted, questionForView, onQuestionResolve, ref?.noWorkspace, ref?.no_workspace, ref?.workspaceHash, ref?.workspace_hash]); + }, [sid, busy, activeTurnId, api, homeSubmitting, recordInputHistory, enqueueInput, applyEvent, setTranscriptTitle, sendInputOrBuiltin, executeBuiltinCommand, composerSubmitting, clearCurrentSessionDraft, composerAttachments, composerContexts, composerSwarmMode, clearComposerExtras, createHomeComposerSession, persistMediaFilesToSession, restoreChatInputFocusSoon, setTailFollowFromAction, runSideQuestion, draftWorkspaceHash, homeDraftWorkspaceHash, onHomeComposerDraftAccepted, ref?.noWorkspace, ref?.no_workspace, ref?.workspaceHash, ref?.workspace_hash]); const drainQueuedInput = useCallback(() => { const targetSid = sidRef.current; @@ -4334,11 +4283,33 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable subagentTasks.tasks, ]); - const resolveQuestion = useCallback(() => { + const resolveQuestion = useCallback((feedback) => { + if (feedback) setQuestionFeedback(feedback); 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]); + const sidePanelMounted = showSidePanel; const sidePanelNavigationCollapsed = sidePanelCollapsed || sidePanelListCollapsed; const previewScope = useMemo( @@ -4977,7 +4948,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable {questionForView && ( - + )} {createProjectOpen && ( ( (turnFileListPlacement.before.get(it.id) || []).map((set) => (
)} @@ -5404,6 +5377,8 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable
) : (
+ {!questionForView ? ( + <> + + ) : null}
)} +
+ + 已改为直接输入,取消作答 +
+ + ); + } + + return ( +
+ {cancelled ? ( +
+ + + +

已取消全部回答

+
+ ) : ( +
+
+ + + +

全部提交完成

+
+ {summary.length > 0 && ( +
    + {summary.map((item, index) => ( +
  • +
    + {index + 1}. {item?.question} + {item?.multiSelect ? '(多选)' : ''} +
    +
    + {item?.notAnswered ? ( + 未作答 + ) : ( + {item?.answer} + )} +
    +
  • + ))} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index 5caa7460..3f58d42f 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -1,5 +1,5 @@ -// AskUserQuestion 内联 picker:停靠在输入框上方,不使用全屏 modal。 -// 支持单选 / 多选 / 自定义答案 / 多题分页 / 键盘操作。 +// AskUserQuestion 内联 picker:停靠在输入框位置(替换 composer 输入区)。 +// 支持单选 / 多选 / 自定义答案 / 多题导航 / 折叠 / 复制 / Enter 快捷键。 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { connection } from '../lib/connection.js'; @@ -8,13 +8,17 @@ import { VsIcon } from './Icon.jsx'; import { buildQuestionAnswerPayload, buildQuestionCancelPayload, + buildQuestionSummary, getNavigationState, hasSelectedTextWithin, + isQuestionAnswered, makeInitialAnswers, normalizeQuestionRequest, + recommendedOptionIndex, selectAnswerCustom, setAnswerCustom, toggleAnswerSelection, + unselectAnswerCustom, } from '../lib/questionPicker.js'; const READABLE_TEXT_STYLE = { overflowWrap: 'anywhere', wordBreak: 'break-word' }; @@ -22,48 +26,70 @@ const SELECTABLE_OPTION_STYLE = { WebkitUserSelect: 'text', userSelect: 'text', }; +const MAX_CUSTOM_LENGTH = 500; +const COPY_FEEDBACK_MS = 1500; +// Esc 一次取消所有选中;在此窗口内再次按 Esc 才视为拒绝作答(取消整个问答)。 +const ESC_ARM_WINDOW_MS = 1200; function focusSoon(ref) { - requestAnimationFrame(() => ref.current?.focus()); + requestAnimationFrame(() => ref.current?.focus?.()); } -// 提问期间输入框不再禁用(直接输入 = 插话)。用户正在输入框里打字时,picker -// 挂载不能抢焦点 —— 否则接下来敲的数字 / Enter 会变成选项选择与提交。 -// 输入框为空时照旧把焦点给 picker,保证键盘答题(数字 / 方向键 / Enter)可用。 -function composerIsMidTyping(active = typeof document !== 'undefined' ? document.activeElement : null) { - if (!active) return false; - const tag = active.tagName; - const editable = active.isContentEditable || tag === 'TEXTAREA' || tag === 'INPUT'; - if (!editable) return false; - const text = tag === 'TEXTAREA' || tag === 'INPUT' - ? String(active.value || '') - : String(active.textContent || ''); - return text.trim().length > 0; +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + try { + const el = document.createElement('textarea'); + el.value = text; + el.style.position = 'fixed'; + el.style.opacity = '0'; + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + return true; + } catch { + return false; + } + } } -export function QuestionPicker({ request, onResolve, originLabel = '' }) { +export function QuestionPicker({ request, onResolve, onFeedback, originLabel = '' }) { const normalized = useMemo(() => normalizeQuestionRequest(request), [request]); const { questions } = normalized; const [answers, setAnswers] = useState(() => makeInitialAnswers(questions)); const [currentIndex, setCurrentIndex] = useState(0); const [focusIndex, setFocusIndex] = useState(0); const [collapsed, setCollapsed] = useState(false); + const [copiedIndex, setCopiedIndex] = useState(-1); + const [editingCustom, setEditingCustom] = useState(false); const rootRef = useRef(null); const customRef = useRef(null); + const copiedTimerRef = useRef(null); + // 记录 Esc「取消选中」与「拒绝作答」之间的连按窗口。 + const escTimerRef = useRef(null); useEffect(() => { setAnswers(makeInitialAnswers(questions)); setCurrentIndex(0); setFocusIndex(0); setCollapsed(false); - if (!composerIsMidTyping()) focusSoon(rootRef); + setEditingCustom(false); + focusSoon(rootRef); }, [normalized.requestId, questions]); + useEffect(() => () => { + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + }, []); + const question = questions[currentIndex]; - const answer = answers[currentIndex] || { selected: [], custom: '', customSelected: false }; + const answer = answers[currentIndex] || { selected: [], custom: '', customSelected: false, skipped: false }; const optionCount = question?.options?.length || 0; const customIndex = optionCount; const nav = getNavigationState(currentIndex, questions, answers); + const isMulti = !!question?.multiSelect; const updateAnswer = useCallback((index, updater) => { setAnswers((prev) => prev.map((item, i) => i === index ? updater(item) : item)); @@ -73,17 +99,32 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { onResolve?.(); }, [onResolve]); + // Esc 一次:取消所有问题的选中态(清空已选选项与自定义激活态),保留自定义草稿文字。 + const resetAllSelections = useCallback(() => { + setAnswers((prev) => prev.map((a) => ({ ...a, selected: [], customSelected: false }))); + setEditingCustom(false); + focusSoon(rootRef); + }, []); + const cancel = useCallback(() => { connection.sendQuestionAnswer(buildQuestionCancelPayload(normalized)); + onFeedback?.({ kind: 'cancel' }); resolve(); - }, [normalized, resolve]); + }, [normalized, onFeedback, resolve]); + + const submitCurrent = useCallback((i) => { + setCurrentIndex(Math.min(questions.length - 1, i + 1)); + setFocusIndex(0); + focusSoon(rootRef); + }, [questions.length]); - const submit = useCallback(() => { + const submitAll = useCallback(() => { 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, questions, resolve]); + }, [answers, currentIndex, normalized, onFeedback, questions, resolve]); const goPrev = useCallback(() => { setCurrentIndex((value) => Math.max(0, value - 1)); @@ -93,69 +134,150 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { const goNext = useCallback(() => { const state = getNavigationState(currentIndex, questions, answers); - if (!state.canGoNext) return; + if (state.isLast) return; + // 向后切换 = 进入下一题;当前题未作答时自动记 Not answered(跳过)。 + setAnswers((prev) => prev.map((item, i) => + i === currentIndex && !state.currentAnswered ? { ...item, skipped: true } : item)); setCurrentIndex((value) => Math.min(questions.length - 1, value + 1)); setFocusIndex(0); focusSoon(rootRef); }, [answers, currentIndex, questions]); + // 主操作:非末题在本地推进到下一题(daemon 为 first-wins,只允许末题统一收卷, + // 中途不可发送 question_answer,否则会提前关闭整个请求);末题提交全部。 const primaryAction = useCallback(() => { const state = getNavigationState(currentIndex, questions, answers); - if (state.isLast) submit(); - else goNext(); - }, [answers, currentIndex, goNext, questions, submit]); + if (state.isLast) submitAll(); + else if (state.canGoNext) submitCurrent(currentIndex); + }, [currentIndex, questions, answers, submitAll, submitCurrent]); + + const skipCurrent = useCallback(() => { + if (!nav.canSkip) return; + setAnswers((prev) => prev.map((item, i) => i === currentIndex ? { ...item, skipped: true } : item)); + submitCurrent(currentIndex); + }, [currentIndex, nav.canSkip, submitCurrent]); const selectOption = useCallback((optionIndex) => { const opt = question?.options?.[optionIndex]; if (!opt) return; setFocusIndex(optionIndex); - updateAnswer(currentIndex, (item) => toggleAnswerSelection(item, opt.value, !!question.multiSelect)); - }, [currentIndex, question, updateAnswer]); + updateAnswer(currentIndex, (item) => toggleAnswerSelection(item, opt.value, isMulti)); + }, [currentIndex, isMulti, question, updateAnswer]); + + // Enter 一键:单选/多选选中当前焦点项并进入下一题;末题只选中(需 Ctrl+Enter 提交)。 + const commitEnter = useCallback((optionIndex) => { + const opt = question?.options?.[optionIndex]; + if (!opt) return; + setFocusIndex(optionIndex); + updateAnswer(currentIndex, (item) => toggleAnswerSelection(item, opt.value, isMulti)); + const state = getNavigationState(currentIndex, questions, { ...answers, [currentIndex]: answers[currentIndex] }); + if (!state.isLast) { + submitCurrent(currentIndex); + } + }, [answers, currentIndex, isMulti, question, questions, submitCurrent, updateAnswer]); const selectCustom = useCallback(() => { setFocusIndex(customIndex); - updateAnswer(currentIndex, (item) => selectAnswerCustom(item, !!question?.multiSelect)); - }, [currentIndex, customIndex, question, updateAnswer]); + setEditingCustom(true); + if (!isMulti) updateAnswer(currentIndex, (item) => selectAnswerCustom(item, false)); + }, [customIndex, currentIndex, isMulti, updateAnswer]); const setCustom = useCallback((value) => { - updateAnswer(currentIndex, (item) => setAnswerCustom(item, value, !!question?.multiSelect)); - }, [currentIndex, question, updateAnswer]); + const next = value.slice(0, MAX_CUSTOM_LENGTH); + updateAnswer(currentIndex, (item) => setAnswerCustom(item, next, isMulti)); + }, [currentIndex, isMulti, updateAnswer]); const moveFocus = useCallback((delta) => { const count = optionCount + 1; setFocusIndex((value) => Math.min(count - 1, Math.max(0, value + delta))); }, [optionCount]); + const copyOption = useCallback(async (optionIndex, event) => { + if (event) event.stopPropagation(); + const opt = question?.options?.[optionIndex]; + if (!opt) return; + const text = opt.description ? `${opt.label} — ${opt.description}` : opt.label; + await copyText(text); + setCopiedIndex(optionIndex); + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = setTimeout(() => setCopiedIndex(-1), COPY_FEEDBACK_MS); + }, [question]); + const onKeyDown = useCallback((event) => { if (!question) return; const target = event.target; const tag = target?.tagName; const inTextInput = tag === 'INPUT' || tag === 'TEXTAREA'; + const withCtrl = event.ctrlKey || event.metaKey; + const ctrlEnter = withCtrl && event.key === 'Enter'; if (event.key === 'Escape') { event.preventDefault(); - cancel(); + if (inTextInput) { + // 自定义输入框内:先退出编辑态,不参与连按判定。 + setEditingCustom(false); + focusSoon(rootRef); + return; + } + // 连按窗口内再按 Esc = 拒绝回答问题(取消整个问答)。 + if (escTimerRef.current) { + clearTimeout(escTimerRef.current); + escTimerRef.current = null; + cancel(); + return; + } + // 第一次 Esc:取消所有问题选中,并开启连按窗口。 + resetAllSelections(); + escTimerRef.current = setTimeout(() => { + escTimerRef.current = null; + }, ESC_ARM_WINDOW_MS); return; } if (inTextInput) { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); primaryAction(); } return; } + if (ctrlEnter) { + event.preventDefault(); + if (nav.isLast) submitAll(); + return; + } + + if (event.key === 'Tab') { + event.preventDefault(); + if (event.shiftKey) goPrev(); + else goNext(); + return; + } + if (/^[1-9]$/.test(event.key)) { const index = Number(event.key) - 1; if (index <= optionCount) { event.preventDefault(); - if (index < optionCount) selectOption(index); - else customRef.current?.focus(); + if (index < optionCount) commitEnter(index); + else { + setEditingCustom(true); + customRef.current?.focus(); + } } return; } + if (event.key === 'ArrowRight') { + event.preventDefault(); + goNext(); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + goPrev(); + return; + } if (event.key === 'ArrowDown') { event.preventDefault(); moveFocus(1); @@ -169,202 +291,259 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { if (event.key === ' ') { event.preventDefault(); if (focusIndex < optionCount) selectOption(focusIndex); - else customRef.current?.focus(); + else { + setEditingCustom(true); + customRef.current?.focus(); + } return; } if (event.key === 'Enter') { event.preventDefault(); - if (nav.currentAnswered) { - primaryAction(); - } else if (focusIndex < optionCount) { - selectOption(focusIndex); - } else { - customRef.current?.focus(); - } + if (focusIndex < optionCount) commitEnter(focusIndex); } - }, [cancel, focusIndex, moveFocus, nav.currentAnswered, optionCount, primaryAction, question, selectOption]); + }, [cancel, commitEnter, focusIndex, goNext, goPrev, moveFocus, nav.isLast, optionCount, primaryAction, question, resetAllSelections, selectOption, submitAll]); if (!question) return null; + const countdownLabel = `${nav.current} / ${nav.total}`; + const collapsedHint = `${nav.total} 个问题待回答`; + const primaryLabel = nav.isLast ? '提交' : (nav.currentAnswered ? '提交' : '跳过'); + const primaryBtnLabel = primaryLabel === '提交' + ? (nav.isLast ? '提交' : '提交') + : '跳过'; + const primaryKeyHint = nav.isLast ? 'Ctrl + Enter' : 'Enter'; + const customActive = !!answer.customSelected && answer.custom.trim().length > 0; + return (
-
-
+
+
{originLabel && (
{originLabel}
)} - {question.header && ( + {!collapsed && (
- {question.header} + {question.text} + {isMulti && (可多选)}
)} -
{collapsedHint}
+ )} +
+ + {!collapsed && ( +
+ + + )} +
+ {collapsed ? countdownLabel : countdownLabel}
+ {!collapsed && ( + + )} +
+ + {collapsed ? ( -
- - {collapsed ? ( -
- 已折叠,继续等待回答。 - {nav.current}/{nav.total} -
) : ( <> -
+
{question.options.map((opt, index) => { const selected = answer.selected?.includes(opt.value); const focused = focusIndex === index; + const copied = copiedIndex === index; return ( - + - +
); })} -
); -} +} \ No newline at end of file diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index 62100f00..ad4afe8c 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -20,7 +20,6 @@ import { highlightSourceForFile } from '../lib/sourceCodeHighlight.js'; import { fallbackToolSummary } from '../lib/toolSummaryFallback.js'; import { codeTextFromCopyButtonTarget, copyTextToClipboard } from '../lib/codeBlockCopy.js'; import { normalizeTaskCompleteMarkdown } from '../lib/taskCompleteSummary.js'; -import { questionFeedbackForTool } from '../lib/questionFeedback.js'; import { DESKTOP_CONTEXT_ACTION_EVENT, DESKTOP_CONTEXT_ACTIONS, @@ -110,35 +109,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,58 +155,7 @@ 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 ( -
- - {!collapsed && ( -
- {items.map((item, index) => ( -
-
- Q - - {item.question} - -
-
- A - - {item.answer} - -
-
- ))} -
- )} -
- ); -} export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessionRunning = true }) { useTranslation(); @@ -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 (
{before} @@ -466,6 +468,7 @@ export function TranscriptItems({ showAceCodeAvatar={showAceCodeAvatar} annotationPresentations={annotationPresentations} /> + {after} ); }); diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index b902a619..02e43f0a 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -135,7 +135,6 @@ export const sourceCatalogs = { "s_0fc55068f541c2cb": "升级安装完成,请完全退出并重新启动 ACECode", "s_0fdada0fc86aed7f": "复制失败:", "s_0ffcfe1af05f8653": "已改为直接输入,取消作答", - "s_101c052fc05fd63d": "已折叠,继续等待回答。", "s_10e80ac177155143": "消息不能为空", "s_111448a2e1a82306": "还有 {{p0}} 个字段", "s_11152b370ff3e7ca": "超高画质模型", @@ -268,6 +267,7 @@ export const sourceCatalogs = { "s_20aaca92af97ede8": "请选择一个 Provider", "s_20bf3c0c8b2e30d3": "无法编辑此文件", "s_20c0d1de7d3b30c0": "写入 {{p0}} 行", + "s_20c393a9029f0004": "选择并进入下一题", "s_20cb887a4825b754": "Grok 登录已开始", "s_20d82c4804569113": "正在打断当前回合…", "s_20df5a56daf04184": "请求未记录(旧记录未保存工具调用参数)", @@ -354,8 +354,8 @@ export const sourceCatalogs = { "s_2ca1a16a870f47d3": "未找到相关设置", "s_2cb98fbdc3256b2a": "当前 daemon 不支持 LOOP", "s_2cc0b5d70839d137": "Mermaid 源码", - "s_2cc37f08f697a58b": "取消回答", "s_2cd0f3be8738a86c": "取消", + "s_2cda0402c915608c": "[推荐]", "s_2ce5c86917eca527": "页面内存不足", "s_2cffe851c7765400": "展开整个右侧面板", "s_2d1d8c1e38956bea": "后退", @@ -369,6 +369,7 @@ export const sourceCatalogs = { "s_2df7697dbb612878": "智能体", "s_2e2fc2c95a2a682c": "无匹配命令", "s_2e6bfec616ad6945": "团队成员", + "s_2e7e1c747a435c14": "(多选)", "s_2ec809a15744b733": "外观配置保存失败,主题未删除", "s_2efad4f47ae7804b": "工具完成", "s_2f067f4185b25551": "每周{{p0}} {{p1}}", @@ -503,6 +504,7 @@ export const sourceCatalogs = { "s_4048d8cd52d1ca52": "模型 {{p0}} 正在被会话使用,暂时不能删除", "s_4058befef3a6363e": "等待用户处理权限请求", "s_4088127b773ce83c": "编辑代码", + "s_40cc8bac959395ae": "(可多选)", "s_40d1fb9add6b27e3": "从项目列表移除", "s_40fae00b7c6d8ac0": "常规", "s_410a8e8a6bf253ac": "剪切", @@ -580,12 +582,12 @@ export const sourceCatalogs = { "s_49ccde43a1549791": "项", "s_49da61ceeea2f271": "天", "s_49e62015fc16edaf": "编辑循环", - "s_4a0a8b6ecae9416b": "回答上方问题,或直接输入插话(将取消作答,交给 AI 继续)", "s_4a2345883e1b1d62": "目标受限", "s_4a6d8d490c54d438": "允许一次", "s_4a83accbad2c11e1": "开场白", "s_4a8d6841b4023edf": "确认导入", "s_4ad732f1d78f6725": "后台进程状态", + "s_4ad8aa6461e64127": "上一题 (Shift+Tab)", "s_4afad488b461b58f": "侧边聊天记录", "s_4b427656a253ca1e": "请完全退出并重新启动 ACECode", "s_4b47dbae97ba938a": "提示词", @@ -753,6 +755,7 @@ export const sourceCatalogs = { "s_5ebc2ffa60cfaf2b": "图像服务额度不足或请求过于频繁,请检查额度后重试", "s_5efd57456cbc23c5": "或运行", "s_5f2cfbfecf9fc4a6": "快捷入口", + "s_5f53727059cdc69a": "输入你的答案", "s_5f5a4c5fb8ab71d6": "输入框当前不可用", "s_5f5b6195dfd24aa1": "等待交互内容同步", "s_5f7780324a4b55f1": "会话续接建议", @@ -1066,6 +1069,7 @@ export const sourceCatalogs = { "s_867ea0e1328728d9": "错误:", "s_8696c49ac55b8c71": "超高", "s_86a63f23a076b11e": "外观", + "s_86a82374a35763d1": "{{p0}} 个问题待回答", "s_86a88f06d31bd3b6": "正在读取 macOS 系统通知权限", "s_86b6d0d63062ba81": "正在读取…", "s_86b7355ec3bd55ef": "隐藏 API Key", @@ -1329,7 +1333,6 @@ export const sourceCatalogs = { "s_aa3341e7359711df": "单独配置", "s_aa5e627d1fad8738": "正在导入主题…", "s_aa5f39fc482f38db": "高级模式", - "s_aa691fdc6d65318f": "输入自定义答案", "s_aa6d8879058e32fb": "本会话暂无文件变更", "s_aae110840475eea7": "管理 ACECode 可调用的技能模块。启用后 Agent 在任务中可自动使用。", "s_aae897a12157cc7c": "ACECode 本地工具", @@ -1375,6 +1378,7 @@ export const sourceCatalogs = { "s_af525b682a4bd1c5": "使用 ACECode 管理的登录和端点", "s_af654152fe716012": "当前安装没有被修改,可以稍后重新升级。", "s_af8043aec70ffeb2": "展开智能体", + "s_af8f7e7c5bb42c09": "下一题 (Tab)", "s_afa546d0e611093d": "永久删除全部已结束任务(不影响主会话)", "s_afba84d2a5f90faf": "彻底删除选中的 {{p0}} 个会话?此操作不可撤销。", "s_afd4b783536b8297": "收起", @@ -1389,6 +1393,7 @@ export const sourceCatalogs = { "s_b19b09a6ed90c4be": "{{p0}}当前生效头像", "s_b1a521adee6fcd78": "旁路聊天响应格式错误,请重试。", "s_b1c27820fec23edb": "高", + "s_b22b499ae69d15cc": "继续回答", "s_b25adad93000204a": "计划文件为空", "s_b2749206c4e01861": "旁路提问失败", "s_b2888f5f93782bb7": "加载会话失败:", @@ -1411,6 +1416,7 @@ export const sourceCatalogs = { "s_b52b7d39abcf20e5": "搜索当前对话内容", "s_b570c915f747718f": "暂无文件变更", "s_b5bb09526db21805": "快速找到、配置并维护聊天使用的模型连接。Provider 决定可用字段,预设保存后才会出现在会话模型选择器中。", + "s_b5f241af1539b4b8": "未作答", "s_b5f8831006f44a2d": "使用 PATH", "s_b5f9b6d088eecd69": "导出任务已过期,请重新导出", "s_b5fe62cb7e692ad5": "搜索当前对话内容 (Ctrl+F)", @@ -1524,7 +1530,6 @@ export const sourceCatalogs = { "s_c3a73c5eecea42a3": "专家", "s_c3b8f744ed2c55bf": "JSON 格式错误:", "s_c3f989b790daab98": "后台服务暂不可用,请稍后重试", - "s_c474884feee4223d": "插话失败:", "s_c4ae8fa5a16b5a75": "跳过所有工具权限确认", "s_c4c5b649cbf25fbb": "记忆与上下文", "s_c52c7fbed621e8ff": "批注 {{p0}}", @@ -1635,7 +1640,6 @@ export const sourceCatalogs = { "s_d2671739eec0a619": "会话运行在 worktree:{{p0}}", "s_d276202550a2206f": "关闭窗口操作失败:", "s_d28b2fa39f9edd3e": "调整控制台高度", - "s_d2909f1647e7c891": "其他", "s_d2c1080da731d8a9": "升级没有完成", "s_d2e48b56205e4922": "仅 Windows Desktop", "s_d2fc539871184000": "插话已提交,正在打断当前回合", @@ -1659,7 +1663,6 @@ export const sourceCatalogs = { "s_d52f61a0616ceeca": "输入模型 ID,按 Enter 添加", "s_d538a7ab8d9826bb": "启动导入失败", "s_d53d32f1a1211371": "自定义请求头 JSON", - "s_d56a511f498f7e73": "已确认", "s_d57bc0acdb5f23ab": "加载 diff 失败", "s_d5d126f6c79d9b11": "所有工作区", "s_d5d360963aa38a66": "打开会话失败:", @@ -1681,7 +1684,6 @@ export const sourceCatalogs = { "s_d74679ad02055214": "钩子操作失败:", "s_d74f204139fdd6ab": "搜索专家名称、Tag 或擅长领域", "s_d7503697e8e7850b": "重试主题工坊", - "s_d77817f1c5c26789": "问题已结束,消息已加入排队", "s_d7875850553752e5": "切换专家失败:{{p0}}", "s_d7b2165028a50b95": "自动编辑和运行沙盒内命令,危险操作与越界请求需确认", "s_d803180e5bca806d": "暂无已注册的工作区", @@ -1763,6 +1765,7 @@ export const sourceCatalogs = { "s_e17df82098f51602": "已将控制台日志添加到聊天", "s_e19c3b9e266c69c9": "浏览器", "s_e1c51786621da01b": "无法打开该地址", + "s_e1dd05dd35264366": "全部提交完成", "s_e1f4e47353387da0": "已允许并写入规则", "s_e204b68338464c50": "先配置一个模型", "s_e230e085131898d2": "拖动调整后台任务面板宽度", @@ -1824,7 +1827,6 @@ export const sourceCatalogs = { "s_e9f6657e82418cdd": "该专家的能力范围", "s_ea005d6d728fc7b0": "", "s_ea12cc5742484e5c": "指引已关闭,但状态保存失败,下次启动可能再次显示:", - "s_ea164dab448902ef": "数字选择 · Enter 确认 · Esc 取消", "s_ea4b29f58f879802": "新对话", "s_ea4bf042d1bc5eb8": "模型列表", "s_ea76aa0badf6e546": "模型申请在沙盒外执行", @@ -1981,6 +1983,7 @@ export const sourceCatalogs = { "s_fc1eaad1f0558fbd": "正在打开页面", "s_fc207807bee8c5f9": "汇总代码变化、未完成事项和下一步建议。", "s_fc455d618fecf57f": "Provider 文档", + "s_fc50a99caae0cddc": "跳过", "s_fc9cb8bc5414d5b3": "网站关闭了当前连接,请重新加载页面。", "s_fca3398d38668a75": "· 模型不可用", "s_fcac6c9df3a773c4": "Copilot 登录已开始,验证码已复制", @@ -2140,7 +2143,6 @@ export const sourceCatalogs = { "s_0fc55068f541c2cb": "The upgrade installation is complete, please exit completely and restart ACECode", "s_0fdada0fc86aed7f": "Copy failed:", "s_0ffcfe1af05f8653": "Answered by a direct message instead", - "s_101c052fc05fd63d": "Folded, continue to wait for the answer.", "s_10e80ac177155143": "Message cannot be empty", "s_111448a2e1a82306": "There are {{p0}} fields", "s_11152b370ff3e7ca": "Ultra quality model", @@ -2273,6 +2275,7 @@ export const sourceCatalogs = { "s_20aaca92af97ede8": "Select a provider", "s_20bf3c0c8b2e30d3": "Unable to edit this file", "s_20c0d1de7d3b30c0": "Write the {{p0}} line", + "s_20c393a9029f0004": "Select and go to the next question", "s_20cb887a4825b754": "Grok sign-in started", "s_20d82c4804569113": "Interrupting the active turn…", "s_20df5a56daf04184": "The request was not logged (old records did not save tool call parameters)", @@ -2359,8 +2362,8 @@ export const sourceCatalogs = { "s_2ca1a16a870f47d3": "No matching settings", "s_2cb98fbdc3256b2a": "The current daemon does not support LOOP", "s_2cc0b5d70839d137": "Mermaid source", - "s_2cc37f08f697a58b": "Cancel answer", "s_2cd0f3be8738a86c": "Cancel", + "s_2cda0402c915608c": "[Recommended]", "s_2ce5c86917eca527": "Page ran out of memory", "s_2cffe851c7765400": "Expand the entire right panel", "s_2d1d8c1e38956bea": "Back", @@ -2374,6 +2377,7 @@ export const sourceCatalogs = { "s_2df7697dbb612878": "agent", "s_2e2fc2c95a2a682c": "No matching command", "s_2e6bfec616ad6945": "Team members", + "s_2e7e1c747a435c14": "(Multi)", "s_2ec809a15744b733": "Appearance preferences could not be saved. The theme was not deleted.", "s_2efad4f47ae7804b": "Tool complete", "s_2f067f4185b25551": "Weekly {{p0}}{{p1}}", @@ -2508,6 +2512,7 @@ export const sourceCatalogs = { "s_4048d8cd52d1ca52": "Model {{p0}} is in use by a session and cannot be deleted yet", "s_4058befef3a6363e": "Waiting for user to process permission request", "s_4088127b773ce83c": "Edit code", + "s_40cc8bac959395ae": "(Multi-select)", "s_40d1fb9add6b27e3": "Archive", "s_40fae00b7c6d8ac0": "General", "s_410a8e8a6bf253ac": "cut", @@ -2585,12 +2590,12 @@ export const sourceCatalogs = { "s_49ccde43a1549791": "items", "s_49da61ceeea2f271": "sky", "s_49e62015fc16edaf": "Edit loop", - "s_4a0a8b6ecae9416b": "Answer the question above, or type to interject (cancels the answer and lets the AI continue)", "s_4a2345883e1b1d62": "Goal budget limited", "s_4a6d8d490c54d438": "allowed once", "s_4a83accbad2c11e1": "opening remarks", "s_4a8d6841b4023edf": "Confirm import", "s_4ad732f1d78f6725": "Background process", + "s_4ad8aa6461e64127": "Previous (Shift+Tab)", "s_4afad488b461b58f": "Side chat history", "s_4b427656a253ca1e": "Fully quit and restart ACECode", "s_4b47dbae97ba938a": "prompt word", @@ -2758,6 +2763,7 @@ export const sourceCatalogs = { "s_5ebc2ffa60cfaf2b": "The image service has insufficient credits or is rate-limited. Check your credits and retry.", "s_5efd57456cbc23c5": "or run", "s_5f2cfbfecf9fc4a6": "Quick access", + "s_5f53727059cdc69a": "Enter your answer", "s_5f5a4c5fb8ab71d6": "The input box is currently unavailable", "s_5f5b6195dfd24aa1": "Waiting for interactive content to be synchronized", "s_5f7780324a4b55f1": "Conversation continuation suggestion", @@ -3071,6 +3077,7 @@ export const sourceCatalogs = { "s_867ea0e1328728d9": "mistake:", "s_8696c49ac55b8c71": "Ultra", "s_86a63f23a076b11e": "Appearance", + "s_86a82374a35763d1": "{{p0}} questions pending", "s_86a88f06d31bd3b6": "Reading macOS system notification permissions", "s_86b6d0d63062ba81": "Loading…", "s_86b7355ec3bd55ef": "Hide API key", @@ -3334,7 +3341,6 @@ export const sourceCatalogs = { "s_aa3341e7359711df": "Configure separately", "s_aa5e627d1fad8738": "Importing theme…", "s_aa5f39fc482f38db": "Advanced Mode", - "s_aa691fdc6d65318f": "Enter custom answer", "s_aa6d8879058e32fb": "There are no file changes in this session", "s_aae110840475eea7": "Manage ACECode callable skill modules. After enabling, Agent can be used automatically in tasks.", "s_aae897a12157cc7c": "ACECode native tools", @@ -3380,6 +3386,7 @@ export const sourceCatalogs = { "s_af525b682a4bd1c5": "Uses ACECode-managed authentication and endpoint", "s_af654152fe716012": "The current installation was not changed. You can update again later.", "s_af8043aec70ffeb2": "Expand the agent", + "s_af8f7e7c5bb42c09": "Next (Tab)", "s_afa546d0e611093d": "Permanently delete all completed tasks (does not affect the main session)", "s_afba84d2a5f90faf": "Completely delete selected {{p0}} conversations? This action cannot be undone.", "s_afd4b783536b8297": "close", @@ -3394,6 +3401,7 @@ export const sourceCatalogs = { "s_b19b09a6ed90c4be": "Effective {{p0}} avatar", "s_b1a521adee6fcd78": "The side chat response was invalid. Try again.", "s_b1c27820fec23edb": "High", + "s_b22b499ae69d15cc": "Continue answering", "s_b25adad93000204a": "Plan file is empty", "s_b2749206c4e01861": "Side chat failed", "s_b2888f5f93782bb7": "Failed to load session:", @@ -3416,6 +3424,7 @@ export const sourceCatalogs = { "s_b52b7d39abcf20e5": "Search the current conversation", "s_b570c915f747718f": "No file changes yet", "s_b5bb09526db21805": "Find, configure, and maintain chat model connections. The provider determines available fields, and a profile appears in the chat model picker only after it is saved.", + "s_b5f241af1539b4b8": "Not answered", "s_b5f8831006f44a2d": "Using PATH", "s_b5f9b6d088eecd69": "The export has expired. Export the theme again.", "s_b5fe62cb7e692ad5": "Search the current conversation (Ctrl+F)", @@ -3529,7 +3538,6 @@ export const sourceCatalogs = { "s_c3a73c5eecea42a3": "Expert", "s_c3b8f744ed2c55bf": "JSON format error:", "s_c3f989b790daab98": "The backend is temporarily unavailable. Try again later.", - "s_c474884feee4223d": "Interjection failed:", "s_c4ae8fa5a16b5a75": "Skip all tool permission confirmations", "s_c4c5b649cbf25fbb": "Memory and context", "s_c52c7fbed621e8ff": "Annotation {{p0}}", @@ -3640,7 +3648,6 @@ export const sourceCatalogs = { "s_d2671739eec0a619": "The session runs in worktree:{{p0}}", "s_d276202550a2206f": "Close-window action failed:", "s_d28b2fa39f9edd3e": "Adjust console height", - "s_d2909f1647e7c891": "other", "s_d2c1080da731d8a9": "Upgrade not completed", "s_d2e48b56205e4922": "Desktop for Windows only", "s_d2fc539871184000": "Interjection submitted; interrupting the active turn", @@ -3664,7 +3671,6 @@ export const sourceCatalogs = { "s_d52f61a0616ceeca": "Enter a model ID and press Enter to add it", "s_d538a7ab8d9826bb": "Failed to start import", "s_d53d32f1a1211371": "Custom request headers JSON", - "s_d56a511f498f7e73": "Confirmed", "s_d57bc0acdb5f23ab": "Loading diff failed", "s_d5d126f6c79d9b11": "All workspaces", "s_d5d360963aa38a66": "Failed to open session:", @@ -3686,7 +3692,6 @@ export const sourceCatalogs = { "s_d74679ad02055214": "Hook operation failed:", "s_d74f204139fdd6ab": "Search for expert names, tags or areas of expertise", "s_d7503697e8e7850b": "Retry workshop", - "s_d77817f1c5c26789": "The question has already ended; your message was queued", "s_d7875850553752e5": "Failed to switch expert: {{p0}}", "s_d7b2165028a50b95": "Automatically edit and run sandboxed commands; confirm dangerous operations and requests outside the sandbox", "s_d803180e5bca806d": "There are no registered workspaces yet", @@ -3768,6 +3773,7 @@ export const sourceCatalogs = { "s_e17df82098f51602": "Added console logs to chat", "s_e19c3b9e266c69c9": "Browser", "s_e1c51786621da01b": "Unable to open this address", + "s_e1dd05dd35264366": "All answers submitted", "s_e1f4e47353387da0": "Allowed and saved to rules", "s_e204b68338464c50": "Configure a model first", "s_e230e085131898d2": "Drag to resize background task panel", @@ -3829,7 +3835,6 @@ export const sourceCatalogs = { "s_e9f6657e82418cdd": "The expert's scope of competence", "s_ea005d6d728fc7b0": "", "s_ea12cc5742484e5c": "The guide has been closed, but the state failed to be saved. It may be displayed again next time it is started:", - "s_ea164dab448902ef": "Number selection · Enter to confirm · Esc to cancel", "s_ea4b29f58f879802": "new conversation", "s_ea4bf042d1bc5eb8": "Model list", "s_ea76aa0badf6e546": "The model requests execution outside the sandbox", @@ -3986,6 +3991,7 @@ export const sourceCatalogs = { "s_fc1eaad1f0558fbd": "Opening page", "s_fc207807bee8c5f9": "A summary of code changes, unfinished business, and suggested next steps.", "s_fc455d618fecf57f": "Provider documentation", + "s_fc50a99caae0cddc": "Skip", "s_fc9cb8bc5414d5b3": "The site closed the connection. Reload the page.", "s_fca3398d38668a75": "· Model unavailable", "s_fcac6c9df3a773c4": "Copilot sign-in started and the verification code was copied", diff --git a/web/src/lib/composerEditabilityArchitecture.test.js b/web/src/lib/composerEditabilityArchitecture.test.js index 80bcf213..a7d7174a 100644 --- a/web/src/lib/composerEditabilityArchitecture.test.js +++ b/web/src/lib/composerEditabilityArchitecture.test.js @@ -52,68 +52,45 @@ run('提交在途只走 submitting,绝不并进 InputBar 的 disabled', () => { 'homeSubmitting 进 disabled 会让主页输入框在建会话期间变成只读', ); - // 主页 composer 的 disabled 只剩「有待回答的问题」这一个来源(没有会话可以 - // 承接插话);会话 composer 在提问挂起时**不再禁用** —— 直接输入 = 插话, - // daemon 把问题以「用户改为直接输入」收掉并让模型在同一回合继续。旧行为 - // (禁用 + 「请先回答上方问题」)正是时序问题的根源:用户只能先取消作答, - // 模型带着「用户拒答」先跑一截,那句话要等本回合结束才作为新回合送达。 + // 主页 composer 的 disabled 只剩「有待回答的问题」这一个来源(主页没有会话 + // 可以承接插话);会话 composer 在提问挂起时**根本不渲染** —— dock 整体换成 + // 提问框,所以它既不需要 disabled,也不该留「请先回答上方问题」这类旧提示。 assert.equal( (chatView.match(/disabled=\{!!questionForView\}/g) || []).length, 1, '只有主页 composer 由 questionForView 决定只读', ); - const sessionComposer = chatView.match(/
\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 回流,结果由 + // 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(' { + 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..7e8d6359 100644 --- a/web/src/lib/questionFeedback.js +++ b/web/src/lib/questionFeedback.js @@ -1,20 +1,96 @@ -// 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 写出 —— 这也是「取消结果不被折叠进历史活动」的前提。 +// +// 临时反馈只在 tool_end 回流之前的窗口里作为即时预览,一旦持久化结果到位就让位。 + +const ASK_TOOL_NAME = '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); +} + +function isAskToolItem(item) { + return item?.kind === 'tool' && item?.tool?.tool === ASK_TOOL_NAME; +} + +// 持久化条目字段是 snake_case(C++ 元数据),临时预览用的 summary 是 camelCase +// (questionPicker.buildQuestionSummary),两种都接受。 +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); +} + +function durableFeedback(item) { + const tool = item?.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; +} + +function transientFeedback(transient) { + if (!transient) return null; + if (transient.kind === 'cancel') return { kind: 'cancel' }; + if (transient.kind === 'submit') { + const summary = summaryFromResultItems(transient.summary); + return summary.length > 0 ? { kind: 'submit', summary } : null; + } + return null; +} + +// 该消息之后应渲染的反馈卡。无反馈时返回 null。 +// allowTransient: 该 item 是否为「用户刚作答的那一条」,是则允许用临时反馈 +// 顶替尚未回流的持久化结果。 +export function questionFeedbackForItem(item, { transient = null, allowTransient = false } = {}) { + if (item?.kind !== 'tool') return null; + const durable = durableFeedback(item); + if (durable) return durable; + if (!allowTransient) return null; + return transientFeedback(transient); } -export function questionFeedbackForItem(item) { - return item?.kind === 'tool' ? questionFeedbackForTool(item.tool) : null; +// 找出消息流里最后一条 AskUserQuestion 工具消息。用于判断临时反馈该挂在谁身上 +// —— 临时预览只认当前挂起的那条,所以这里按工具名取。 +export function lastAskUserQuestionItem(items) { + let host = null; + if (!Array.isArray(items)) return null; + for (const item of items) { + if (isAskToolItem(item)) host = item; + } + return host; } diff --git a/web/src/lib/questionFeedback.test.js b/web/src/lib/questionFeedback.test.js index 57e53135..7d71e60f 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,265 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { parseSync } from '@babel/core'; import { transformWithEsbuild } from 'vite'; -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'; - -function run(name, fn) { - fn(); - console.log(`[pass] ${name}`); -} +import { createTranscriptState, loadTranscriptHistory } from './sessionTranscript.js'; +import { + lastAskUserQuestionItem, + questionFeedbackForItem, +} from './questionFeedback.js'; -// 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') +// 编译真实 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 transformed = await transformWithEsbuild( - body.map((node) => source.slice(node.start, node.end)).join('\n'), - 'ToolBlock.jsx', +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 { ToolBlock } = vm.runInNewContext(`${transformed.code}; ({ ToolBlock });`, { - React, ...React, ...format, - compactOneLinePreview, createdFileSource, normalizeAttachmentList, - fallbackToolSummary, questionFeedbackForTool, - useTranslation() {}, - renderMarkdown: () => '', - VsIcon: () => null, - ToolSummaryIcon: () => null, - ActivityLine: ({ label }) => React.createElement('span', null, label), -}); +const { QuestionFeedbackCard } = vm.runInNewContext( + `${feedbackCardTransformed.code}; ({ QuestionFeedbackCard });`, + { React, ...React, VsIcon: () => null }, +); + +// 按落盘工具条目派生反馈并渲染卡片(与 ChatView 的 renderFeedbackAfterQuestion +// 同一条数据路径),供持久化测试断言「卡片紧跟 AskUserQuestion 调用」。 +export function renderQuestionFeedbackCardForTest(item) { + const feedback = questionFeedbackForItem(item); + return feedback + ? renderToStaticMarkup(React.createElement(QuestionFeedbackCard, { feedback })) + : ''; +} + +async function run(name, fn) { + try { + await fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} -export function renderQuestionToolForTest(entry) { - return renderToStaticMarkup(React.createElement(ToolBlock, { entry, sessionRunning: false })); +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, + }; } -const answered = { - isDone: true, success: true, tool: 'AskUserQuestion', - askUserQuestionResult: { items: [{ question: 'Which path?', answer: 'src/index.js' }] }, +function userMessage() { + return { id: 'u1', role: 'user', content: '随机问我', ts: 1 }; +} + +const SUBMIT_METADATA = { + tool_success: true, + ask_user_question_result: { + items: [ + { question: '你最喜欢的语言?', answer: 'Rust', multi_select: false }, + { question: '目标平台?', answer: 'Windows, Linux', multi_select: true }, + ], + }, +}; + +const CANCEL_METADATA = { + tool_success: false, + ask_user_question_result: { cancelled: true, items: [] }, }; -run('共享 ToolBlock 为成功答案渲染且仅渲染一张确认卡', () => { - const html = renderQuestionToolForTest(answered); - 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"/); +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('共享 ToolBlock 为失败状态的显式取消渲染取消卡并忽略残留答案', () => { - const entry = { - ...answered, success: false, - askUserQuestionResult: { cancelled: true, items: answered.askUserQuestionResult.items }, +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('tool_end 尚未回流时用临时反馈做即时预览', () => { + const pending = { + kind: 'tool', + id: 7, + tool: { tool: 'AskUserQuestion', isDone: true, askUserQuestionResult: null }, }; - 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"/); + assert.equal( + questionFeedbackForItem(pending, { transient: { kind: 'cancel' }, allowTransient: true }).kind, + 'cancel', + ); + assert.equal( + questionFeedbackForItem(pending, { transient: { kind: 'cancel' }, allowTransient: false }), + null, + ); + const preview = questionFeedbackForItem(pending, { + transient: { kind: 'submit', summary: [{ question: 'Q', answer: 'A', multiSelect: true }] }, + allowTransient: true, + }); + assert.equal(preview.kind, 'submit'); + assert.equal(preview.summary[0].answer, 'A'); +}); + +run('持久化结果到位后覆盖临时预览(不重复展示)', () => { + const state = load([userMessage(), askToolMessage(SUBMIT_METADATA)]); + const item = lastAskUserQuestionItem(state.items); + const card = questionFeedbackForItem(item, { + transient: { kind: 'cancel' }, + allowTransient: true, + }); + assert.equal(card.kind, 'submit', '已落盘的提交结果优先于临时取消态'); }); -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 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('运行中、普通失败和无结构化结果都不出反馈卡', () => { + 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.equal(renderQuestionFeedbackCardForTest({ ...answered, tool }), ''); + } + // 非工具条目一律不派生反馈。 + 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(renderQuestionFeedbackCardForTest(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 = renderQuestionFeedbackCardForTest(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..952c6e05 100644 --- a/web/src/lib/questionFeedbackPersistence.test.js +++ b/web/src/lib/questionFeedbackPersistence.test.js @@ -5,7 +5,7 @@ import { reduceTranscriptEvent, } from './sessionTranscript.js'; import { projectCollapsedTranscriptItems } from './transcriptProjection.js'; -import { renderQuestionToolForTest } from './questionFeedback.test.js'; +import { renderQuestionFeedbackCardForTest } from './questionFeedback.test.js'; import { reconcileLatestCompletedTurn } from './transcriptSelfHeal.js'; async function run(name, fn) { @@ -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 = renderQuestionFeedbackCardForTest(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(renderQuestionFeedbackCardForTest(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(renderQuestionFeedbackCardForTest(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, ''); +}); From bbbb0251e97346c0e94a0471f8e4fb2e1474efb6 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 15 Sep 2026 22:53:41 +0800 Subject: [PATCH 2/2] Fix question confirmation and durable feedback isolation --- docs/design/web-ask-user-question-design.md | 8 +- ...9-13-web-ask-user-question-requirements.md | 2 +- .../redesign-web-ask-user-question/design.md | 25 +++ .../proposal.md | 4 +- .../specs/web-ask-user-question/spec.md | 106 +++++---- .../redesign-web-ask-user-question/tasks.md | 21 +- web/src/components/ChatView.jsx | 34 +-- web/src/components/QuestionPicker.jsx | 86 ++++--- web/src/components/ToolBlock.jsx | 6 +- web/src/components/TranscriptItems.jsx | 3 - .../composerEditabilityArchitecture.test.js | 7 +- web/src/lib/questionFeedback.js | 46 +--- web/src/lib/questionFeedback.test.js | 131 +++++++---- .../lib/questionFeedbackPersistence.test.js | 8 +- web/src/lib/questionPickerInteraction.test.js | 209 ++++++++++++++++++ web/src/lib/runTests.js | 1 + 16 files changed, 489 insertions(+), 208 deletions(-) create mode 100644 openspec/changes/redesign-web-ask-user-question/design.md create mode 100644 web/src/lib/questionPickerInteraction.test.js diff --git a/docs/design/web-ask-user-question-design.md b/docs/design/web-ask-user-question-design.md index b86e580d..48cf030e 100644 --- a/docs/design/web-ask-user-question-design.md +++ b/docs/design/web-ask-user-question-design.md @@ -58,7 +58,7 @@ AskUserQuestion(提问框)是 Web 端用于向用户收集结构化答案的 | 属性 | 值 | |------|----| -| 提问框宽度 | **满宽**(`w-full`,与 composer 同宽,仅保留左右外边距 `mx-2.5`)——早期 640px 定宽会让提问框窄于会话宽度,已废弃 | +| 提问框宽度 | **铺满可用宽度**(由 flex stretch 扣除左右外边距 `mx-2.5`;避免 `w-full` 再叠加外边距溢出)——早期 640px 定宽会让提问框窄于会话宽度,已废弃 | | 提问框圆角 | 14px | | 提问框阴影 | `0 4px 24px rgba(0,0,0,0.08)` | | 选项行内边距 | 14px 16px | @@ -212,7 +212,7 @@ AskUserQuestion(提问框)是 Web 端用于向用户收集结构化答案的 - 取消路径:`ask_user_question_result.cancelled = true` - 未作答:由答案为空派生为「未作答」 -临时反馈状态只用于 `tool_end` 回流之前的即时预览,持久化结果到位后即被覆盖。同一轮提问只渲染一张卡。 +反馈在 `tool_end` 回流后由公共 `ToolBlock` 渲染,同一条工具结果只展示一张卡。等待结果时不使用未绑定请求的临时反馈,避免连续提问或切换会话时把上一题的答案挂到新问题上。 --- @@ -236,7 +236,7 @@ AskUserQuestion(提问框)是 Web 端用于向用户收集结构化答案的 | 按键 | 场景 | 行为 | |------|------|------| -| **Enter** | 非末题(单选 / 多选) | 选中/切换当前焦点选项 → 提交 → 进入下一题(一步到位) | +| **Enter** | 非末题(单选 / 多选) | 确认选中当前焦点选项 → 提交 → 进入下一题;已选项保持选中 | | **Enter** | 末题 | 只选中当前焦点选项,不提交(防误触) | | **Enter** | 自定义输入框内 | 走主操作(提交 / 跳过),不换行 | | **Ctrl + Enter** | 末题 | 提交全部答案 | @@ -314,7 +314,7 @@ AskUserQuestion(提问框)是 Web 端用于向用户收集结构化答案的 | 日期 | 变更 | 原因 | |---|---|---| -| 2026-09-14 | 提问框由 640px 定宽改为满宽(`w-full`) | 用户反馈未铺满会话宽度 | +| 2026-09-14 | 提问框由 640px 定宽改为铺满可用宽度,保留左右边距 | 用户反馈未铺满会话宽度 | | 2026-09-14 | 自定义输入行去掉虚线外框 | 虚线视觉突兀,与预设行不一致 | | 2026-09-14 | 底部按钮独立成行、右对齐 | 与自定义行同行时被 hover 按钮遮挡,「跳过」溢出屏幕 | | 2026-09-14 | 键盘表补齐 Enter(多选统一)/ 数字键 / 方向键 / Space / Esc 两级语义 | 交互对齐 TUI 并修正与预期不符处 | diff --git a/docs/specs/2026-09-13-web-ask-user-question-requirements.md b/docs/specs/2026-09-13-web-ask-user-question-requirements.md index a83b136b..246a8ad4 100644 --- a/docs/specs/2026-09-13-web-ask-user-question-requirements.md +++ b/docs/specs/2026-09-13-web-ask-user-question-requirements.md @@ -143,7 +143,7 @@ Enter 键是为**快速答题的高频用户**设计的效率操作,采用"渐 | 场景 | 行为 | |---|---| -| 非末题(单选 / 多选) | 选中/切换当前焦点选项 → 提交当前题 → 进入下一题(一步到位) | +| 非末题(单选 / 多选) | 确认选中当前焦点选项 → 提交当前题 → 进入下一题;已选项保持选中 | | 末题 | 只选中当前焦点选项,不自动提交(需 Ctrl+Enter 或点「提交」按钮) | | 自定义输入框内 | Enter 走主操作(提交/跳过),不换行 | diff --git a/openspec/changes/redesign-web-ask-user-question/design.md b/openspec/changes/redesign-web-ask-user-question/design.md new file mode 100644 index 00000000..c69d4c6b --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/design.md @@ -0,0 +1,25 @@ +## Context + +The interaction and visual decisions are recorded in `docs/specs/2026-09-13-web-ask-user-question-requirements.md` and `docs/design/web-ask-user-question-design.md`. Their 2026-09-14 revisions define numeric-key advancement, custom-input Enter, and two-stage Escape. The delta spec includes those revisions. + +The daemon accepts the first answer for an entire request. Transcript self-heal replaces item identifiers, and ChatView can remain mounted across sessions. + +## Goals / Non-Goals + +Preserve local drafts until final batch submission and bind durable feedback to its own result. Keep the current protocol, picker appearance, composer replacement, and existing daemon interjection support. + +## Decisions + +- Separate selection toggling from confirmation. Confirmation ensures that its option remains selected before navigation; it cannot undo the first click of a double-click. +- Resolve keyboard targets from explicit hover/focus, then recommendation, then the first option. IME composition is handled before question shortcuts. +- Derive feedback from completed tool metadata inside shared ToolBlock rendering. Remove the unbound ChatView transient fallback: selecting the latest tool by name can attach a previous result to a new request or another session. The brief wait for `tool_end` keeps feedback authoritative. +- Persist question type and unanswered status using additive metadata fields. Existing history without type metadata remains readable. + +## Risks / Trade-offs + +- Browser mouse and IME event ordering can differ from pure state tests. Validate the real component event handlers and run browser interaction checks. +- Feedback appears when the tool result arrives. Until then, the tool remains pending and does not claim that a different request completed. + +## Migration Plan + +No stored-history migration is required. Validate the repaired Web implementation and existing native metadata tests before integration. diff --git a/openspec/changes/redesign-web-ask-user-question/proposal.md b/openspec/changes/redesign-web-ask-user-question/proposal.md index bfb20e07..52b05339 100644 --- a/openspec/changes/redesign-web-ask-user-question/proposal.md +++ b/openspec/changes/redesign-web-ask-user-question/proposal.md @@ -26,7 +26,7 @@ Web 端 `AskUserQuestion`(`QuestionPicker`)与 TUI 版存在明显交互落 ## Impact -- **Web 前端**:`web/src/components/QuestionPicker.jsx`、`web/src/components/QuestionFeedbackCard.jsx`(反馈卡)、`web/src/components/ChatView.jsx`(集成点)、`web/src/components/ToolBlock.jsx`(移除旧的结果卡渲染,避免与反馈卡重叠)、`web/src/lib/questionPicker.js`、`web/src/lib/questionFeedback.js`(反馈卡派生)、`web/src/lib/sessionTranscript.js`(归一化 `cancelled` / `multi_select`)。 +- **Web 前端**:`web/src/components/QuestionPicker.jsx`、`web/src/components/QuestionFeedbackCard.jsx`(反馈卡)、`web/src/components/ChatView.jsx`(集成点)、`web/src/components/ToolBlock.jsx`(在公共工具渲染路径展示新反馈卡)、`web/src/lib/questionPicker.js`、`web/src/lib/questionFeedback.js`(反馈卡派生)、`web/src/lib/sessionTranscript.js`(归一化 `cancelled` / `multi_select`)。 - **daemon**:`src/tool/ask_user_question_tool.cpp`(取消路径落 `cancelled`、提交与超时路径落 `multi_select`)、`src/agent_loop.cpp`(答案派生 `not_answered`)。 - **测试**:`web/src/lib/questionPicker.test.js`、`web/src/lib/questionFeedback.test.js`(新增)、`web/src/lib/sessionTranscript.test.js`、`tests/tool/ask_user_question_tool_test.cpp`;`web/src/lib/runTests.js` 注册新测试文件。 -- **文档**:`docs/specs/2026-09-13-web-ask-user-question-requirements.md`、`docs/design/web-ask-user-question-design.md` 为验收基线,均已记录本轮决策变更。 \ No newline at end of file +- **文档**:`docs/specs/2026-09-13-web-ask-user-question-requirements.md`、`docs/design/web-ask-user-question-design.md` 为验收基线,均已记录本轮决策变更。 diff --git a/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md b/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md index ebb3f863..47e98e27 100644 --- a/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md +++ b/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md @@ -1,58 +1,82 @@ -# Spec: web-ask-user-question +## ADDED Requirements -Web 端 AskUserQuestion 提问框的全套交互契约。验收依据合并引用 `docs/specs/2026-09-13-web-ask-user-question-requirements.md` 与 `docs/design/web-ask-user-question-design.md`(本 spec 为上两者的可测契约子集)。 +### Requirement: Question navigation and composer replacement -## 模型与导航 +The Web UI SHALL show one question at a time, with previous/next navigation and a collapsible header. While a request is pending, the picker SHALL replace the composer input area, including when collapsed. Resolving or cancelling SHALL restore the composer. -- 一次调用可携带多道题,界面每次展示一道,右上角 `‹ n / N ›` 前后切换。 -- 当前题可折叠/展开;折叠态仅保留头部一行(待答题数提示 + 展开按钮),折叠不改变答案与当前题号。 +#### Scenario: Navigate and collapse a pending request +- **WHEN** a user navigates questions or collapses and expands the picker +- **THEN** answers and custom drafts remain intact and the composer stays hidden until resolution -## 答案语义 +### Requirement: Answer selection and custom drafts -- 单选题:`selected` 互斥;选中预设时若自定义有草稿则保留草稿但取消选中态(灰显、不进 payload);输入非空则清空预设并激活自定义。 -- 多选题:可多个 `selected`,自定义激活时与预设并存。 -- 一题已作答当且仅当存在任一 `selected` 或自定义处于激活且内容非空。 -- payload 的 `custom_text` 仅写激活态(`customSelected`)的非空去前后空格草稿。 -- 「跳过 / 未作答」的题以空 `selected`、空 `custom_text` 上报;daemon 在序列化工具结果时按 TUI 同规则派生 `not_answered`(`selected` 与 `custom_text` 均为空时置真),使未答题在 LLM 结果中呈现为 `Not answered` 而非空串。 +The Web UI SHALL keep preset selection, custom text, and custom activation separate. Single-select presets and active custom answers SHALL be mutually exclusive. Multi-select SHALL allow presets and active custom text together. Inactive custom drafts SHALL remain editable but SHALL NOT enter the payload. -## 导航与按钮 +#### Scenario: Preserve an inactive custom draft +- **WHEN** a user types a custom single-select answer and then selects a preset +- **THEN** the draft remains visible but inactive and only the preset enters the payload -- 非末题 + 未作答:主按钮「跳过」,点击记为 `Not answered` 并进下一题。 -- 非末题 + 已作答:主按钮「提交」,提交当前题并进下一题。 -- 末题:主按钮恒为「提交」(未答/跳过的题一并记为 Not answered)。 -- 底部始终提供「取消」,点击发送 `{cancelled:true}` 并触发 resolve。 -- 主按钮文案随当前题已作答状态实时切换。 +#### Scenario: Combine multi-select answers +- **WHEN** a user selects several presets and enters active custom text +- **THEN** the final answer includes all selected presets and the trimmed custom text -## 收卷时机 +### Requirement: Collect answers once per request -- daemon 对同一 `question_id`/`request_id` 的 `question_answer` 是 **first-wins 原子**:第一份被 accepted 即关闭整个提问并结束 agent_loop 的等待,后续一律忽略(`Closed`)。 -- 因此非末题的「提交/跳过」只更新**本地答案状态并推进**,绝不向后端发送 `question_answer`;最终整批答案只在**末题「提交」**时统一 `sendQuestionAnswer` 一次。 -- 末题 `canSubmit` 恒为真(即使整批未答/跳过,一并记 Not answered);防误触由「末题单按 Enter 不提交、需 Ctrl+Enter」保证。 -- 一条请求全过程至多产生一次 `question_answer`(末题提交)与一次 `cancelled`(取消)。 +Non-final actions SHALL only update local state and advance. Unanswered non-final questions SHALL offer Skip. The final question SHALL always offer Submit, even when all answers are empty. Submission SHALL send the complete batch with existing identifiers. The daemon SHALL derive `not_answered` when both `selected` and `custom_text` are empty, yielding `Not answered` to the model. -## 键盘 +#### Scenario: Skip and submit a batch +- **WHEN** a user skips a non-final question and submits the final question +- **THEN** no answer is sent during navigation and one complete payload is sent at submission +- **AND** skipped questions are represented as unanswered -- `1–9` 选择对应选项(`N+1` 聚焦自定义行)。 -- `↑ / ↓` 移动焦点;`Space` 选中当前焦点项 / 聚焦自定义。 -- `Enter`:单选 + 非末题且已作答 → 提交并进下一题;单选 + 末题 → 仅选中不提交(预防止误提交);多选 → 切换当前焦点项选中态。 -- `Ctrl+Enter`:末题提交全部。 -- `Tab / Shift+Tab`:切换上一题/下一题。 -- `Esc`:取消整个问答(编辑态先退出编辑)。 -- 文本输入(自定义框)内:Enter 不触发提交类快捷键(正常输入),避免误跳。 +### Requirement: Confirming an option preserves its selection -## Enter 选中目标优先级 +Plain activation and Space SHALL toggle selection. Enter, numeric shortcuts, inline confirmation, and double-click SHALL select the target and advance on non-final questions, without deselecting an already selected option. On the final question these option confirmation actions SHALL select without submitting. -1. 当前 hover 的选项;2. 带推荐标记的第一个选项;3. 第一个预设选项。 +#### Scenario: Confirm an already selected multi-select option +- **WHEN** a user double-clicks an option or confirms an already selected option +- **THEN** the option and other multi-select answers remain selected, and a non-final question advances -## 输入框替换 +### Requirement: Keyboard target and text input handling -- 提问框打开期间,composer dock(输入区)隐藏,提问框占据其位置;提交或取消后 composer 恢复。 -- 折叠态不取消替换。 +Enter SHALL target the hovered or explicitly focused option, otherwise the first recommended option, otherwise the first preset. Up/Down SHALL move focus including the custom row. Tab/Shift+Tab and Right/Left SHALL navigate outside text inputs. Digits 1–9 SHALL confirm the corresponding preset or focus custom input when beyond the preset count. Ctrl+Enter SHALL submit the final question. Inside custom input, Enter SHALL invoke the primary action without inserting a newline; other navigation keys SHALL retain text editing behavior. IME composition SHALL NOT invoke question shortcuts. -## 状态反馈 +#### Scenario: Use the recommended option by default +- **WHEN** Enter is pressed without an explicit target +- **THEN** the first recommended option is selected, or the first preset if no recommendation exists -- 选中:序号徽章原地变黑底白勾;行浅底色。 -- 复制:按钮变绿色对勾,1.5s 恢复。 -- 取消/提交:展示「已取消全部回答」/逐题答案汇总。 -- 反馈卡持久化:卡片从 AskUserQuestion 工具消息的落盘元数据就地派生,随消息留在会话里。回合结束(transcript self-heal 用新 item id 覆写最近一轮)、继续对话、重载会话后卡片都必须仍然存在;不得依赖组件内存里的临时锚点或临时反馈。 -- 取消路径必须落 `cancelled` 标记、提交路径必须落 `multi_select`,否则重载后无法还原卡片与题型。 \ No newline at end of file +#### Scenario: Confirm Chinese input +- **WHEN** IME composition emits Enter or the composing key code +- **THEN** the picker does not advance, submit, or cancel + +#### Scenario: Submit while editing custom text +- **WHEN** non-composing Enter is pressed inside custom input +- **THEN** the picker advances on a non-final question or submits the final batch + +### Requirement: Two-stage Escape and cancellation + +Outside text input, the first Escape SHALL clear selections while preserving custom drafts and start a 1.2 second window. A second Escape within that window SHALL cancel. Escape inside custom input SHALL exit editing without arming cancellation. The Cancel button SHALL cancel directly with the existing `cancelled` payload. + +#### Scenario: Clear answers before cancelling +- **WHEN** a user presses Escape twice within 1.2 seconds +- **THEN** the first clears selections and the second cancels the request + +### Requirement: Selection and copy feedback + +Selected badges SHALL display a check mark. Hover SHALL expose copy and confirm actions. Successful copy SHALL show a check for 1.5 seconds without changing answers. + +#### Scenario: Copy a preset description +- **WHEN** a user clicks copy +- **THEN** the option text and description are copied without changing answers or question position + +### Requirement: Durable feedback belongs to its tool result + +Submission, cancellation, and interjection feedback SHALL derive only from the corresponding completed tool metadata and render once through shared ToolBlock. Feedback SHALL preserve multi-select and unanswered annotations after reload, self-heal, and subsequent turns. Pending questions and other sessions SHALL NOT inherit previous feedback. + +#### Scenario: Ask consecutive questions +- **WHEN** one question finishes and another starts before any composer input +- **THEN** only the completed tool has a feedback card + +#### Scenario: Restore results with fresh identifiers +- **WHEN** reload or self-heal recreates items, including renamed or nameless tool results +- **THEN** each result retains exactly its own card without cached item identifiers diff --git a/openspec/changes/redesign-web-ask-user-question/tasks.md b/openspec/changes/redesign-web-ask-user-question/tasks.md index 212a06db..349483b9 100644 --- a/openspec/changes/redesign-web-ask-user-question/tasks.md +++ b/openspec/changes/redesign-web-ask-user-question/tasks.md @@ -11,7 +11,7 @@ - [x] 2.1 视觉重做:低饱和中性配色、序号徽章黑底白勾、行 hover 浅底到位、内联自定义输入(幽灵文字/计数/聚焦与灰化草稿)、折叠态、底部 `取消/跳过/提交`。 - [x] 2.2 交互:选项行 hover 浮现「复制 / 回车」按钮;复制写入剪贴板并显示 1.5s 对勾反馈;回车按钮与双击=选中并进下一题/提交。 -- [x] 2.3 键盘:`1–9`、`↑↓`、`Space`、`Enter`(单选非末题一步到位/末题仅选中、多选切换)、`Ctrl+Enter` 末题提交、`Tab/Shift+Tab` 切题、`Esc` 取消(编辑态先退编辑)。 +- [x] 2.3 键盘:数字键与 Enter 确认选项后推进(末题只选中),方向键/Tab 导航,Space 切换选中;自定义输入 Enter 走主操作,IME 确认不触发快捷键;Esc 首次清空选中,1.2s 内再次按才取消。 - [x] 2.4 提交/取消后通过 `onResolve` 释放,供上层恢复 composer 并展示汇总/取消反馈。 - [x] 2.5 收卷时机:非末题「提交/跳过」仅本地推进,绝不逐题发送;末题统一 `sendQuestionAnswer` 一次、取消一次 `cancelled`;末题 `canSubmit` 恒为真(整批一并记 Not answered)。 @@ -27,7 +27,7 @@ - [x] 4.1 `pnpm test`(自 `web/`)全绿。 - [x] 4.2 `pnpm build` 通过,嵌入前端资源刷新。 -- [x] 4.3 反馈卡持久化回归测试:`web/src/lib/questionFeedback.test.js`(提交/取消/未作答/id 变更/临时预览)+ `tests/tool/ask_user_question_tool_test.cpp` 的 cancelled 与 multi_select 断言。 +- [x] 4.3 反馈卡持久化回归测试:`web/src/lib/questionFeedback.test.js`(提交/取消/未作答/id 变更/连续提问与会话隔离)+ `tests/tool/ask_user_question_tool_test.cpp` 的 cancelled 与 multi_select 断言。 - [x] 4.4 仅当与既有行为相关时同步更新 `docs`;不引入超时收卷、汇总页、`Ctrl+C` 劫持。 - [x] 4.5 完整链路回归:使用真实 `assistant.tool_calls` + `role:tool` 持久化协议形状,覆盖实时提交/取消、历史重载、回合 self-heal、继续对话、调用后紧邻卡片且每次调用只出现一张。 @@ -38,4 +38,19 @@ - [x] 5.3 反馈卡判定与 master 对齐:`questionFeedbackForItem` 不再按工具名过滤(历史页可能只剩工具结果消息,或工具已被改名),并恢复 `interjected` 形态;`QuestionFeedbackCard` 补回「已改为直接输入,取消作答」卡。 - [x] 5.4 `web/src/lib/composerEditabilityArchitecture.test.js` 改为守方案 A 的契约(composer 让位、无插话入口),并把排队卡片插话单独成条。 - [x] 5.5 重新生成 i18n 源目录;Web 侧 `pnpm test`、`pnpm build`(含 lookbehind 兼容检查)全绿。安装须用 CI 同版本 pnpm 10.32.1(`npx pnpm@10.32.1 install --frozen-lockfile`)—— 本机 pnpm 12 解析该 patch 文件会失败,属工具链版本问题,与本次改动无关。 -- [x] 5.6 C++ 侧 `*AskUserQuestion*`、`AskUserQuestionPrompter.*`、`AgentLoopQuestionInterjection.*` 共 54 个用例全绿。注意:旧 `build/windows-x64-dev` 里还留着 09-13 的过期目标文件(含 `agent_loop.hpp`、`ask_user_question_prompter.hpp` 等已变动的头文件),直接跑会出现与本改动无关的堆损坏崩溃;清掉过期 obj 重编后全部通过。 \ No newline at end of file +- [x] 5.6 C++ 侧 `*AskUserQuestion*`、`AskUserQuestionPrompter.*`、`AgentLoopQuestionInterjection.*` 共 54 个用例全绿。注意:旧 `build/windows-x64-dev` 里还留着 09-13 的过期目标文件(含 `agent_loop.hpp`、`ask_user_question_prompter.hpp` 等已变动的头文件),直接跑会出现与本改动无关的堆损坏崩溃;清掉过期 obj 重编后全部通过。 + +## 6. PR #50 审核修复 + +- [x] 6.1 确认选项与切换选项分开处理,修复多选双击和已选项快捷确认丢失答案;接通 hover / 键盘焦点 / 推荐项的 Enter 目标,阻止 IME 确认键触发问答快捷键。 +- [x] 6.2 反馈卡只从所属工具的持久化结果派生,在公共 ToolBlock 中渲染,移除跨请求、跨会话复用的临时反馈;验证连续问答、self-heal 和重载。 +- [x] 6.3 同步 2026-09-14 已修订的键盘契约,补齐合法 OpenSpec delta 与设计记录。 +- [x] 6.4 运行真实组件回归、Web 全量测试与构建、i18n 目录生成、OpenSpec strict 和差异检查,记录验证结果。 + +### 审核验证记录 + +- `pnpm test`:2417 条通过,包含 12 项真实 QuestionPicker JSX 事件回归与共享 ToolBlock 持久化渲染回归。 +- `pnpm build`:通过,4433 个正则字面量兼容检查通过。 +- `pnpm i18n:catalog` 与 `openspec validate redesign-web-ask-user-question --strict`:通过。 +- Chromium 真实组件检查:13 项通过,覆盖 IME、双击/键盘确认、复制、推荐与悬停、Esc 请求隔离、折叠、按钮、390px 宽度;使用模拟连接,未发送真实会话答案。 +- C++ 改动与原 PR `c998f336` 相同,该提交的远端 `unit-tests (linux-x64)` 已通过;本轮修复不改 C++。 diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 49f880a5..61d23cfb 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(() => { @@ -1221,7 +1216,6 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable draftEditVersionRef.current += 1; composerDirtyRef.current = true; setComposerValue(next); - if (next) setQuestionFeedback(null); if (!sid) onHomeComposerDraftChange?.(homeDraftWorkspaceHash, next); }, [homeDraftWorkspaceHash, onHomeComposerDraftChange, sid]); @@ -4283,33 +4277,11 @@ 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]); - const sidePanelMounted = showSidePanel; const sidePanelNavigationCollapsed = sidePanelCollapsed || sidePanelListCollapsed; const previewScope = useMemo( @@ -4948,7 +4920,7 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable
{questionForView && ( - + )} {createProjectOpen && ( ( (turnFileListPlacement.before.get(it.id) || []).map((set) => (
)} diff --git a/web/src/components/QuestionPicker.jsx b/web/src/components/QuestionPicker.jsx index 3f58d42f..61ef59ab 100644 --- a/web/src/components/QuestionPicker.jsx +++ b/web/src/components/QuestionPicker.jsx @@ -8,17 +8,14 @@ import { VsIcon } from './Icon.jsx'; import { buildQuestionAnswerPayload, buildQuestionCancelPayload, - buildQuestionSummary, getNavigationState, hasSelectedTextWithin, - isQuestionAnswered, makeInitialAnswers, normalizeQuestionRequest, recommendedOptionIndex, selectAnswerCustom, setAnswerCustom, toggleAnswerSelection, - unselectAnswerCustom, } from '../lib/questionPicker.js'; const READABLE_TEXT_STYLE = { overflowWrap: 'anywhere', wordBreak: 'break-word' }; @@ -56,12 +53,13 @@ async function copyText(text) { } } -export function QuestionPicker({ request, onResolve, onFeedback, originLabel = '' }) { +export function QuestionPicker({ request, onResolve, originLabel = '' }) { const normalized = useMemo(() => normalizeQuestionRequest(request), [request]); const { questions } = normalized; const [answers, setAnswers] = useState(() => makeInitialAnswers(questions)); const [currentIndex, setCurrentIndex] = useState(0); - const [focusIndex, setFocusIndex] = useState(0); + const [focusIndex, setFocusIndex] = useState(-1); + const [hoverIndex, setHoverIndex] = useState(-1); const [collapsed, setCollapsed] = useState(false); const [copiedIndex, setCopiedIndex] = useState(-1); const [editingCustom, setEditingCustom] = useState(false); @@ -74,14 +72,18 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' useEffect(() => { setAnswers(makeInitialAnswers(questions)); setCurrentIndex(0); - setFocusIndex(0); + setFocusIndex(-1); + setHoverIndex(-1); setCollapsed(false); setEditingCustom(false); + if (escTimerRef.current) clearTimeout(escTimerRef.current); + escTimerRef.current = null; focusSoon(rootRef); }, [normalized.requestId, questions]); useEffect(() => () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + if (escTimerRef.current) clearTimeout(escTimerRef.current); }, []); const question = questions[currentIndex]; @@ -90,6 +92,8 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const customIndex = optionCount; const nav = getNavigationState(currentIndex, questions, answers); const isMulti = !!question?.multiSelect; + const activeOptionIndex = hoverIndex >= 0 ? hoverIndex + : (focusIndex >= 0 ? focusIndex : recommendedOptionIndex(question)); const updateAnswer = useCallback((index, updater) => { setAnswers((prev) => prev.map((item, i) => i === index ? updater(item) : item)); @@ -108,13 +112,13 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const cancel = useCallback(() => { connection.sendQuestionAnswer(buildQuestionCancelPayload(normalized)); - onFeedback?.({ kind: 'cancel' }); resolve(); - }, [normalized, onFeedback, resolve]); + }, [normalized, resolve]); const submitCurrent = useCallback((i) => { setCurrentIndex(Math.min(questions.length - 1, i + 1)); - setFocusIndex(0); + setFocusIndex(-1); + setHoverIndex(-1); focusSoon(rootRef); }, [questions.length]); @@ -122,13 +126,13 @@ 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]); + }, [answers, currentIndex, normalized, questions, resolve]); const goPrev = useCallback(() => { setCurrentIndex((value) => Math.max(0, value - 1)); - setFocusIndex(0); + setFocusIndex(-1); + setHoverIndex(-1); focusSoon(rootRef); }, []); @@ -139,7 +143,8 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' setAnswers((prev) => prev.map((item, i) => i === currentIndex && !state.currentAnswered ? { ...item, skipped: true } : item)); setCurrentIndex((value) => Math.min(questions.length - 1, value + 1)); - setFocusIndex(0); + setFocusIndex(-1); + setHoverIndex(-1); focusSoon(rootRef); }, [answers, currentIndex, questions]); @@ -148,8 +153,8 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const primaryAction = useCallback(() => { const state = getNavigationState(currentIndex, questions, answers); if (state.isLast) submitAll(); - else if (state.canGoNext) submitCurrent(currentIndex); - }, [currentIndex, questions, answers, submitAll, submitCurrent]); + else goNext(); + }, [currentIndex, questions, answers, submitAll, goNext]); const skipCurrent = useCallback(() => { if (!nav.canSkip) return; @@ -169,17 +174,18 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const opt = question?.options?.[optionIndex]; if (!opt) return; setFocusIndex(optionIndex); - updateAnswer(currentIndex, (item) => toggleAnswerSelection(item, opt.value, isMulti)); - const state = getNavigationState(currentIndex, questions, { ...answers, [currentIndex]: answers[currentIndex] }); - if (!state.isLast) { + // 确认操作保留已选答案;只有单击选项或 Space 才切换多选状态。 + updateAnswer(currentIndex, (item) => isMulti && item.selected.includes(opt.value) + ? item : toggleAnswerSelection(item, opt.value, isMulti)); + if (currentIndex < questions.length - 1) { submitCurrent(currentIndex); } - }, [answers, currentIndex, isMulti, question, questions, submitCurrent, updateAnswer]); + }, [currentIndex, isMulti, question, questions.length, submitCurrent, updateAnswer]); const selectCustom = useCallback(() => { setFocusIndex(customIndex); setEditingCustom(true); - if (!isMulti) updateAnswer(currentIndex, (item) => selectAnswerCustom(item, false)); + updateAnswer(currentIndex, (item) => selectAnswerCustom(item, isMulti)); }, [customIndex, currentIndex, isMulti, updateAnswer]); const setCustom = useCallback((value) => { @@ -189,8 +195,9 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const moveFocus = useCallback((delta) => { const count = optionCount + 1; - setFocusIndex((value) => Math.min(count - 1, Math.max(0, value + delta))); - }, [optionCount]); + setFocusIndex(Math.min(count - 1, Math.max(0, activeOptionIndex + delta))); + setHoverIndex(-1); + }, [activeOptionIndex, optionCount]); const copyOption = useCallback(async (optionIndex, event) => { if (event) event.stopPropagation(); @@ -205,6 +212,8 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' const onKeyDown = useCallback((event) => { if (!question) return; + // 输入法的确认/取消按键只交给输入法,不能收卷或改变题目。 + if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) return; const target = event.target; const tag = target?.tagName; const inTextInput = tag === 'INPUT' || tag === 'TEXTAREA'; @@ -213,6 +222,7 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' if (event.key === 'Escape') { event.preventDefault(); + if (event.repeat) return; if (inTextInput) { // 自定义输入框内:先退出编辑态,不参与连按判定。 setEditingCustom(false); @@ -234,6 +244,14 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' return; } + if (collapsed) return; + + if (ctrlEnter) { + event.preventDefault(); + if (nav.isLast) submitAll(); + return; + } + if (inTextInput) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); @@ -242,11 +260,9 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' return; } - if (ctrlEnter) { - event.preventDefault(); - if (nav.isLast) submitAll(); - return; - } + if (withCtrl || event.altKey) return; + // 复制/取消/展开等原生按钮保留 Enter 和 Space 的激活行为。 + if ((event.key === 'Enter' || event.key === ' ') && target?.closest?.('button')) return; if (event.key === 'Tab') { event.preventDefault(); @@ -290,7 +306,7 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' } if (event.key === ' ') { event.preventDefault(); - if (focusIndex < optionCount) selectOption(focusIndex); + if (activeOptionIndex >= 0 && activeOptionIndex < optionCount) selectOption(activeOptionIndex); else { setEditingCustom(true); customRef.current?.focus(); @@ -299,9 +315,10 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' } if (event.key === 'Enter') { event.preventDefault(); - if (focusIndex < optionCount) commitEnter(focusIndex); + if (activeOptionIndex >= 0 && activeOptionIndex < optionCount) commitEnter(activeOptionIndex); + else customRef.current?.focus(); } - }, [cancel, commitEnter, focusIndex, goNext, goPrev, moveFocus, nav.isLast, optionCount, primaryAction, question, resetAllSelections, selectOption, submitAll]); + }, [activeOptionIndex, cancel, collapsed, commitEnter, goNext, goPrev, moveFocus, nav.isLast, optionCount, primaryAction, question, resetAllSelections, selectOption, submitAll]); if (!question) return null; @@ -320,7 +337,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="mx-2.5 mb-2 shrink min-h-0 rounded-[14px] border border-border bg-surface ace-shadow-lg outline-none overflow-hidden flex flex-col" >
@@ -395,12 +412,15 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = '
{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 (
setHoverIndex(index)} + onMouseLeave={() => setHoverIndex(-1)} onMouseDown={(event) => { + if (event.target?.closest?.('button')) return; if (event.detail > 0 && event.detail >= 2) { event.preventDefault(); commitEnter(index); @@ -546,4 +566,4 @@ export function QuestionPicker({ request, onResolve, onFeedback, originLabel = ' )} ); -} \ No newline at end of file +} diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index ad4afe8c..8d4ff1b0 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -20,6 +20,7 @@ import { highlightSourceForFile } from '../lib/sourceCodeHighlight.js'; import { fallbackToolSummary } from '../lib/toolSummaryFallback.js'; import { codeTextFromCopyButtonTarget, copyTextToClipboard } from '../lib/codeBlockCopy.js'; import { normalizeTaskCompleteMarkdown } from '../lib/taskCompleteSummary.js'; +import { questionFeedbackForTool } from '../lib/questionFeedback.js'; import { DESKTOP_CONTEXT_ACTION_EVENT, DESKTOP_CONTEXT_ACTIONS, @@ -27,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'; @@ -155,8 +157,6 @@ function taskCompleteDisplayText(summary, output) { return outputText || '完成'; } - - export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessionRunning = true }) { useTranslation(); const [expanded, setExpanded] = useState(false); @@ -340,6 +340,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio // 完成态与运行态共用 ActivityLine;详情仍由 ToolBlock 自己负责。 if (isDone) { const ok = !!success; + const questionFeedback = questionFeedbackForTool(entry); return (
)} + {questionFeedback && }
); } diff --git a/web/src/components/TranscriptItems.jsx b/web/src/components/TranscriptItems.jsx index 6775a71c..eb66c04b 100644 --- a/web/src/components/TranscriptItems.jsx +++ b/web/src/components/TranscriptItems.jsx @@ -424,7 +424,6 @@ export function TranscriptItems({ showAceCodeAvatar = false, annotationPresentations, renderBeforeItem, - renderAfterItem, }) { const list = Array.isArray(items) ? items : EMPTY_ITEMS; const generatedDirectives = useMemo( @@ -442,7 +441,6 @@ export function TranscriptItems({ const identity = item?.id ?? index; const itemKey = nested ? `${keyPrefix}-${identity}` : identity; const before = !nested ? renderBeforeItem?.(item) : null; - const after = !nested ? renderAfterItem?.(item) : null; return ( {before} @@ -468,7 +466,6 @@ export function TranscriptItems({ showAceCodeAvatar={showAceCodeAvatar} annotationPresentations={annotationPresentations} /> - {after} ); }); diff --git a/web/src/lib/composerEditabilityArchitecture.test.js b/web/src/lib/composerEditabilityArchitecture.test.js index a7d7174a..56f9da6f 100644 --- a/web/src/lib/composerEditabilityArchitecture.test.js +++ b/web/src/lib/composerEditabilityArchitecture.test.js @@ -69,10 +69,11 @@ run('提交在途只走 submitting,绝不并进 InputBar 的 disabled', () => { run('提问挂起时 composer 整体让位给提问框,不留插话入口', () => { const chatView = source('components/ChatView.jsx'); - // 提问框承担提问期间唯一的交互面:提交/取消经 resolveQuestion 回流,结果由 - // onFeedback 落成反馈卡。 + // 提问框承担提问期间唯一的交互面:提交/取消经 resolveQuestion 回流,反馈 + // 由共享 ToolBlock 按 tool_end 的持久化结果渲染。 assert.match(chatView, /\{questionForView && \(\s*'); diff --git a/web/src/lib/questionFeedback.js b/web/src/lib/questionFeedback.js index 7e8d6359..c0cd6308 100644 --- a/web/src/lib/questionFeedback.js +++ b/web/src/lib/questionFeedback.js @@ -11,9 +11,6 @@ // 名不再是 AskUserQuestion)。唯一可靠的判据是落盘元数据本身,它只由 // AskUserQuestion 写出 —— 这也是「取消结果不被折叠进历史活动」的前提。 // -// 临时反馈只在 tool_end 回流之前的窗口里作为即时预览,一旦持久化结果到位就让位。 - -const ASK_TOOL_NAME = 'AskUserQuestion'; const NOT_ANSWERED_TEXT = 'Not answered'; function toText(value, fallback = '') { @@ -22,12 +19,7 @@ function toText(value, fallback = '') { return String(value); } -function isAskToolItem(item) { - return item?.kind === 'tool' && item?.tool?.tool === ASK_TOOL_NAME; -} - -// 持久化条目字段是 snake_case(C++ 元数据),临时预览用的 summary 是 camelCase -// (questionPicker.buildQuestionSummary),两种都接受。 +// C++ 元数据使用 snake_case,历史规范化后使用 camelCase,两种都接受。 function summaryFromResultItems(resultItems) { if (!Array.isArray(resultItems)) return []; return resultItems @@ -49,8 +41,7 @@ function summaryFromResultItems(resultItems) { .filter((item) => item.question || item.answer); } -function durableFeedback(item) { - const tool = item?.tool; +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; @@ -63,34 +54,7 @@ function durableFeedback(item) { return summary.length > 0 ? { kind: 'submit', summary } : null; } -function transientFeedback(transient) { - if (!transient) return null; - if (transient.kind === 'cancel') return { kind: 'cancel' }; - if (transient.kind === 'submit') { - const summary = summaryFromResultItems(transient.summary); - return summary.length > 0 ? { kind: 'submit', summary } : null; - } - return null; -} - -// 该消息之后应渲染的反馈卡。无反馈时返回 null。 -// allowTransient: 该 item 是否为「用户刚作答的那一条」,是则允许用临时反馈 -// 顶替尚未回流的持久化结果。 -export function questionFeedbackForItem(item, { transient = null, allowTransient = false } = {}) { - if (item?.kind !== 'tool') return null; - const durable = durableFeedback(item); - if (durable) return durable; - if (!allowTransient) return null; - return transientFeedback(transient); -} - -// 找出消息流里最后一条 AskUserQuestion 工具消息。用于判断临时反馈该挂在谁身上 -// —— 临时预览只认当前挂起的那条,所以这里按工具名取。 -export function lastAskUserQuestionItem(items) { - let host = null; - if (!Array.isArray(items)) return null; - for (const item of items) { - if (isAskToolItem(item)) host = item; - } - return host; +// 只按当前工具结果派生,避免连续提问或切换会话后误用上一题的答案。 +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 7d71e60f..b6a2861c 100644 --- a/web/src/lib/questionFeedback.test.js +++ b/web/src/lib/questionFeedback.test.js @@ -9,10 +9,15 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { parseSync } from '@babel/core'; import { transformWithEsbuild } from 'vite'; -import { createTranscriptState, loadTranscriptHistory } from './sessionTranscript.js'; +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 { - lastAskUserQuestionItem, questionFeedbackForItem, + questionFeedbackForTool, } from './questionFeedback.js'; // 编译真实 QuestionFeedbackCard 用于渲染断言。该组件只依赖 VsIcon(图标),其余 @@ -39,13 +44,41 @@ const { QuestionFeedbackCard } = vm.runInNewContext( { React, ...React, VsIcon: () => null }, ); -// 按落盘工具条目派生反馈并渲染卡片(与 ChatView 的 renderFeedbackAfterQuestion -// 同一条数据路径),供持久化测试断言「卡片紧跟 AskUserQuestion 调用」。 -export function renderQuestionFeedbackCardForTest(item) { - const feedback = questionFeedbackForItem(item); - return feedback - ? renderToStaticMarkup(React.createElement(QuestionFeedbackCard, { feedback })) - : ''; +// 编译共享 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 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(`${toolTransformed.code}; ({ ToolBlock });`, { + React, ...React, ...format, + compactOneLinePreview, createdFileSource, normalizeAttachmentList, + fallbackToolSummary, questionFeedbackForTool, QuestionFeedbackCard, + useTranslation() {}, + renderMarkdown: () => '', + VsIcon: () => null, + ToolSummaryIcon: () => null, + ActivityLine: ({ label }) => React.createElement('span', { 'data-tool-activity': true }, label), +}); + +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) { @@ -149,36 +182,56 @@ run('未作答题在反馈卡上标记 notAnswered', () => { assert.equal(card.summary[0].answer, ''); }); -run('tool_end 尚未回流时用临时反馈做即时预览', () => { - const pending = { - kind: 'tool', - id: 7, - tool: { tool: 'AskUserQuestion', isDone: true, askUserQuestionResult: null }, - }; - assert.equal( - questionFeedbackForItem(pending, { transient: { kind: 'cancel' }, allowTransient: true }).kind, - 'cancel', - ); - assert.equal( - questionFeedbackForItem(pending, { transient: { kind: 'cancel' }, allowTransient: false }), - null, - ); - const preview = questionFeedbackForItem(pending, { - transient: { kind: 'submit', summary: [{ question: 'Q', answer: 'A', multiSelect: true }] }, - allowTransient: true, - }); - assert.equal(preview.kind, 'submit'); - assert.equal(preview.summary[0].answer, 'A'); +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('持久化结果到位后覆盖临时预览(不重复展示)', () => { - const state = load([userMessage(), askToolMessage(SUBMIT_METADATA)]); - const item = lastAskUserQuestionItem(state.items); - const card = questionFeedbackForItem(item, { - transient: { kind: 'cancel' }, - allowTransient: true, +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.ok(html.indexOf('data-tool-activity') < html.indexOf('data-question-feedback')); + assert.match(html, /全部提交完成/); + assert.match(html, /(多选)/); +}); + +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.equal(card.kind, 'submit', '已落盘的提交结果优先于临时取消态'); + assert.doesNotMatch(renderQuestionToolForTest(lastAskUserQuestionItem(pending.state.items)), /data-question-feedback|Rust/); }); run('会话级取最近一次提问对应的那条工具消息', () => { @@ -214,7 +267,7 @@ run('运行中、普通失败和无结构化结果都不出反馈卡', () => { { ...answered.tool, askUserQuestionResult: { items: [] } }, ]) { assert.equal(questionFeedbackForItem({ ...answered, tool }), null); - assert.equal(renderQuestionFeedbackCardForTest({ ...answered, tool }), ''); + assert.doesNotMatch(renderQuestionToolForTest({ ...answered, tool }), /data-question-feedback/); } // 非工具条目一律不派生反馈。 assert.equal(questionFeedbackForItem({ kind: 'msg', tool: answered.tool }), null); @@ -232,7 +285,7 @@ run('工具改名或历史页缺少调用名时结构化结果仍渲染反馈卡 }, }; assert.equal(questionFeedbackForItem(item)?.kind, 'cancel'); - assert.match(renderQuestionFeedbackCardForTest(item), /data-question-feedback="cancel"/); + assert.match(renderQuestionToolForTest(item), /data-question-feedback="cancel"/); } }); @@ -251,7 +304,7 @@ run('插话取消作答渲染专属反馈卡,且改名后仍可恢复', () => { }, }; assert.equal(questionFeedbackForItem(item)?.kind, 'interject'); - const html = renderQuestionFeedbackCardForTest(item); + 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"|已取消全部回答/); diff --git a/web/src/lib/questionFeedbackPersistence.test.js b/web/src/lib/questionFeedbackPersistence.test.js index 952c6e05..a98e6479 100644 --- a/web/src/lib/questionFeedbackPersistence.test.js +++ b/web/src/lib/questionFeedbackPersistence.test.js @@ -5,7 +5,7 @@ import { reduceTranscriptEvent, } from './sessionTranscript.js'; import { projectCollapsedTranscriptItems } from './transcriptProjection.js'; -import { renderQuestionFeedbackCardForTest } from './questionFeedback.test.js'; +import { renderQuestionToolForTest } from './questionFeedback.test.js'; import { reconcileLatestCompletedTurn } from './transcriptSelfHeal.js'; async function run(name, fn) { @@ -77,7 +77,7 @@ function renderedFeedbackSequence(items, options = {}) { sequence.push(`${item.kind}:${item.role || ''}`); } if (item.kind === 'tool') { - const html = renderQuestionFeedbackCardForTest(item); + 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(renderQuestionFeedbackCardForTest(feedbackItems[0]), /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(renderQuestionFeedbackCardForTest(item), /A, B\nC/); + assert.match(renderQuestionToolForTest(item), /A, B\nC/); }); console.log('questionFeedbackPersistence tests passed'); 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';