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..48cf030e --- /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 间距 & 尺寸 + +| 属性 | 值 | +|------|----| +| 提问框宽度 | **铺满可用宽度**(由 flex stretch 扣除左右外边距 `mx-2.5`;避免 `w-full` 再叠加外边距溢出)——早期 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` 回流后由公共 `ToolBlock` 渲染,同一条工具结果只展示一张卡。等待结果时不使用未绑定请求的临时反馈,避免连续提问或切换会话时把上一题的答案挂到新问题上。 + +--- + +## 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 定宽改为铺满可用宽度,保留左右边距 | 用户反馈未铺满会话宽度 | +| 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..246a8ad4 --- /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/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 new file mode 100644 index 00000000..52b05339 --- /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` 为验收基线,均已记录本轮决策变更。 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..47e98e27 --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/specs/web-ask-user-question/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### 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. + +#### 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 + +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 + +#### 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 + +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 + +### Requirement: Confirming an option preserves its selection + +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. + +#### 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 + +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 + +#### 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 new file mode 100644 index 00000000..349483b9 --- /dev/null +++ b/openspec/changes/redesign-web-ask-user-question/tasks.md @@ -0,0 +1,56 @@ +# 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 键盘:数字键与 Enter 确认选项后推进(末题只选中),方向键/Tab 导航,Space 切换选中;自定义输入 Enter 走主操作,IME 确认不触发快捷键;Esc 首次清空选中,1.2s 内再次按才取消。 +- [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 重编后全部通过。 + +## 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/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..61d23cfb 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -589,8 +589,8 @@ export function ChatView({ children, sessionRef, sessionId, homeLogoEffectEnable const subagentTasks = useSubagentTasks(sid, { onSpawnStart: openSubagentPanelForSpawn, }); - // 放在 submit 之前:提问挂起时的插话分支要读 questionForView,useCallback 的 - // deps 在渲染期求值,memo 必须先于它声明(否则 TDZ)。 + // 当前视图可见的待答问题。提问挂起期间 composer dock 由提问框整体替换 + // (方案 A),所以它只驱动渲染,不再参与 submit 的分支判定。 const questionForView = useMemo(() => { if (!questionRequest) return null; const reqSid = questionRequest.session_id || ''; @@ -2908,66 +2908,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 +3061,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; @@ -5404,6 +5347,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..61ef59ab 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'; @@ -12,6 +12,7 @@ import { hasSelectedTextWithin, makeInitialAnswers, normalizeQuestionRequest, + recommendedOptionIndex, selectAnswerCustom, setAnswerCustom, toggleAnswerSelection, @@ -22,23 +23,34 @@ 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 = '' }) { @@ -46,24 +58,42 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { 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); 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); + setFocusIndex(-1); + setHoverIndex(-1); setCollapsed(false); - if (!composerIsMidTyping()) focusSoon(rootRef); + 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]; - 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 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)); @@ -73,12 +103,26 @@ 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)); resolve(); }, [normalized, resolve]); - const submit = useCallback(() => { + const submitCurrent = useCallback((i) => { + setCurrentIndex(Math.min(questions.length - 1, i + 1)); + setFocusIndex(-1); + setHoverIndex(-1); + focusSoon(rootRef); + }, [questions.length]); + + const submitAll = useCallback(() => { const state = getNavigationState(currentIndex, questions, answers); if (!state.canSubmit) return; connection.sendQuestionAnswer(buildQuestionAnswerPayload(normalized, questions, answers)); @@ -87,75 +131,169 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { const goPrev = useCallback(() => { setCurrentIndex((value) => Math.max(0, value - 1)); - setFocusIndex(0); + setFocusIndex(-1); + setHoverIndex(-1); focusSoon(rootRef); }, []); 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); + setFocusIndex(-1); + setHoverIndex(-1); focusSoon(rootRef); }, [answers, currentIndex, questions]); + // 主操作:非末题在本地推进到下一题(daemon 为 first-wins,只允许末题统一收卷, + // 中途不可发送 question_answer,否则会提前关闭整个请求);末题提交全部。 const primaryAction = useCallback(() => { const state = getNavigationState(currentIndex, questions, answers); - if (state.isLast) submit(); + if (state.isLast) submitAll(); else goNext(); - }, [answers, currentIndex, goNext, questions, submit]); + }, [currentIndex, questions, answers, submitAll, goNext]); + + 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); + // 确认操作保留已选答案;只有单击选项或 Space 才切换多选状态。 + updateAnswer(currentIndex, (item) => isMulti && item.selected.includes(opt.value) + ? item : toggleAnswerSelection(item, opt.value, isMulti)); + if (currentIndex < questions.length - 1) { + submitCurrent(currentIndex); + } + }, [currentIndex, isMulti, question, questions.length, submitCurrent, updateAnswer]); const selectCustom = useCallback(() => { setFocusIndex(customIndex); - updateAnswer(currentIndex, (item) => selectAnswerCustom(item, !!question?.multiSelect)); - }, [currentIndex, customIndex, question, updateAnswer]); + setEditingCustom(true); + updateAnswer(currentIndex, (item) => selectAnswerCustom(item, isMulti)); + }, [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]); + setFocusIndex(Math.min(count - 1, Math.max(0, activeOptionIndex + delta))); + setHoverIndex(-1); + }, [activeOptionIndex, 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; + // 输入法的确认/取消按键只交给输入法,不能收卷或改变题目。 + if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229) 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 (event.repeat) return; + 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 (collapsed) return; + + if (ctrlEnter) { + event.preventDefault(); + if (nav.isLast) submitAll(); return; } if (inTextInput) { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); primaryAction(); } return; } + if (withCtrl || event.altKey) return; + // 复制/取消/展开等原生按钮保留 Enter 和 Space 的激活行为。 + if ((event.key === 'Enter' || event.key === ' ') && target?.closest?.('button')) 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); @@ -168,200 +306,261 @@ export function QuestionPicker({ request, onResolve, originLabel = '' }) { } if (event.key === ' ') { event.preventDefault(); - if (focusIndex < optionCount) selectOption(focusIndex); - else customRef.current?.focus(); + if (activeOptionIndex >= 0 && activeOptionIndex < optionCount) selectOption(activeOptionIndex); + 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 (activeOptionIndex >= 0 && activeOptionIndex < optionCount) commitEnter(activeOptionIndex); + else customRef.current?.focus(); } - }, [cancel, focusIndex, moveFocus, nav.currentAnswered, optionCount, primaryAction, question, selectOption]); + }, [activeOptionIndex, cancel, collapsed, commitEnter, 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 focused = activeOptionIndex === index; + const copied = copiedIndex === index; return ( - + - +
); })} -