Files
Jyotisha/docs/tasks/TASK-composer-live-input-and-stop-20260906.md
T

104 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 生成中输入框不再禁用、回车排队发送、停止不再报错(2026-09-06)
- 基线:`origin/staging` @ `b8ea3112`
- 分支:`codex/composer-live-input-and-stop-20260906`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/components/chat-composer.tsx``frontend/src/app/page.tsx`(只改传参与调用,**不得增行**;新逻辑进 `frontend/src/hooks/` / `frontend/src/lib/`)、`frontend/src/hooks/use-consultation-run.ts``frontend/src/components/rectification-agentic-chat.tsx``frontend/src/lib/rectification-surface-state.ts``frontend/DESIGN.md``frontend/docs/VOICE.md`
- BUG 编号起点:**BUG-551**(开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核;BUG-542 仍为 `TASK-api-not-configured-mislabel-20260904.md` 预留)
- 串行:本单改 `page.tsx``rectification-agentic-chat.tsx`;同日若有别的单改这两个文件,本单先做。
## 1. 事故实证(staging2026-09-0506,产品实测)
| # | 用户看到 | 期望(对标主流 AI 对话产品) |
| --- | --- | --- |
| 1 | 发出消息后输入框整个变灰,不能打字;回答完毕后必须再点一次输入框才能继续输入 | 生成中随时能打字,焦点不丢;只有发送键换成停止键 |
| 2 | 生成中按回车什么都不发生(消息被丢弃) | 生成中发出的消息排队,当前回答一结束自动发出,且可撤回 |
| 3 | 生时校正里生成中点停止:对话区出现一条红色告警条,气泡按失败样式处理,看起来像"出错"而不是"我停下了" | 停止是用户动作,呈现为中性状态:已生成的内容保留、一句灰字说明已停止、不扣点 |
| 4 | 生时校正里回答选择题后的那段等待中,停止键在但点了没反应 | 停止键出现就必须能停 |
## 2. 根因(已在代码里核实)
**#1#2 是同一处**:两个对话面都在生成期间把 `<textarea>` 整个禁用。
- `chat-composer.tsx` L83`disabled={inputDisabled}` 直接落到 `<Textarea>`
- 主咨询 `page.tsx` L1998`inputDisabled = isLoading || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || (onboarding 条件)`L2000 `submitBlocked` 同一组条件加 `pendingSessionId`
- 生时校正 `rectification-agentic-chat.tsx` L1430 `canSend = !busy && !readonly && !regeneratingMessageKey`L1704/1706 把 `!canSend` 同时喂给 `inputDisabled``submitBlocked`
- 浏览器对被 `disabled` 的元素会立即移除键盘焦点,解禁后不会自动归还;两处都没有在结算后重新聚焦(主咨询只在 `chooseSuggestedQuestion` L1390 和 onboarding L1313 聚焦)。所以每答一轮都要点一次。
- 回车:主咨询 `handleComposerKeyDown``requestSubmit()``submit()``send()`,而 `use-consultation-run.ts` L474 `if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;` 静默丢弃;生时校正 `send()` L732 `if (!continuation && busy) return;` 同样丢弃。没有任何排队机制(`page.tsx` 里搜不到 queue/待发送)。
- 服务端没有"一个会话只能一条"的锁:`/api/consult` 的 409 只有模型变化、计划、出生资料模式;`/api/rectification/agent` 的 409 是 Case 状态。禁用输入框是纯前端决定。
**#3 是呈现层错位**:主咨询有明确的结果分级 `chat-reply-announcement.ts`——`stopped → chat_notice_toast`(中性 toast)、`failed → conversation_alert`(红色告警)。生时校正没有遵守:
- `rectification-agentic-chat.tsx` L10121034:捕获 `AbortError` 后把气泡写成 `failed: true`,并 `setError(RECTIFICATION_STOPPED_NOTICE)`
- `error` 只有一个渲染出口 L1659 `<p className="error-message" role="alert">``globals.css` L1503 是 `--color-danger` 左边框 + 红字。"已停止,已生成的内容保留;本次不会扣点。"这句话本身是对的,但穿着报错的衣服。
- 若停止时还没有正文(还在工具阶段):`raw` 为空,不给任何提示;气泡 `failed` 且空文本时 L1601 条件不渲染行 → 那条思考行直接消失,用户不知道发生了什么。
- 服务端这部分是对的:`agent/route.ts` L277/377/449/529/739 把 `request.signal` 一路传下去,`agent-run.ts` L553559 联动中止模型流,`route.ts` L699 `releaseUsage(..., "rectification_cancelled")` 退点。本单不动服务端。
**#4**:生时校正的选择题路径 L1086–1135 与采用路径 L12121290 各自 `fetch("/api/rectification/agent")` 时**没有**把 `AbortController` 挂到 `runAbort`(只有 `send()` L795796 挂了),而 `busy` 为真时停止键照样显示(L1707 `stopVisible={busy}`)。所以在这段 fetch 里点停止,`stopRun` L564 `runAbort.current?.abort()` 是空操作;要等它接着跑 `send("read_only", …)` 续写时才停得下来。
## 3. 决策记录
1. **产品已拍板做 1+2**(2026-09-06):生成中输入框永不禁用;生成中发送 = 排队,当前回答结束后自动发出。不做"打断并合并推理"(那需要重定义取消计费与半截回答落库,另立项)。
2. **排队的语义**
- 排队只保存一条;再按回车把新内容追加到同一条(换行拼接),不做多条队列。
- 排队条以卡片形式贴在输入框上方,文案见 §5.1,带「撤回」——撤回把文字放回输入框并聚焦。
- 当前回答**正常结算**后自动发出;**用户停止、失败、断线恢复**时不自动发,文字放回输入框,由用户决定。
- 生时校正面:排队文字在下一轮到来时走现有 typed 路径(等同用户手打回答,`markQuestionAnswered(..., "typed")`),不改服务端。若下一轮结算后处于 `readonly`/已采用等不可发送状态,同样放回输入框。
3. **停止是中性动作**,两个对话面统一遵守 `chat-reply-announcement.ts` 的分级:停止走中性提示,不得走 `role="alert"` 的红色告警;气泡不得标成 `failed`
4. `inputDisabled` 只保留**结构性**理由:`readonly`、主页上校正面打开时(`rectificationSurfaceOpen`)、会话消息加载中(`sessionMessagesLoading`)、onboarding 未完成。`isLoading` / `busy` / `cancellationPending` / `regeneratingMessageKey` 一律不再禁用输入框,只影响发送键与排队。
5. 停止后**不**自动把焦点拉回输入框(用户可能正在看回答);输入框既然不再禁用,焦点本来就不会丢。
6. 文案先对照 `frontend/docs/VOICE.md`;新增文案与视觉同提交更新 `DESIGN.md`「Input and composer」与「Message」。
## 4. 硬红线
- `page.tsx` 不得增行(当前 2041 行);排队逻辑放 `frontend/src/hooks/use-queued-message.ts`(参数式 hook 模式,内部 0 个 React hook 或按仓库既定模式)或 `frontend/src/lib/queued-draft.ts`,两个对话面共用。
- 不得再手写第二个输入框;排队条是 `ChatComposer` 的一个可选 slot/prop,不是新组件里的新 textarea。
- 不改服务端计费、取消(`/api/consult/cancel`)、`releaseUsage` 逻辑;不改 `agent-run.ts`
- 主咨询现有停止链路(退点确认、409 `request_completed` 时恢复完整回答、断线恢复轮询)原样保留,只改呈现与输入框禁用条件。
- `composer-ime-contract``composer-isolation-contract``chat-notice-and-scroll-contract``rectification-surface-contract``rectification-agentic-entry` 既有用例原样通过;改任何既有断言写「原值 / 新值 / 原因」三栏。
- `next build``/``○ Static`;首屏 gzip ±2%。
- 排队条、停止提示不得出现 spinner/骨架。
## 5. 任务分解
### 5.1 输入框不再禁用 + 排队发送(BUG-551)
- `ChatComposer`:新增可选 `queued?: { text: string; onRecall: () => void }`;有值时在 textarea 上方渲染一条排队卡片(文案:「已排队,回答结束后发出」+ 文字预览 + 「撤回」按钮)。`inputDisabled` 仍支持,但两处调用方只传 §3.4 的结构性条件。
- 主咨询:`isLoading``cancellationPending` 期间回车与点发送 → 进入排队(不再被 `send()` 静默丢弃);`replyOutcome.phase === "completed"` 后自动 `send(queued)``stopped``failed``recovering` 时把排队文字放回草稿(`setDraft`)并清空排队。发送键在有排队时禁用或改为「已排队」态由执行方在 DESIGN.md 记录。
- 生时校正:`busy``regeneratingMessageKey` 期间同上;`send` 结算成功(`succeeded`)后自动以 `message` 发出排队文字;`AbortError`/失败/`readonly` 时放回草稿。
- 验收:
- `tests/chat-composer-queue.test.ts`(新):生成中 textarea 无 `disabled`;回车把文字放进排队而非丢弃;再次回车追加到同一条;撤回放回输入框;结算成功自动发出、停止不发出。
- `composer-ime-contract` / `composer-isolation-contract` 原样通过。
- `rectification-surface-contract``busy``inputDisabled === false`
### 5.2 停止 = 中性状态(BUG-552
- 生时校正 `AbortError` 分支:气泡改为 `state: "settled"`、不标 `failed`,新增 `stopped: true``ChatMessageRow``stopped` 时于正文下渲染一行灰字(`RECTIFICATION_STOPPED_NOTICE` 或更短的「已停止,本次不扣点」),不再 `setError`。停止时无正文:保留气泡,只显示这行灰字,不让思考行凭空消失。
- `error-message` 只留给真正的失败;`rectification-surface-state.ts``RECTIFICATION_STOPPED_NOTICE` 若改字,VOICE.md 同步。
- 选择题 L1086 与采用 L1212 两条 fetch 挂上 `AbortController` 并写入 `runAbort`,被中止时与 `send()` 同样处理(气泡 `stopped`,不报错);服务端已按 `request.signal` 退点,前端不用额外调用。
- 主咨询:核对 `stopResponse` 各分支只用 toast(已如此);若产品复现的"报错"出现在主咨询,执行方在进度记录贴出脱敏后的提示原文并对照 §2 补根因,不得猜。
- 验收:
- `rectification-agentic-entry` 新增:流中 abort → 最后一条助手消息 `stopped === true``failed !== true`、页面无 `role="alert"`;无正文时 abort → 气泡仍在且带停止说明。
- 选择题 fetch 中 abort → 请求被中止、`busy` 归位、无告警。
- `chat-notice-and-scroll-contract``stopped` 仍映射 `chat_notice_toast`
### 5.3 记录
- `docs/BUG_HISTORY.md`:BUG-551(输入框禁用致焦点丢失、回车丢消息;关联 BUG-216、BUG-329)、BUG-552(停止穿报错衣服、选择题期间停止无效;关联 BUG-329,说明它"绑定 fetch abort"的防复发为何没覆盖选择题与采用路径)。
- `CHANGELOG.md` 一条;`frontend/DESIGN.md`「Input and composer」加排队条、「Message」加停止态;`frontend/docs/VOICE.md` 加排队与停止文案。
- `docs/tasks/PROGRESS-composer-live-input-and-stop-20260906.md``docs/testing/composer-live-input-and-stop-20260906.md`(真实环境:生成中连续打字不丢焦点;回车排队并在回答后自动发出;撤回;两个面停止后无红条、内容保留、点数未扣;选择题等待中点停止立即停)。
## 6. 让步顺序
5.1 与 5.2 可拆成两个提交但同一分支交付;5.3 不可省。若排队在生时校正面与 `readonly`/采用态冲突难以收敛,可先只做主咨询排队、校正面只做"不禁用 + 回车放回草稿并提示「回答结束后再发」",须在进度记录写明。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/composer-live-input-and-stop-20260906 .worktrees/composer-live-input-and-stop-20260906 origin/staging
cd .worktrees/composer-live-input-and-stop-20260906
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && npx tsx --test tests/composer-*.test.ts tests/chat-notice-and-scroll-contract.test.ts tests/rectification-surface-contract.test.ts tests/rectification-agentic-entry.test.ts tests/agent-voice-copy-contract.test.ts 2>&1 | grep -E "^# (tests|pass|fail)|^not ok"
wc -l src/app/page.tsx # 2041,收尾不得超过
```
收尾跑同一条命令 fail=0,再 `tsc --noEmit``npm run lint``npm run build``/` Static、gzip ±2%)。