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
@@ -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>
);
}