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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user