feat(consult): type out streamed text at a capped pace and write the tail out on settle instead of one frame (T3, BUG-1075)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 09:14:46 +08:00
co-authored by Claude Fable 5.1
parent 86d99115c6
commit 3f8b817261
8 changed files with 346 additions and 32 deletions
+35 -6
View File
@@ -489,7 +489,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (pendingConsultation.current?.requestId !== requestId) return;
pendingConsultation.current = null;
if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null;
setStreamingReply(null);
// A reply still being written out at the typing pace keeps the display
// (BUG-1075); its last paced frame clears it. Everything else is done now.
setStreamingReply((current) => (current?.settling ? current : null));
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
@@ -766,6 +768,11 @@ export function useConsultationRun(params: ConsultationRunParams) {
let streamedThinking = "";
let timelineState = emptyConsultationTimeline();
let currentActivity: AgentActivityView | undefined;
// True once the stream has ended and the buffer is writing out what is
// left at the typing pace (BUG-1075). From then on the display belongs to
// this request only while the streaming reply still carries its id: a new
// send replaces the reply, and the old buffer's frames fall through.
let settleRequested = false;
// Every stream event lands in `frames`; it commits at most once per animation
// frame and releases text at a steady pace. Nothing below calls
// setStreamingReply directly while the response body is being read.
@@ -775,7 +782,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
const partialReply = parseAgentReply(frame.answer).text;
latestPartialReply = partialReply;
thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply);
setStreamingReply({
const next: StreamingReply = {
sessionId,
text: partialReply,
responseKind,
@@ -783,12 +790,33 @@ export function useConsultationRun(params: ConsultationRunParams) {
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
timeline: timelineState.rows,
activity: currentActivity,
});
...(settleRequested ? { settling: requestId } : {}),
};
if (settleRequested) {
setStreamingReply((current) => (
current?.settling !== requestId ? current : frame.settled ? null : next
));
} else {
setStreamingReply(next);
}
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
}
},
});
// The stream has ended: write out the rest at the typing pace unless the
// run was stopped or failed, in which case what arrived shows at once.
const settleFrames = (immediate: boolean) => {
if (immediate) {
void frames.settle({ immediate: true });
return;
}
settleRequested = true;
setStreamingReply((current) => (
current && current.sessionId === sessionId ? { ...current, settling: requestId } : current
));
void frames.settle();
};
try {
const response = await fetch("/api/consult", {
method: "POST",
@@ -949,7 +977,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
parser.push(decoder.decode(value, { stream: true }));
}
parser.finish(decoder.decode());
frames.settle();
settleFrames(controller.signal.aborted || Boolean(truncatedFailure));
if (truncatedFailure) {
const reply = parseAgentReply(answer);
if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message);
@@ -992,7 +1020,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
}
answer += decoder.decode();
frames.setAnswer(answer);
frames.settle();
settleFrames(controller.signal.aborted);
}
if (controller.signal.aborted) return Boolean(latestPartialReply);
const reply = parseAgentReply(answer);
@@ -1043,7 +1071,8 @@ export function useConsultationRun(params: ConsultationRunParams) {
} catch (caught) {
// Whatever arrived before the failure is what gets kept, not just the
// part the pacing had released so far.
frames.settle();
settleRequested = false;
void frames.settle({ immediate: true });
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;