From 994f0583af88af979ea571f1bfc290711a6a94e6 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 20 Aug 2026 19:46:27 +0800 Subject: [PATCH] fix(rectification): stream answers live and keep mobile chat visible Use the quiet overlay scrollbar on chat and the house board, replace the blocking mobile dialog with a bottom sheet, and emit agent deltas before billing so users can read progress and stop a run. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 48 ++++++++ frontend/DESIGN.md | 2 +- frontend/src/app/globals.css | 116 +++++++++++------- .../components/rectification-agentic-chat.tsx | 66 ++++++++-- .../src/components/rectification-board.tsx | 66 ++++------ .../lib/rectification-agentic/v9/agent-run.ts | 32 +++-- .../v9/public-receipt.ts | 1 + .../tests/rectification-agentic-entry.test.ts | 38 +++++- .../tests/rectification-v9-stream.test.ts | 73 ++++++++--- frontend/tests/sidebar-contract.test.ts | 1 + 10 files changed, 322 insertions(+), 121 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 70a2d13a..3845ce9b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4922,3 +4922,51 @@ - 相关记录:BUG-322 - 复发自:无 - 修复版本:dbbcb71b + +## BUG-327 | Web 对话与生时纠正仍露出原生滚动条 + +- 状态:resolved +- 首次发现:2026-08-20 +- 最近更新:2026-08-20 +- 影响面:普通 session `.conversation`、生时纠正消息列表、右侧盘面 `.rectification-board__body` +- 用户现象:聊天和生时纠正内容超出高度时,直接出现浏览器原生滚动条,和侧栏已有的安静 overlay 滚动条不一致。 +- 触发条件:普通对话或生时纠正历史超过可视高度,在桌面 Chromium / Firefox 中滚动。 +- 根因:只有侧栏 `SidebarContent` 使用产品内滚动条;会话区和盘面仍是 `overflow-y: auto` 并保留 `scrollbar-gutter`,因此继续露出系统滚动条。 +- 修复:会话列表、普通对话和生时纠正盘面共用同一套安静 overlay 滚动条:透明轨道、无 gutter、默认隐藏拇指,悬停或键盘焦点后显示低对比圆角拇指。 +- 验证:`frontend/tests/sidebar-contract.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`。 +- 防复发:独立滚动容器必须同时覆盖标准 scrollbar 属性和 WebKit 伪元素,且不得再用 `scrollbar-gutter` 给系统滚动条留槽。 +- 相关记录:BUG-042 +- 复发自:BUG-042(只修了当时的生时纠正消息区,后来的 session 列宽和盘面分栏又回到原生滚动条) +- 修复版本:待本次提交 + +## BUG-328 | 移动端盘面以模态挡住聊天且关闭入口弱 + +- 状态:resolved +- 首次发现:2026-08-20 +- 最近更新:2026-08-20 +- 影响面:生时纠正窄屏盘面、`rectification-board` +- 用户现象:打开“当前盘面”后,白色面板盖住大部分聊天,几乎读不到刚才的对话;关闭只能靠一段会折行的文字按钮。 +- 触发条件:窄于分栏阈值的视口打开宫位表。 +- 根因:窄屏用 `showModal()` 对话框加不透明 backdrop,并把聊天设为 `inert`;高度约 86dvh / 100dvh,聊天被遮死。 +- 修复:窄屏改为工作区分栏下半屏 sheet(约 46% 高),聊天留在上半屏可继续阅读;关闭改为 44px 图标按钮,Escape 仍可关闭。 +- 验证:`frontend/tests/rectification-agentic-entry.test.ts`。 +- 防复发:窄屏盘面不得再用 modal dialog / backdrop 盖住聊天;关闭控件必须是独立 44px 目标并带 `aria-label="关闭盘面"`。 +- 相关记录:BUG-322、BUG-326 +- 复发自:BUG-322(分栏后窄屏改成对话框,聊天被挡住) +- 修复版本:待本次提交 + +## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止 + +- 状态:resolved +- 首次发现:2026-08-20 +- 最近更新:2026-08-20 +- 影响面:`POST /api/rectification/agent`、`runV9AgentTurn`、生时纠正输入框 +- 用户现象:发送经历后接口长时间无增量文字,工具活动与回答在计费完成后才整段出现;推理过程中发送按钮不可用,也无法停止。 +- 触发条件:任意生时纠正 `message` / `opening` 流,尤其是 `rectification-set-focus` 同轮重试的长等待。 +- 根因:成功 attempt 的 tool.activity 与 `answer.delta` 被攒到 `billing.complete` 和持久化之后才发给浏览器。前端 fetch 没有 AbortSignal,输入区也没有停止按钮。 +- 修复:工具活动和回答增量在生成过程中即时发布;失败重试先发 `attempt.reset` 清掉弃用 attempt 的可见文字。前端在推理中显示停止按钮,中止请求;已流出的文字保留。 +- 验证:`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`。 +- 防复发:`answer.delta` 必须在 `billing.settled` 之前出现在公共流里;生时纠正 composer 在 `busy` 时必须提供停止按钮并绑定 fetch abort。 +- 相关记录:BUG-039、BUG-067 +- 复发自:BUG-039(当时为了 attempt 隔离把增量攒到结算后,V9 流式合同被一起推迟) +- 修复版本:待本次提交 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f2b5ffb8..2d8d284d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -154,7 +154,7 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Mobile:** no icon rail; an off-canvas drawer uses `min(86vw, 320px)` and closes through its scrim or Escape. - **Collapsed content:** logo, new-chat action, one history expansion action, and account avatar. Individual sessions do not become indistinguishable repeated icons. - **Scroll ownership:** header and footer remain fixed; `SidebarContent` is the sole sidebar scroll owner. -- **Scrollbar:** `SidebarContent` uses a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the history, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar. +- **Scrollbar:** `SidebarContent`, ordinary session `.conversation`, and the rectification house board use a quiet overlay scrollbar: transparent track, no `scrollbar-gutter`, and a 4px warm thumb mixed from `--color-ink`. The thumb stays transparent until hover or keyboard focus inside the scroller, then uses `color-mix(in srgb, var(--color-ink) 26%, transparent)`; thumb hover uses 40%. Increased contrast keeps the thumb visible; forced colors restore the system scrollbar. - **Motion:** Sidebar state changes are immediate on desktop, tablet, and mobile. The 44px trigger keeps one stable 18px sidebar glyph and never enters an intermediate scale or opacity state. - **Accessibility:** Command/Control+B shortcut outside editable controls, contextual trigger labels, 44px targets, focus return, collapsed-only tooltips, reduced-motion, reduced-transparency, and increased-contrast support. - **State:** session-local; reload uses breakpoint defaults rather than cookie or local-storage persistence. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1f027751..4b37a095 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -394,20 +394,48 @@ button:disabled { cursor: default; opacity: .45; } .chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); } .sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); } [data-sidebar="header"] { flex: 0 0 auto; } -[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: transparent transparent; } -[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within { scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent; } -[data-sidebar="content"]::-webkit-scrollbar { width: 10px; background: transparent; } -[data-sidebar="content"]::-webkit-scrollbar-button { display: none; width: 0; height: 0; } -[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner { background: transparent; } -[data-sidebar="content"]::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; } -[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); } -[data-sidebar="content"]::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); } +[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; } +[data-sidebar="content"], +.conversation, +.rectification-board__body { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} +[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within, +.conversation:hover, .conversation:focus-within, +.rectification-board__body:hover, .rectification-board__body:focus-within { + scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent; +} +[data-sidebar="content"]::-webkit-scrollbar, +.conversation::-webkit-scrollbar, +.rectification-board__body::-webkit-scrollbar { width: 10px; background: transparent; } +[data-sidebar="content"]::-webkit-scrollbar-button, +.conversation::-webkit-scrollbar-button, +.rectification-board__body::-webkit-scrollbar-button { display: none; width: 0; height: 0; } +[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner, +.conversation::-webkit-scrollbar-track, .conversation::-webkit-scrollbar-corner, +.rectification-board__body::-webkit-scrollbar-track, .rectification-board__body::-webkit-scrollbar-corner { background: transparent; } +[data-sidebar="content"]::-webkit-scrollbar-thumb, +.conversation::-webkit-scrollbar-thumb, +.rectification-board__body::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; } +[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb, +.conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb, +.rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); } +[data-sidebar="content"]::-webkit-scrollbar-thumb:hover, +.conversation::-webkit-scrollbar-thumb:hover, +.rectification-board__body::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); } @media (prefers-contrast: more) { - [data-sidebar="content"] { scrollbar-color: var(--color-ink-secondary) transparent; } - [data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); } + [data-sidebar="content"], + .conversation, + .rectification-board__body { scrollbar-color: var(--color-ink-secondary) transparent; } + [data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb, + .conversation::-webkit-scrollbar-thumb, .conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb, + .rectification-board__body::-webkit-scrollbar-thumb, .rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); } } @media (forced-colors: active) { - [data-sidebar="content"] { scrollbar-width: auto; scrollbar-color: auto; } + [data-sidebar="content"], + .conversation, + .rectification-board__body { scrollbar-width: auto; scrollbar-color: auto; } } [data-sidebar="footer"] { flex: 0 0 auto; } [data-sidebar="sidebar"]:focus { outline: none; } @@ -482,7 +510,7 @@ button:disabled { cursor: default; opacity: .45; } .chat-header-actions > * { flex: 0 0 auto; } .credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; } -.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); } +.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); } .conversation.is-empty { display: grid; place-items: center; padding: var(--space-8); } .welcome { width: min(820px, 100%); padding: var(--space-10) 0 var(--space-16); } .welcome > .onboarding-message:first-child { padding-bottom: var(--space-8); } @@ -1124,17 +1152,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .paywall-footer > a, .paywall-footer > button { width: 100%; } .dialog-actions { flex-direction: column-reverse; } .dialog-actions > button { width: 100%; } - dialog.rectification-board { - width: 100%; - height: min(86dvh, 100%); - margin-block-start: auto; - margin-inline: 0; - border-inline-start: 0; - border-block-start: 1px solid var(--color-border); - border-start-start-radius: var(--radius-lg); - border-start-end-radius: var(--radius-lg); - padding-bottom: env(safe-area-inset-bottom); - } .auth-story { min-height: 0; padding: var(--space-5); } .auth-story h2 { font-size: var(--type-title-lg); } .auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); } @@ -1612,10 +1629,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class padding: var(--space-10) var(--session-column-gutter) var(--space-24); } -.conversation:not(.is-empty):not(.is-rectification) { - scrollbar-gutter: stable both-edges; -} - .conversation:not(.is-empty):not(.is-rectification) .message { padding: 0; animation: none; @@ -1871,6 +1884,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .rectification-workspace.is-compact { grid-template-columns: minmax(0, 1fr); } +.rectification-workspace.is-compact.is-board-open { + grid-template-rows: minmax(8rem, 1fr) minmax(13rem, 46%); +} .rectification-workspace__chat { min-width: 0; min-height: 0; @@ -1897,21 +1913,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class background: var(--color-canvas-soft); color: var(--color-ink); } -dialog.rectification-board { - width: min(22.5rem, 100%); - height: 100dvh; - max-width: none; - max-height: none; - margin: 0; - margin-inline-start: auto; - padding: 0; - border: 0; - border-inline-start: 1px solid var(--color-border); - background: var(--color-canvas-soft); - color: var(--color-ink); +.rectification-board.is-sheet { + border-inline-start: 0; + border-block-start: 1px solid var(--color-border); + border-start-start-radius: var(--radius-lg); + border-start-end-radius: var(--radius-lg); + box-shadow: 0 -8px 28px color-mix(in srgb, var(--color-ink) 10%, transparent); + padding-bottom: env(safe-area-inset-bottom); } -dialog.rectification-board::backdrop { - background: var(--color-scrim); +.rectification-board__handle { + width: 36px; + height: 4px; + margin: var(--space-2) auto 0; + border-radius: 999px; + background: color-mix(in srgb, var(--color-ink) 18%, transparent); } .rectification-board__header { position: sticky; @@ -1919,17 +1934,21 @@ dialog.rectification-board::backdrop { z-index: 1; display: grid; gap: var(--space-2); - padding: var(--space-4) var(--space-4) var(--space-3); + padding: var(--space-3) var(--space-4) var(--space-3); background: var(--color-canvas-soft); } .rectification-board__title-row { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: var(--space-2) var(--space-3); } .rectification-board__header h2 { + min-width: 0; margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 600; @@ -1942,15 +1961,24 @@ dialog.rectification-board::backdrop { font-weight: 600; } .rectification-board__close { + width: 44px; + height: 44px; + min-width: 44px; min-height: 44px; margin-inline-start: auto; - padding: 0 var(--space-3); + padding: 0; + display: grid; + place-items: center; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; + transition: background-color 120ms ease-out, transform 120ms ease-out; } +.rectification-board__close svg { width: 18px; height: 18px; } +.rectification-board__close:hover { background: var(--color-canvas-muted); } +.rectification-board__close:active { transform: scale(.96); } .rectification-board__close:focus-visible, .rectification-board-peek:focus-visible { outline: 2px solid var(--color-focus); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 10717cf0..1660fb7f 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, Square } from "lucide-react"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { parseAgentReply } from "@/lib/agent-reply"; import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; @@ -258,6 +258,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const keyCounter = useRef(0); const openingStarted = useRef(false); const previousBoardResult = useRef(null); + const runAbort = useRef(null); const [compactBoard, setCompactBoard] = useState(false); const [boardOpen, setBoardOpen] = useState(false); const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); @@ -284,6 +285,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, [candidateResult]); const closeBoard = useCallback(() => setBoardOpen(false), []); + const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []); + + const stopRun = useCallback(() => { + runAbort.current?.abort(); + }, []); const setPending = useCallback((value: boolean) => { setBusy(value); @@ -349,10 +355,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { let activityReceiptState = createRectificationActivityReceiptState(); let completedReceipt = receiptFromRectificationActivityState(activityReceiptState); let completedTurnId: string | undefined; + const abortController = new AbortController(); + runAbort.current = abortController; try { const response = await fetch("/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, + signal: abortController.signal, body: JSON.stringify({ caseId, sessionId, @@ -418,6 +427,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { ...message, text: parsed.text, state: "streaming" } : message)); + } else if (event.type === "attempt.reset") { + raw = ""; + activityReceiptState = createRectificationActivityReceiptState(); + completedReceipt = receiptFromRectificationActivityState(activityReceiptState); + completedTurnId = undefined; + setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey + ? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined } + : message)); } else if (event.type === "run.failed") { streamFailed = true; } else if (event.type === "error") { @@ -483,7 +500,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (result) setCandidateResult(result); }); } - } catch { + } catch (caught) { + const aborted = caught instanceof DOMException + ? caught.name === "AbortError" + : caught instanceof Error && caught.name === "AbortError"; + if (aborted) { + const parsed = parseAgentReply(raw); + setMessages((current) => current.flatMap((message): RenderMessage[] => { + if (message.renderKey !== assistantRenderKey) return [message]; + if (parsed.text) { + return [{ + ...message, + text: parsed.text, + state: "settled", + completedReceipt, + failed: false, + turnId: completedTurnId, + }]; + } + return []; + })); + return; + } setError("生时校正暂时不可用,请稍后再试。"); setMessages((current) => current.flatMap((message): RenderMessage[] => { if (message.renderKey !== assistantRenderKey) return [message]; @@ -498,6 +536,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { : []; })); } finally { + if (runAbort.current === abortController) runAbort.current = null; setPending(false); } }, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]); @@ -650,7 +689,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ref={workspace} className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`} > -
+
{pendingConsultationQuestion?.trim() && ( @@ -745,7 +784,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { result={candidateResult} expanded={boardOpen} boardId={boardId} - onOpen={() => setBoardOpen(true)} + onOpen={toggleBoard} /> )}
@@ -765,9 +804,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } }} /> - + {busy ? ( + + ) : ( + + )}
diff --git a/frontend/src/components/rectification-board.tsx b/frontend/src/components/rectification-board.tsx index 81a2f0db..66499a04 100644 --- a/frontend/src/components/rectification-board.tsx +++ b/frontend/src/components/rectification-board.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef } from "react"; +import { X } from "lucide-react"; +import { useEffect } from "react"; import { groupWindowTransitions, rectificationBoardPeekCopy, @@ -63,6 +64,7 @@ function RectificationBoardBody({ return ( <> + {compact ?