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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-20 19:46:27 +08:00
co-authored by Cursor
parent 421ec0f9bf
commit 994f0583af
10 changed files with 322 additions and 121 deletions
+48
View File
@@ -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 流式合同被一起推迟)
- 修复版本:待本次提交
+1 -1
View File
@@ -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.
+72 -44
View File
@@ -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);
@@ -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<CandidateResult>(null);
const runAbort = useRef<AbortController | null>(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" : ""}`}
>
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
<div className="rectification-workspace__chat">
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list">
{pendingConsultationQuestion?.trim() && (
@@ -745,7 +784,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
result={candidateResult}
expanded={boardOpen}
boardId={boardId}
onOpen={() => setBoardOpen(true)}
onOpen={toggleBoard}
/>
)}
<form className="composer" onSubmit={submit}>
@@ -765,9 +804,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}}
/>
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
{busy ? (
<Button
className="composer-stop"
aria-label="停止回答"
title="停止当前推理,已生成内容会保留"
size="icon"
type="button"
onClick={stopRun}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<div className="composer-footer">
+26 -40
View File
@@ -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 ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}>当前盘面</h2>
@@ -71,9 +73,10 @@ function RectificationBoardBody({
<button
className="rectification-board__close"
type="button"
aria-label="关闭盘面"
onClick={onClose}
>
关闭盘面
<X aria-hidden="true" strokeWidth={2} />
</button>
)}
</div>
@@ -207,48 +210,31 @@ export function RectificationBoard({
titleId: string;
onClose: () => void;
}>) {
const dialogRef = useRef<HTMLDialogElement>(null);
const body = (
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
);
useEffect(() => {
const node = dialogRef.current;
if (!compact || !node) return;
if (open) {
if (!node.open) node.showModal();
} else if (node.open) {
node.close();
}
}, [compact, open]);
if (!compact || !open) return;
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
useEffect(() => {
if (!compact) return;
const node = dialogRef.current;
if (!node) return;
const handleClose = () => onClose();
node.addEventListener("close", handleClose);
return () => node.removeEventListener("close", handleClose);
}, [compact, onClose]);
if (compact) {
return (
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
</dialog>
);
}
if (compact && !open) return null;
return (
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
{body}
<aside
id={boardId}
className={`rectification-board${compact ? " is-sheet" : ""}`}
aria-labelledby={titleId}
>
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
</aside>
);
}
@@ -1,9 +1,10 @@
/**
* V10 rectification turn runner over the durable V9 Case/Evidence domains.
*
* Every model attempt is isolated. Text, tool sets, usage and public receipt
* events are committed only from the successful attempt. The browser never
* sees partial text from an attempt that is retried or abandoned.
* Tool activity and answer tokens are published as they happen so the browser
* can render progress. A retried attempt emits `attempt.reset` first so the
* client discards the abandoned attempt's visible text. Durable receipts,
* billing, and settled history still come only from the successful attempt.
*/
import type { Agent } from "@mastra/core/agent";
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
@@ -316,6 +317,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|| outcome.status === "failed"
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal)
|| attemptNumber === MAX_ATTEMPTS) break;
await emit({ type: "attempt.reset" });
}
const outcome = finalOutcome ?? {
@@ -336,7 +338,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!outcome.ok) {
await billing.release();
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
for (const event of outcome.events) await emit(event);
await emit({ type: "run.failed" });
return {
ok: false,
@@ -404,9 +405,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
);
await finalizeTurn("completed", outcome.answerText, outcome.attemptId, outcome.attemptId, true);
for (const event of outcome.events) await emit(event);
await emit({ type: "billing.settled" });
for (const text of outcome.answerDeltas) await emit({ type: "answer.delta", text });
await emit({ type: "run.completed", turnId });
return {
@@ -463,13 +462,18 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let phaseSequence = 0;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (phase === "answer.delta" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
if (phase === "answer.delta" || phase === "attempt.reset" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
emittedKeys.add(`${phase}:${tool ?? ""}`);
phases.push(phase);
phaseSequence += 1;
await persistCommittedPhase(phase, tool, attemptId, phaseSequence);
};
const publish = async (event: PublicStreamEvent) => {
events.push(event);
await emit(event);
};
try {
await recordPhase("run.started");
await insertV9SkillRunReceipt(
@@ -482,7 +486,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
skillPackage,
);
await recordPhase("skill.bound");
events.push({ type: "skill.bound" });
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const result = await (agent as unknown as {
@@ -537,7 +541,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const activityEvent = mapStreamChunkToActivity(chunk as never);
if (activityEvent) {
events.push(activityEvent);
await publish(activityEvent);
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
@@ -562,22 +566,26 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (phaseEvent.type === "answer.delta") {
const text = phaseEvent.text ?? "";
if (text) {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
}
}
} else {
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
if (!emittedKeys.has(`event:${key}`)) {
emittedKeys.add(`event:${key}`);
events.push(phaseEvent);
await publish(phaseEvent);
}
if (phaseEvent.type === "case.loaded") {
caseLoaded = true;
if (!intentClassified) {
intentClassified = true;
await recordPhase("intent.classified");
events.push({ type: "intent.classified" });
await publish({ type: "intent.classified" });
}
}
}
@@ -611,7 +619,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
await recordPhase("answer.composed");
events.push({ type: "answer.composed" });
await publish({ type: "answer.composed" });
return {
ok: true,
status: "completed",
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"answer.composed",
"billing.settled",
"answer.delta",
"attempt.reset",
"run.completed",
"run.failed",
] as const;
@@ -362,16 +362,48 @@ test("the natal house table is a live board beside the chat, not a message", ()
assert.match(chat, /await loadCandidate\(\)\.then\(\(result\) => \{/);
assert.match(board, /aria-live="polite"/);
assert.match(board, /groupWindowTransitions/);
assert.match(board, /<dialog/);
assert.match(board, /<aside/);
assert.doesNotMatch(board, /<dialog/);
assert.doesNotMatch(board, /showModal/);
assert.doesNotMatch(board, /onCloseRef\.current = onClose/);
assert.match(board, /node\.addEventListener\("close", handleClose\)/);
assert.match(board, /}, \[compact, onClose\]\);/);
assert.match(board, /event\.key === "Escape"/);
assert.match(board, /aria-label="关闭盘面"/);
assert.match(board, /className="rectification-board__handle"/);
assert.match(chat, /RectificationBoardPeek/);
assert.match(chat, /onOpen=\{toggleBoard\}/);
assert.doesNotMatch(chat, /inert=\{compactBoard && boardOpen/);
assert.match(houseTable, /当前本命宫位/);
assert.match(houseTable, /补充经历后会按新线索重算/);
assert.doesNotMatch(houseTable, /<ChatMessageRow/);
});
test("compact board shares the viewport with chat and uses an icon close control", () => {
assert.match(styles, /\.rectification-workspace\.is-compact\.is-board-open \{[\s\S]*grid-template-rows: minmax\(8rem, 1fr\) minmax\(13rem, 46%\)/);
assert.match(styles, /\.rectification-board\.is-sheet \{[\s\S]*border-block-start: 1px solid var\(--color-border\)/);
assert.doesNotMatch(styles, /dialog\.rectification-board/);
assert.match(styles, /\.rectification-board__close \{[\s\S]*width: 44px/);
assert.match(styles, /\.rectification-board__close \{[\s\S]*min-height: 44px/);
assert.match(board, /<X aria-hidden="true"/);
assert.match(chat, /className=\{`rectification-workspace\$\{compactBoard \? " is-compact" : ""\}\$\{boardOpen \? " is-board-open" : ""\}`\}/);
});
test("rectification composer can stop a live agent run", () => {
assert.match(chat, /signal: abortController\.signal/);
assert.match(chat, /runAbort\.current\?\.abort\(\)/);
assert.match(chat, /className="composer-stop"/);
assert.match(chat, /aria-label="停止回答"/);
assert.match(chat, /event\.type === "attempt\.reset"/);
assert.match(chat, /caught\.name === "AbortError"/);
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
});
test("conversation and house board reuse the quiet overlay scrollbar", () => {
assert.match(styles, /\[data-sidebar="content"\],\s*\n\.conversation,\s*\n\.rectification-board__body \{/);
assert.match(styles, /\.conversation \{[^}]*overflow-y:\s*auto/);
assert.doesNotMatch(styles, /\.conversation \{[^}]*scrollbar-gutter/);
assert.doesNotMatch(styles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \{[^}]*scrollbar-gutter/);
});
test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => {
const messageLoop = chat.slice(
chat.indexOf("{messages.map((message) => {"),
+59 -14
View File
@@ -153,6 +153,7 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "attempt.reset" }), { type: "attempt.reset" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.deepEqual(
safePublicEvent({ type: "run.completed", turnId: TURN_ID }),
@@ -448,6 +449,53 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
});
test("answer deltas and tool activity are published before billing settles", async () => {
let billingStarted = false;
const seenBeforeBilling: string[] = [];
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => {
billingStarted = true;
billing.completed += 1;
return true;
},
release: async () => { billing.released += 1; return true; },
},
emit: (event) => {
if (!billingStarted && (event.type === "answer.delta" || event.type === "tool.activity" || event.type === "answer.composed")) {
seenBeforeBilling.push(event.type);
}
emitted.push(event);
},
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "先记下这件事。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.ok(seenBeforeBilling.includes("tool.activity"));
assert.ok(seenBeforeBilling.includes("answer.delta"));
assert.ok(seenBeforeBilling.includes("answer.composed"));
const deltaIndex = emitted.findIndex((event) => event.type === "answer.delta");
const billingIndex = emitted.findIndex((event) => event.type === "billing.settled");
assert.ok(deltaIndex >= 0 && billingIndex > deltaIndex);
});
test("half-failure never becomes settled history and releases usage", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
@@ -670,22 +718,18 @@ for (const failureCode of ["stream_aborted", "stream_unfinished", "empty_stream"
"rectification-read-case",
"rectification-record-evidence-batch",
]);
const resetAt = emitted.findIndex((event) => event.type === "attempt.reset");
assert.ok(resetAt >= 0);
assert.equal(
emitted.some((event) => event.type === "answer.delta" && event.text?.includes("失败 attempt")),
failureCode !== "empty_stream",
);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
emitted.slice(resetAt + 1).filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "第二次 attempt 成功" }],
);
assert.equal(
emitted.some((event) => event.type === "answer.delta" && event.text?.includes("失败 attempt")),
false,
);
assert.equal(
emitted.some((event) =>
event.type === "tool.activity"
&& (event as { tool?: string }).tool === "rectification-set-focus"),
false,
);
assert.equal(
emitted.some((event) =>
emitted.slice(resetAt + 1).some((event) =>
event.type === "tool.activity"
&& (event as { tool?: string }).tool === "rectification-record-evidence-batch"),
true,
@@ -793,7 +837,8 @@ test("a failed set-focus cannot complete a question turn even when the agent emi
assert.equal(result.answerText, "");
assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "answer.delta"), false);
assert.equal(emitted.some((event) => event.type === "answer.delta"), true);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.equal(
emitted.some((event) => event.type === "tool.activity"
@@ -988,7 +1033,7 @@ test("thinking-mode tool_choice rejection fails the opening turn without a secon
assert.equal(result.turnStatus, "failed");
assert.equal(result.errorCode, "thinking_tool_choice_unsupported");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.deepEqual(emitted, [{ type: "run.started" }, { type: "run.failed" }]);
assert.deepEqual(emitted, [{ type: "run.started" }, { type: "skill.bound" }, { type: "run.failed" }]);
assert.equal(
accounting.calls.filter((call) => call.fn === "create_agentic_rectification_run_attempt").length,
1,
+1
View File
@@ -296,6 +296,7 @@ test("styles the session history scrollbar as a quiet overlay", () => {
assert.match(globalStyles, /@media\s*\(prefers-contrast:\s*more\)[\s\S]*\[data-sidebar="content"\][^{]*\{[^}]*scrollbar-color:\s*var\(--color-ink-secondary\)\s+transparent/);
assert.match(globalStyles, /@media\s*\(forced-colors:\s*active\)[\s\S]*\[data-sidebar="content"\][^{]*\{[^}]*scrollbar-color:\s*auto/);
assert.match(readProjectFile("DESIGN.md"), /quiet overlay scrollbar/);
assert.match(readProjectFile("DESIGN.md"), /ordinary session/);
});
test("renders each session title and menu as one unified row surface", () => {