fix(core,web): retry transport and quota LLM errors, disable dead sessions on auth failure (#82)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c369e089a7
commit
3d3495528c
54 files changed
+2278
-161
No files matched your search
@@ -4,13 +4,13 @@
|
||||
* streamed as Markdown, tool cards, subagent cards, compaction banners, abort markers, and Task
|
||||
* stats lines. Items have a light entrance animation.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { formatMessageTime } from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { splitImageAttachments } from "../../lib/attachments";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import type { ChatItem, ReconnectItem } from "../../lib/omni/stream-model";
|
||||
import { Md } from "./md";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
@@ -89,6 +89,85 @@ function MessageMeta({
|
||||
);
|
||||
}
|
||||
|
||||
/** Countdown floor: waits shorter than this keep the plain waiting text (sub-second flashes of numbers are noise). */
|
||||
const COUNTDOWN_MIN_MS = 2000;
|
||||
|
||||
/**
|
||||
* Reconnect hint line. The live waiting state renders a COUNTDOWN to the next attempt when
|
||||
* the engine announced its planned wait (request_end.retry_in_ms ≥ 2s — with the
|
||||
* exponential ladder a wait can reach 30s, and a static "waiting" line reads as a hang),
|
||||
* plus two inline controls: "retry now" (skips the remaining backoff server-side; the line
|
||||
* flips to "retrying" when the request_begin arrives — no optimistic state beyond
|
||||
* disabling the buttons) and "give up" (the ordinary session abort; the engine's
|
||||
* abort-during-backoff path ends the turn and re-enables the composer). Anchored on the
|
||||
* CLIENT arrival time of the event (skew-free), ticking every 250ms with ceil'd whole
|
||||
* seconds; at zero it falls back to the plain waiting text, so a stale item can never tick
|
||||
* forever. History safety: replay delivers the following request_begin/abort immediately,
|
||||
* flipping the state, so neither the countdown nor the buttons render for replayed items;
|
||||
* the buttons are additionally main-session-only (a subagent's backoff belongs to the
|
||||
* child session, which the retry-now route does not target).
|
||||
*/
|
||||
function ReconnectLine({ item, ctx }: { item: ReconnectItem; ctx: StreamRenderContext }) {
|
||||
const state = item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting";
|
||||
const target =
|
||||
state === "waiting" &&
|
||||
item.plannedDelayMs !== undefined &&
|
||||
item.plannedDelayMs >= COUNTDOWN_MIN_MS &&
|
||||
item.arrivedAtMs !== undefined
|
||||
? item.arrivedAtMs + item.plannedDelayMs
|
||||
: null;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [acted, setActed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (target === null || Date.now() >= target) return;
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
if (Date.now() >= target) clearInterval(timer); // stop ticking once the wait has elapsed
|
||||
}, 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [target]);
|
||||
const remainingMs = target !== null ? target - now : 0;
|
||||
const live = target !== null && remainingMs > 0;
|
||||
const seconds = live ? Math.ceil(remainingMs / 1000) : undefined;
|
||||
const showControls =
|
||||
live && ctx.origin.length === 0 && (ctx.onRetryNow !== undefined || ctx.onGiveUp !== undefined);
|
||||
return (
|
||||
<p className="anim-msg my-1 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-amber-600 dark:text-amber-500">
|
||||
<span>{S.chat.reconnect(item.status, state, item.attempt, seconds)}</span>
|
||||
{showControls && (
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{ctx.onRetryNow && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={acted}
|
||||
onClick={() => {
|
||||
setActed(true);
|
||||
ctx.onRetryNow!();
|
||||
}}
|
||||
className="rounded border border-amber-300 px-1.5 py-0.5 text-[11px] font-medium text-amber-700 transition-colors duration-150 hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-700 dark:text-amber-400 dark:hover:bg-amber-950/40"
|
||||
>
|
||||
{S.chat.reconnectRetryNow}
|
||||
</button>
|
||||
)}
|
||||
{ctx.onGiveUp && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={acted}
|
||||
onClick={() => {
|
||||
setActed(true);
|
||||
ctx.onGiveUp!();
|
||||
}}
|
||||
className="rounded border border-gray-300 px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors duration-150 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.chat.reconnectGiveUp}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) {
|
||||
switch (item.kind) {
|
||||
case "user_text": {
|
||||
@@ -228,15 +307,7 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
||||
</p>
|
||||
);
|
||||
case "reconnect":
|
||||
return (
|
||||
<p className="anim-msg my-1 font-mono text-xs text-amber-600 dark:text-amber-500">
|
||||
{S.chat.reconnect(
|
||||
item.status,
|
||||
item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting",
|
||||
item.attempt,
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
return <ReconnectLine item={item} ctx={ctx} />;
|
||||
case "compaction":
|
||||
return <CompactionBanner item={item} />;
|
||||
case "task_stats":
|
||||
|
||||
Reference in new issue
Block a user