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

12 KiB
Raw Blame History

TASK · 生成中输入框不再禁用、回车排队发送、停止不再报错(2026-09-06)

  • 基线:origin/staging @ b8ea3112
  • 分支:codex/composer-live-input-and-stop-20260906
  • 执行方:coding agent;验收:Claude
  • 涉及文件:frontend/src/components/chat-composer.tsxfrontend/src/app/page.tsx(只改传参与调用,不得增行;新逻辑进 frontend/src/hooks/ / frontend/src/lib/)、frontend/src/hooks/use-consultation-run.tsfrontend/src/components/rectification-agentic-chat.tsxfrontend/src/lib/rectification-surface-state.tsfrontend/DESIGN.mdfrontend/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.tsxrectification-agentic-chat.tsx;同日若有别的单改这两个文件,本单先做。

1. 事故实证(staging2026-09-0506,产品实测)

# 用户看到 期望(对标主流 AI 对话产品)
1 发出消息后输入框整个变灰,不能打字;回答完毕后必须再点一次输入框才能继续输入 生成中随时能打字,焦点不丢;只有发送键换成停止键
2 生成中按回车什么都不发生(消息被丢弃) 生成中发出的消息排队,当前回答一结束自动发出,且可撤回
3 生时校正里生成中点停止:对话区出现一条红色告警条,气泡按失败样式处理,看起来像"出错"而不是"我停下了" 停止是用户动作,呈现为中性状态:已生成的内容保留、一句灰字说明已停止、不扣点
4 生时校正里回答选择题后的那段等待中,停止键在但点了没反应 停止键出现就必须能停

2. 根因(已在代码里核实)

#1、#2 是同一处:两个对话面都在生成期间把 <textarea> 整个禁用。

  • chat-composer.tsx L83disabled={inputDisabled} 直接落到 <Textarea>
  • 主咨询 page.tsx L1998inputDisabled = isLoading || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || (onboarding 条件)L2000 submitBlocked 同一组条件加 pendingSessionId
  • 生时校正 rectification-agentic-chat.tsx L1430 canSend = !busy && !readonly && !regeneratingMessageKeyL1704/1706 把 !canSend 同时喂给 inputDisabledsubmitBlocked
  • 浏览器对被 disabled 的元素会立即移除键盘焦点,解禁后不会自动归还;两处都没有在结算后重新聚焦(主咨询只在 chooseSuggestedQuestion L1390 和 onboarding L1313 聚焦)。所以每答一轮都要点一次。
  • 回车:主咨询 handleComposerKeyDownrequestSubmit()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-contractcomposer-isolation-contractchat-notice-and-scroll-contractrectification-surface-contractrectification-agentic-entry 既有用例原样通过;改任何既有断言写「原值 / 新值 / 原因」三栏。
  • next build/○ Static;首屏 gzip ±2%。
  • 排队条、停止提示不得出现 spinner/骨架。

5. 任务分解

5.1 输入框不再禁用 + 排队发送(BUG-551)

  • ChatComposer:新增可选 queued?: { text: string; onRecall: () => void };有值时在 textarea 上方渲染一条排队卡片(文案:「已排队,回答结束后发出」+ 文字预览 + 「撤回」按钮)。inputDisabled 仍支持,但两处调用方只传 §3.4 的结构性条件。
  • 主咨询:isLoadingcancellationPending 期间回车与点发送 → 进入排队(不再被 send() 静默丢弃);replyOutcome.phase === "completed" 后自动 send(queued)stoppedfailedrecovering 时把排队文字放回草稿(setDraft)并清空排队。发送键在有排队时禁用或改为「已排队」态由执行方在 DESIGN.md 记录。
  • 生时校正:busyregeneratingMessageKey 期间同上;send 结算成功(succeeded)后自动以 message 发出排队文字;AbortError/失败/readonly 时放回草稿。
  • 验收:
    • tests/chat-composer-queue.test.ts(新):生成中 textarea 无 disabled;回车把文字放进排队而非丢弃;再次回车追加到同一条;撤回放回输入框;结算成功自动发出、停止不发出。
    • composer-ime-contract / composer-isolation-contract 原样通过。
    • rectification-surface-contractbusyinputDisabled === false

5.2 停止 = 中性状态(BUG-552

  • 生时校正 AbortError 分支:气泡改为 state: "settled"、不标 failed,新增 stopped: trueChatMessageRowstopped 时于正文下渲染一行灰字(RECTIFICATION_STOPPED_NOTICE 或更短的「已停止,本次不扣点」),不再 setError。停止时无正文:保留气泡,只显示这行灰字,不让思考行凭空消失。
  • error-message 只留给真正的失败;rectification-surface-state.tsRECTIFICATION_STOPPED_NOTICE 若改字,VOICE.md 同步。
  • 选择题 L1086 与采用 L1212 两条 fetch 挂上 AbortController 并写入 runAbort,被中止时与 send() 同样处理(气泡 stopped,不报错);服务端已按 request.signal 退点,前端不用额外调用。
  • 主咨询:核对 stopResponse 各分支只用 toast(已如此);若产品复现的"报错"出现在主咨询,执行方在进度记录贴出脱敏后的提示原文并对照 §2 补根因,不得猜。
  • 验收:
    • rectification-agentic-entry 新增:流中 abort → 最后一条助手消息 stopped === truefailed !== true、页面无 role="alert";无正文时 abort → 气泡仍在且带停止说明。
    • 选择题 fetch 中 abort → 请求被中止、busy 归位、无告警。
    • chat-notice-and-scroll-contractstopped 仍映射 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.mddocs/testing/composer-live-input-and-stop-20260906.md(真实环境:生成中连续打字不丢焦点;回车排队并在回答后自动发出;撤回;两个面停止后无红条、内容保留、点数未扣;选择题等待中点停止立即停)。

6. 让步顺序

5.1 与 5.2 可拆成两个提交但同一分支交付;5.3 不可省。若排队在生时校正面与 readonly/采用态冲突难以收敛,可先只做主咨询排队、校正面只做"不禁用 + 回车放回草稿并提示「回答结束后再发」",须在进度记录写明。

7. 开工前置命令

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 --noEmitnpm run lintnpm run build/ Static、gzip ±2%)。