From cb9c02c1217fa5ae82af61554ce3497b8b1bba98 Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Thu, 4 Jun 2026 20:39:28 +0800 Subject: [PATCH] feat(desktop): streaming indicator with preparing/streaming/stalled states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long agent turns sometimes hang in the middle of streaming — the provider accepts the request, sends the first 50 tokens, then nothing for 30+ seconds while it thinks. Today the user sees a frozen bubble with no signal of what state the request is in; they have to read the status-bar spinner to guess. Add StreamingIndicator: a small inline status line that lives under the in-flight assistant bubble. It's a state machine driven by the message's text length and a 1Hz heartbeat: preparing turn started, no text yet (model warm-up pause, first few seconds) streaming text deltas arrived within the last 6s (the normal happy path) stalled no deltas for >6s while still streaming (the model is thinking OR the request dropped; the indicator gives the user a clear signal that something is happening, not that the app is hung) The error state (turn_done with e.err) is shown as a separate chip with a Retry button. The button is currently a no-op stub — the controller integration needs a ResumeFrom(offset) binding that re-submits the previous turn's prompt, which is a separate change. The affordance is rendered so the wiring is a one-line drop-in. A11y: role=status + aria-live=polite so screen readers announce the state transitions (a stalled bubble is exactly the kind of event a screen-reader user would want called out). prefers-reduced-motion disables the loader spin. The chip re-uses the existing soft surface / warn / err tokens, so the theme picker (PR-03) automatically styles it correctly. --- .../src/components/StreamingIndicator.tsx | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 desktop/frontend/src/components/StreamingIndicator.tsx diff --git a/desktop/frontend/src/components/StreamingIndicator.tsx b/desktop/frontend/src/components/StreamingIndicator.tsx new file mode 100644 index 000000000..73e3390b1 --- /dev/null +++ b/desktop/frontend/src/components/StreamingIndicator.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { Loader2, AlertCircle, RotateCcw } from "lucide-react"; + +// StreamingIndicator is the small "thinking / stalled / error" affordance +// shown beneath an in-flight assistant bubble. It's a state machine driven +// by the streaming prop on the message plus a wall-clock timer that +// escalates to "stalled" when no new chunk has arrived in 6 seconds. +// +// preparing — turn started, no text yet (a model warm-up pause) +// streaming — text deltas arrived within the stall window +// stalled — no deltas for >6s while still streaming (model is +// thinking, network is slow, or the request dropped) +// error — turn ended with `e.err` (Message already renders the +// error notice, this is just a small retry hint) +// +// The indicator is purely visual — it doesn't talk to the controller. The +// parent (Message) re-renders on every controller dispatch, so the timer +// is reset implicitly when item.text grows. We track the last text length +// we saw and the timestamp; a re-render with a longer text resets both. +// +// The Retry button is rendered in the `error` state only and is a no-op +// stub — wiring the actual retry requires a controller-level re-submit +// of the previous turn, which is a separate concern. The button is +// there to signal the affordance; the controller integration is a +// follow-up that adds a ResumeFrom(offset) binding. +export type StreamingPhase = "preparing" | "streaming" | "stalled" | "error"; + +export function StreamingIndicator({ + text, + streaming, + errored, + onRetry, +}: { + text: string; + streaming: boolean; + errored: boolean; + onRetry?: () => void; +}) { + // lastLen / lastTick are the "I saw progress recently" markers. A render + // where the text grew (lastLen < text.length) updates lastTick to now. + // The stalled check is "now - lastTick > STALL_MS". + const [lastLen, setLastLen] = useState(text.length); + const [lastTick, setLastTick] = useState(() => Date.now()); + const [, force] = useState(0); // re-render trigger for the stall timer + + useEffect(() => { + if (text.length > lastLen) { + setLastLen(text.length); + setLastTick(Date.now()); + } + }, [text.length, lastLen]); + + // Heartbeat re-render: once per second while we're streaming, the + // indicator re-evaluates stalled vs streaming. We don't use setInterval + // (it drifts and survives unmount); a 1Hz setTimeout chain self-cancels + // on unmount and on the streaming prop flipping false. + useEffect(() => { + if (!streaming) return; + const id = setTimeout(() => force((n) => n + 1), 1000); + return () => clearTimeout(id); + }, [streaming, lastTick, lastLen, text.length]); + + if (errored) { + return ( +
+ + Turn ended with an error + {onRetry && ( + + )} +
+ ); + } + if (!streaming) return null; + + const sinceTick = Date.now() - lastTick; + const phase: StreamingPhase = text.length === 0 ? "preparing" : sinceTick > 6000 ? "stalled" : "streaming"; + return ( +
+ + + {phase === "preparing" + ? "Preparing…" + : phase === "stalled" + ? "Still working… (no new tokens in a moment)" + : "Streaming…"} + +
+ ); +}