Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4203,6 +4203,7 @@ export default function GatewayApp() {
error={transcriptError}
toolStatus={transcriptToolStatus}
toolStatusIsCompaction={transcriptToolStatusIsCompaction}
retryAttempts={displayedTranscript.retryAttempts}
isStreaming={transcriptBusy}
isLoading={transcriptHistoryLoading}
loadingTitle={historyDetailLoadingTitle}
Expand Down
23 changes: 21 additions & 2 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ import {
AssistantBubble,
AssistantStatus,
CompactingText,
RetryDetailsBlock,
VibingText,
} from "@/pages/chat/AssistantBubble";
import type { TranscriptRow } from "../lib/chat/transcript/types";
import type { RetryAttemptRecord, TranscriptRow } from "../lib/chat/transcript/types";

import type { GatewayTranscriptRound } from "../lib/chatUi";
import type { SectionId } from "../pages/settings/types";
Expand Down Expand Up @@ -93,6 +94,9 @@ type GatewayTranscriptProps = {
error?: string | null;
toolStatus?: string | null;
toolStatusIsCompaction?: boolean;
// Live run's stream-retry history; renders as an expandable details block
// under the live status (mirrors the desktop app).
retryAttempts?: readonly RetryAttemptRecord[];
isStreaming?: boolean;
isLoading?: boolean;
loadingTitle?: string;
Expand Down Expand Up @@ -1183,6 +1187,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
branchPendingMessageId?: string | null;
toolStatus?: string | null;
toolStatusIsCompaction: boolean;
retryAttempts?: readonly RetryAttemptRecord[];
readOnly?: boolean;
redactToolContent?: boolean;
}) {
Expand Down Expand Up @@ -1210,6 +1215,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
branchPendingMessageId,
toolStatus,
toolStatusIsCompaction,
retryAttempts,
readOnly = false,
redactToolContent = false,
} = props;
Expand Down Expand Up @@ -1552,7 +1558,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
>
<div className="flex w-full max-w-full items-start gap-3">
<AssistantAvatar />
<div className="min-w-0 flex-1 pt-1">
<div className="min-w-0 flex-1 space-y-2 pt-1">
{displayedToolStatusIsCompaction ? (
<div className="flex items-center py-1">
<CompactingText />
Expand Down Expand Up @@ -1584,6 +1590,9 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
<VibingText />
</div>
)}
{retryAttempts && retryAttempts.length > 0 ? (
<RetryDetailsBlock attempts={retryAttempts} />
) : null}
</div>
</div>
</article>
Expand Down Expand Up @@ -1647,6 +1656,14 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
redactToolContent={redactToolContent}
/>
{shouldShowLiveStatus ? <LiveStatusFooter status={liveStatusText} /> : null}
{isLatestLiveStreaming &&
!shouldShowPendingLiveBubble &&
retryAttempts &&
retryAttempts.length > 0 ? (
<div className="ml-9 pt-1">
<RetryDetailsBlock attempts={retryAttempts} />
</div>
) : null}
{!readOnly && !isLatestLiveStreaming ? (
<GatewayAssistantMessageActions
row={row}
Expand Down Expand Up @@ -1710,6 +1727,7 @@ export function GatewayTranscript({
error,
toolStatus,
toolStatusIsCompaction = false,
retryAttempts,
isStreaming = false,
isLoading = false,
loadingTitle,
Expand Down Expand Up @@ -1807,6 +1825,7 @@ export function GatewayTranscript({
branchPendingMessageId={branchPendingMessageId}
toolStatus={toolStatus}
toolStatusIsCompaction={toolStatusIsCompaction}
retryAttempts={retryAttempts}
readOnly={readOnly}
redactToolContent={redactToolContent}
/>
Expand Down
4 changes: 4 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.stopGeneration": "停止生成",
"chat.thinking": "思考中",
"chat.thinkingProcess": "思考过程",
"chat.retryDetailsToggle": "重试详情 ({count})",
"chat.retryAttemptLabel": "第 {attempt}/{maxAttempts} 次重试",
"chat.runtime.thinkingOn": "Thinking 已开启",
"chat.runtime.thinkingOff": "Thinking 已关闭",
"chat.runtime.thinkingUnavailable": "当前模型不支持 Thinking",
Expand Down Expand Up @@ -1967,6 +1969,8 @@ export const translations: Record<Locale, Record<string, string>> = {
"chat.stopGeneration": "Stop Generation",
"chat.thinking": "Thinking",
"chat.thinkingProcess": "Thinking Process",
"chat.retryDetailsToggle": "Retry details ({count})",
"chat.retryAttemptLabel": "Retry {attempt}/{maxAttempts}",
"chat.runtime.thinkingOn": "Thinking enabled",
"chat.runtime.thinkingOff": "Thinking disabled",
"chat.runtime.thinkingUnavailable": "Thinking is unavailable for this model",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const EMPTY_TRANSCRIPT: TranscriptSnapshot = {
activeRun: null,
toolStatus: null,
toolStatusIsCompaction: false,
retryAttempts: [],
foldRevision: 0,
revision: 0,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./turnReducer";
import type {
HistoryApplyMode,
RetryAttemptRecord,
TranscriptRow,
TranscriptSnapshot,
Turn,
Expand All @@ -46,7 +47,7 @@ import type {
// row keys, row objects and the DOM container are all unchanged, so the
// fold is a pure data transition and nothing remounts.

export type { TranscriptRow, TranscriptSnapshot, Turn } from "./types";
export type { RetryAttemptRecord, TranscriptRow, TranscriptSnapshot, Turn } from "./types";

export type TranscriptStore = {
getSnapshot(): TranscriptSnapshot;
Expand Down Expand Up @@ -83,6 +84,8 @@ export type TranscriptStore = {
flush(): void;
};

const EMPTY_RETRY_ATTEMPTS: readonly RetryAttemptRecord[] = [];

const EMPTY_SNAPSHOT: TranscriptSnapshot = {
rows: [],
liveStartIndex: -1,
Expand All @@ -91,10 +94,37 @@ const EMPTY_SNAPSHOT: TranscriptSnapshot = {
activeRun: null,
toolStatus: null,
toolStatusIsCompaction: false,
retryAttempts: EMPTY_RETRY_ATTEMPTS,
foldRevision: 0,
revision: 0,
};

// tool_status events without a retryAttempts array leave the current list
// untouched (null result); an array — including an empty one — replaces it.
function normalizeRetryAttempts(raw: unknown): RetryAttemptRecord[] | null {
if (!Array.isArray(raw)) {
return null;
}
const attempts: RetryAttemptRecord[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") {
continue;
}
const value = entry as Record<string, unknown>;
const attempt = typeof value.attempt === "number" && Number.isFinite(value.attempt);
const maxAttempts = typeof value.maxAttempts === "number" && Number.isFinite(value.maxAttempts);
if (!attempt || !maxAttempts) {
continue;
}
attempts.push({
attempt: value.attempt as number,
maxAttempts: value.maxAttempts as number,
errorMessage: typeof value.errorMessage === "string" ? value.errorMessage : "",
});
}
return attempts;
}

// Streaming-delta commit cadence while the tab is hidden and rAF is frozen.
const HIDDEN_COMMIT_DELAY_MS = 250;

Expand Down Expand Up @@ -148,6 +178,7 @@ export function createTranscriptStore(options?: {
let activeRun: StreamRunActivity | null = null;
let toolStatus: string | null = null;
let toolStatusIsCompaction = false;
let retryAttempts: readonly RetryAttemptRecord[] = EMPTY_RETRY_ATTEMPTS;
let foldRevision = 0;
let localTurnSeq = 0;
// Idempotency cursor: the highest log seq already applied. Re-subscribe
Expand Down Expand Up @@ -303,6 +334,7 @@ export function createTranscriptStore(options?: {
activeRun,
toolStatus,
toolStatusIsCompaction,
retryAttempts,
foldRevision,
revision: snapshot.revision + 1,
};
Expand Down Expand Up @@ -399,6 +431,14 @@ export function createTranscriptStore(options?: {
schedule(flush);
};

const setRetryAttempts = (next: readonly RetryAttemptRecord[], flush?: boolean) => {
if (retryAttempts.length === 0 && next.length === 0) {
return;
}
retryAttempts = next.length === 0 ? EMPTY_RETRY_ATTEMPTS : next;
schedule(flush);
};

const applyUserMessage = (event: ConversationStreamEvent, runId: string) => {
const payload = event as { message?: unknown; uploaded_files?: unknown };
const clientRequestId = readEventClientRequestId(event);
Expand Down Expand Up @@ -567,6 +607,7 @@ export function createTranscriptStore(options?: {
}
activeRun = null;
setToolStatus(null, false);
setRetryAttempts(EMPTY_RETRY_ATTEMPTS);
schedule(true);
};

Expand Down Expand Up @@ -663,6 +704,7 @@ export function createTranscriptStore(options?: {
updatedAt: Date.now(),
};
setToolStatus(null, false, true);
setRetryAttempts(EMPTY_RETRY_ATTEMPTS, true);
schedule(true);
return;
}
Expand Down Expand Up @@ -720,6 +762,12 @@ export function createTranscriptStore(options?: {
typeof status === "string" ? status : null,
(event as { isCompaction?: boolean }).isCompaction === true,
);
const nextRetryAttempts = normalizeRetryAttempts(
(event as { retryAttempts?: unknown }).retryAttempts,
);
if (nextRetryAttempts !== null) {
setRetryAttempts(nextRetryAttempts);
}
if (activeRun && activeRun.runId === runId) {
activeRun = { ...activeRun, toolStatus, toolStatusIsCompaction };
}
Expand Down Expand Up @@ -803,6 +851,7 @@ export function createTranscriptStore(options?: {
}
toolStatus = null;
toolStatusIsCompaction = false;
retryAttempts = EMPTY_RETRY_ATTEMPTS;
// Set the activity before the rebuild so the snapshot can target the
// optimistic pending turn by client_request_id (its user bubble then
// keeps its identity instead of a duplicate run turn appearing).
Expand Down
12 changes: 12 additions & 0 deletions crates/agent-gateway/web/src/lib/chat/transcript/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ import type { ChatEntry, GatewayTranscriptRound } from "@/lib/chatUi";

export type UserChatEntry = Extract<ChatEntry, { kind: "user" }>;

// One failed-and-retried network attempt of the live run's model request,
// mirrored from the desktop's stream-retry layer over the tool_status event.
// attempt/maxAttempts are retry ordinals (1..5 of 5), not total attempts.
export type RetryAttemptRecord = {
attempt: number;
maxAttempts: number;
errorMessage: string;
};

// A turn is one prompt/response exchange of the live stream: the user bubble
// (a single slot — a second user_message for the same run can only upsert it,
// never append a sibling) plus every assistant-side entry its run produced.
Expand Down Expand Up @@ -99,6 +108,9 @@ export type TranscriptSnapshot = {
activeRun: StreamRunActivity | null;
toolStatus: string | null;
toolStatusIsCompaction: boolean;
// Live run's stream-retry history (cleared at run boundaries and whenever
// the desktop starts a fresh network attempt).
retryAttempts: readonly RetryAttemptRecord[];
// Bumped whenever turns fold into the virtualized region.
foldRevision: number;
revision: number;
Expand Down
3 changes: 3 additions & 0 deletions crates/agent-gateway/web/src/lib/gatewayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export type ChatEvent = (
type: "tool_status";
status?: string | null;
isCompaction?: boolean;
// Stream-retry history of the live run: null/absent = unchanged, an
// array (possibly empty) replaces the current list.
retryAttempts?: { attempt: number; maxAttempts: number; errorMessage: string }[] | null;
round?: number;
conversation_id?: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AssistantAvatar } from "./assistant-bubble/AssistantAvatar";
import { RoundContent } from "./assistant-bubble/RoundContent";

export { AssistantAvatar } from "./assistant-bubble/AssistantAvatar";
export { RetryDetailsBlock } from "./assistant-bubble/RoundContent";
export { AssistantStatus, CompactingText, VibingText } from "./assistant-bubble/StatusText";

const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Lightbulb } from "../../../components/icons";
import { ChevronRight, Lightbulb, RefreshCw } from "../../../components/icons";
import { Markdown } from "../../../components/Markdown";
import { useLocale } from "../../../i18n";
import { normalizeLiveToolStatus, VIBING_STATUS } from "../../../lib/chat/chatPageHelpers";
import type { RetryAttemptRecord } from "../../../lib/chat/transcript/types";
import type { ToolTraceItem, UiRound } from "../../../lib/chat/uiMessages";
import { groupRoundBlocks, isBuiltinShareToolName } from "./assistantBubbleUtils";
import { HostedSearchGroupView } from "./HostedSearchGroupView";
Expand Down Expand Up @@ -77,6 +78,58 @@ const ThinkingBlock = memo(function ThinkingBlock({
);
});

// Expandable per-attempt stream-retry history for the live run, mirrored
// from the desktop app's RetryDetailsBlock (agent-gui RoundContent.tsx).
export const RetryDetailsBlock = memo(function RetryDetailsBlock({
attempts,
}: {
attempts: readonly RetryAttemptRecord[];
}) {
const { t } = useLocale();
const [isOpen, setIsOpen] = useState(false);

if (attempts.length === 0) return null;

return (
<div className="group/retry w-full">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
className="retry-details-toggle flex w-full cursor-pointer select-none items-center gap-2 py-1.5 text-left text-[calc(13px*var(--zone-font-scale,1))] font-normal text-muted-foreground/80 hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<span>{t("chat.retryDetailsToggle").replace("{count}", String(attempts.length))}</span>
<ChevronRight
className={`ml-auto h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ease-out ${isOpen ? "rotate-90" : ""}`}
/>
</button>
<LazyCollapse open={isOpen}>
{() => (
<div className="space-y-1 pb-1 pt-1.5">
{/* Index-keyed: attempt ordinals can repeat within one list (text
mode's tool-recovery loop restarts each wrapper's counter at 1)
and the list is append-only, so the index is the stable key. */}
{attempts.map((entry, index) => (
<div
key={`${index}-${entry.attempt}-${entry.maxAttempts}`}
className="rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5 text-[calc(12px*var(--zone-font-scale,1))] text-muted-foreground"
>
<div className="font-medium text-foreground/80">
{t("chat.retryAttemptLabel")
.replace("{attempt}", String(entry.attempt))
.replace("{maxAttempts}", String(entry.maxAttempts))}
</div>
<div className="whitespace-pre-wrap break-words">{entry.errorMessage}</div>
</div>
))}
</div>
)}
</LazyCollapse>
</div>
);
});

export const RoundContent = memo(function RoundContent(props: {
round: UiRound;
showUsage?: boolean;
Expand Down
Loading
Loading