// Owns: run-error metadata extractors, recovery helpers, RunErrorRecoveryCard,
// LoopLimitContinueCard, BuilderConnectCta, BuilderSetupCard, ApiKeyConnect,
// PlanModeCallout, and getLoopLimitMetadata / getRunErrorMetadata exports used
// by AssistantChatInner.
import {
IconLoader2,
IconCheck,
IconCopy,
IconX,
IconChevronDown,
IconExternalLink,
IconGitFork,
IconGauge,
IconSettings,
IconArrowRight,
IconAlertTriangle,
IconPlayerPlay,
IconRefresh,
IconPlus,
IconClipboardList,
} from "@tabler/icons-react";
import { useState, useEffect, useCallback, useRef } from "react";
import {
saveAgentEngineApiKey,
type AgentEngineProvider,
} from "../agent-engine-key.js";
import { agentNativePath } from "../api-path.js";
import { writeClipboardText } from "../clipboard.js";
import { useT } from "../i18n.js";
import { useBuilderConnectFlow } from "../settings/useBuilderStatus.js";
import { cn } from "../utils.js";
// ─── Type definitions ─────────────────────────────────────────────────────────
export type LoopLimitInfo = { maxIterations?: number };
export type RunErrorInfo = {
message: string;
details?: string;
errorCode?: string;
runId?: string;
recoverable?: boolean;
};
interface AgentLoopSettingsResponse {
maxIterations: number;
defaultMaxIterations: number;
minMaxIterations: number;
maxMaxIterations: number;
scope: "org" | "user" | "default";
source: "org" | "user" | "env" | "default";
canUpdate: boolean;
orgName?: string | null;
role?: string | null;
}
// ─── Metadata extractors ──────────────────────────────────────────────────────
export function getLoopLimitMetadata(message: unknown): LoopLimitInfo | null {
const meta = (message as { metadata?: unknown })?.metadata as
| {
custom?: { loopLimit?: LoopLimitInfo };
loopLimit?: LoopLimitInfo;
}
| undefined;
const loopLimit = meta?.custom?.loopLimit ?? meta?.loopLimit;
if (!loopLimit || typeof loopLimit !== "object") return null;
return {
...(typeof loopLimit.maxIterations === "number"
? { maxIterations: loopLimit.maxIterations }
: {}),
};
}
export function getRunErrorMetadata(message: unknown): RunErrorInfo | null {
const meta = (message as { metadata?: unknown })?.metadata as
| {
custom?: { runError?: RunErrorInfo; runId?: unknown };
runError?: RunErrorInfo;
runId?: unknown;
}
| undefined;
const runError = meta?.custom?.runError ?? meta?.runError;
if (!runError || typeof runError !== "object") return null;
const messageText =
typeof runError.message === "string" ? runError.message : "";
if (!messageText) return null;
const runId =
typeof runError.runId === "string"
? runError.runId
: typeof meta?.custom?.runId === "string"
? meta.custom.runId
: typeof meta?.runId === "string"
? meta.runId
: undefined;
return {
message: messageText,
...(typeof runError.details === "string"
? { details: runError.details }
: {}),
...(typeof runError.errorCode === "string"
? { errorCode: runError.errorCode }
: {}),
...(runId ? { runId } : {}),
...(runError.recoverable ? { recoverable: true } : {}),
};
}
export function getRequestModeMetadata(
message: unknown,
): "act" | "plan" | null {
const meta = (message as { metadata?: unknown })?.metadata as
| {
custom?: { requestMode?: unknown };
requestMode?: unknown;
}
| undefined;
const requestMode = meta?.custom?.requestMode ?? meta?.requestMode;
return requestMode === "act" || requestMode === "plan" ? requestMode : null;
}
// ─── Run error classifiers ────────────────────────────────────────────────────
function isBuilderReconnectRunError(info: RunErrorInfo): boolean {
const code = (info.errorCode ?? "").toLowerCase();
const message = info.message.toLowerCase();
const isAuthCode =
code === "authentication_error" ||
code === "unauthorized" ||
code === "http_401" ||
code === "http_403";
return (
code === "builder_auth_error" ||
message.includes("builder authentication failed") ||
(isAuthCode &&
(message.includes("invalid token") ||
message.includes("personal access token")))
);
}
function isProviderQueryRunError(info: RunErrorInfo): boolean {
const text = [info.errorCode, info.message, info.details]
.filter(Boolean)
.join("\n")
.toLowerCase();
return (
text.includes("bigquery") ||
text.includes("sql") ||
text.includes("query") ||
text.includes("schema") ||
text.includes("syntax") ||
text.includes("unknown column") ||
text.includes("unknown table") ||
text.includes("type mismatch")
);
}
function isConnectionRecoveryRunError(info: RunErrorInfo): boolean {
const code = (info.errorCode ?? "").toLowerCase();
const message = info.message.toLowerCase();
return (
code === "connection_error" ||
message.includes("connection kept failing") ||
message.includes("automatic recovery attempts")
);
}
// ─── BuilderConnectCta ────────────────────────────────────────────────────────
// Renders a single row with left-aligned copy and a right-aligned action.
// Click opens the Builder CLI-auth popup via the shared
// `useBuilderConnectFlow` hook (which owns the synchronous window.open,
// the 2s status poll, and the focus-refresh). On success the hook broadcasts
// a config-change event so the chat clears its local `missingApiKey` gate.
//
// Desktop note: when this component runs inside the Electron shell, the
// window.open call is intercepted by the main process's webview popup handler,
// which opens the flow in an Electron BrowserWindow that shares the webview's
// session. See packages/desktop-app/src/main/index.ts.
export function BuilderConnectCta({
variant = "primary",
onConnected,
}: {
variant?: "primary" | "compact";
onConnected?: () => void;
}) {
const { configured, orgName, connecting, error, start } =
useBuilderConnectFlow({
trackingSource: "assistant_chat_builder_cta",
onConnected,
});
if (variant === "compact") {
if (configured) {
return (
{orgName ? `Connected to ${orgName}` : "Connected"}
);
}
return (
start()}
disabled={connecting}
className="agent-builder-setup-card__builder-button inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-md bg-foreground px-3 text-[11px] font-medium text-background hover:opacity-90 disabled:cursor-wait disabled:opacity-60"
aria-busy={connecting}
>
{connecting ? (
<>
Waiting…
>
) : (
"Connect Builder.io"
)}
{error && (
{error}
)}
);
}
const containerClass =
"flex items-center gap-3 rounded-md border border-border px-3 py-3";
if (configured) {
return (
Builder.io
{orgName ? `Connected — ${orgName}` : "Connected"}
Connected
);
}
return (
Connect Builder.io
Free credits for LLM, hosting, and more — no API key needed
{error &&
{error}
}
start()}
disabled={connecting}
className="ml-auto inline-flex items-center gap-1 shrink-0 rounded-md bg-foreground px-3 py-1.5 text-[11px] font-medium no-underline text-background hover:opacity-90 disabled:opacity-60 disabled:cursor-wait"
aria-busy={connecting}
>
{connecting ? (
<>
Waiting…
>
) : (
<>
Connect
>
)}
);
}
// ─── ApiKeyConnect ────────────────────────────────────────────────────────────
const API_KEY_PROVIDERS: Array<{
value: AgentEngineProvider;
label: string;
placeholder: string;
}> = [
{ value: "anthropic", label: "Anthropic", placeholder: "sk-ant-…" },
{ value: "openai", label: "OpenAI", placeholder: "sk-…" },
];
export function ApiKeyConnect({ onConnected }: { onConnected?: () => void }) {
const t = useT();
const [provider, setProvider] = useState("anthropic");
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const active = API_KEY_PROVIDERS.find((p) => p.value === provider)!;
const handleSave = useCallback(async () => {
if (!apiKey.trim() || saving) return;
setSaving(true);
setError(null);
try {
await saveAgentEngineApiKey({ provider, apiKey });
setApiKey("");
onConnected?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the key.");
} finally {
setSaving(false);
}
}, [apiKey, onConnected, provider, saving]);
return (
{t("agentPanel.addOwnKeys", { defaultValue: "Add your own keys" })}
Stored securely for this app only.
{API_KEY_PROVIDERS.map((option) => {
const selected = option.value === provider;
return (
{
setProvider(option.value);
setError(null);
}}
className={cn(
"rounded px-2.5 py-1 text-[11px] font-medium transition-colors",
selected
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
);
})}
{
setApiKey(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSave();
}
}}
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background"
/>
{saving ? (
<>
Saving…
>
) : (
"Save"
)}
{error ? (
{error}
) : null}
);
}
// ─── BuilderSetupCard ─────────────────────────────────────────────────────────
export type BuilderSetupCardLayout = "default" | "sidebar";
export function BuilderSetupContent({
onConnected,
layout = "default",
}: {
onConnected?: () => void;
layout?: BuilderSetupCardLayout;
}) {
const t = useT();
const [keyOpen, setKeyOpen] = useState(false);
const sidebarLayout = layout === "sidebar";
return (
{t("agentPanel.connectAi", { defaultValue: "Connect AI" })}
{t("agentPanel.builderOrOwnKeys", {
defaultValue:
"Use Builder.io (free credits), or add your own provider keys.",
})}
setKeyOpen((open) => !open)}
className={cn(
"agent-builder-setup-card__key-button inline-flex shrink-0 items-center whitespace-nowrap rounded-md text-[11px] font-medium",
sidebarLayout
? "h-7 border-0 bg-transparent px-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
: "h-8 border border-border bg-background px-3 text-foreground hover:bg-accent",
)}
aria-expanded={keyOpen}
>
{t("agentPanel.addOwnKeys", {
defaultValue: "Add your own keys",
})}
{keyOpen ? (
) : null}
);
}
export function BuilderSetupCard({
onConnected,
bouncePulse,
fullWidth,
layout = "default",
}: {
onConnected?: () => void;
bouncePulse?: number;
fullWidth?: boolean;
layout?: BuilderSetupCardLayout;
}) {
const sidebarLayout = layout === "sidebar";
const cardRef = useRef(null);
// Replay the bounce keyframe each time bouncePulse increments. Toggling the
// class off-then-on (with a forced reflow) restarts the animation even when
// the value changes back-to-back.
useEffect(() => {
if (!bouncePulse) return;
const el = cardRef.current;
if (!el) return;
el.classList.remove("animate-bounce-once");
void el.offsetWidth;
el.classList.add("animate-bounce-once");
}, [bouncePulse]);
return (
);
}
// ─── RunErrorRecoveryCard ─────────────────────────────────────────────────────
export function RunErrorRecoveryCard({
info,
onContinue,
onRetry,
onFork,
onDismiss,
}: {
info: RunErrorInfo;
onContinue: () => void;
onRetry: () => void;
onFork?: () => void | boolean | Promise;
onDismiss: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">(
"idle",
);
const [forking, setForking] = useState(false);
const [forkError, setForkError] = useState(null);
const builderReconnect = useBuilderConnectFlow({
trackingSource: "assistant_chat_reconnect_error",
});
const canRecover = info.recoverable === true;
const shouldShowBuilderReconnect = isBuilderReconnectRunError(info);
const builderReconnectResolved =
shouldShowBuilderReconnect &&
builderReconnect.hasFetchedStatus &&
builderReconnect.configured;
const isQueryError = isProviderQueryRunError(info);
const isConnectionRecoveryError = isConnectionRecoveryRunError(info);
const copyLabel =
info.runId || info.errorCode || info.details ? "Copy debug" : "Copy";
const copyDetails = useCallback(() => {
const text = [
info.message,
info.errorCode ? `Code: ${info.errorCode}` : "",
info.runId ? `Run: ${info.runId}` : "",
info.details ? `Details:\n${info.details}` : "",
]
.filter(Boolean)
.join("\n\n");
void writeClipboardText(text)
.then((ok) => {
setCopyState(ok ? "copied" : "failed");
setTimeout(() => setCopyState("idle"), 1600);
})
.catch(() => {
setCopyState("failed");
setTimeout(() => setCopyState("idle"), 1600);
});
}, [info]);
const startNewChat = useCallback(() => {
window.dispatchEvent(new CustomEvent("agent-chat:new-chat"));
onDismiss();
}, [onDismiss]);
const handleFork = useCallback(async () => {
if (!onFork || forking) return;
setForking(true);
setForkError(null);
try {
const result = await onFork();
if (result === false) {
setForkError("Could not fork this chat. Try starting a new chat.");
}
} catch {
setForkError("Could not fork this chat. Try starting a new chat.");
} finally {
setForking(false);
}
}, [forking, onFork]);
useEffect(() => {
if (builderReconnectResolved) {
onDismiss();
}
}, [builderReconnectResolved, onDismiss]);
return (
{canRecover
? "The agent stopped before finishing"
: "The agent hit an error"}
{info.message}
{shouldShowBuilderReconnect && !builderReconnectResolved && (
The current Builder.io or model-provider credential was rejected.
Reconnect Builder.io, then retry this message.
)}
{isConnectionRecoveryError && (
If retry lands on the same error, start a new chat session and
continue from what already changed.
)}
{(info.runId || info.errorCode || info.details) && (
setDetailsOpen((v) => !v)}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
Details
)}
{detailsOpen && (
{info.runId &&
run: {info.runId}
}
{info.errorCode &&
code: {info.errorCode}
}
{info.details && (
{info.details}
)}
)}
{shouldShowBuilderReconnect && !builderReconnectResolved && (
builderReconnect.start()}
disabled={builderReconnect.connecting}
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-foreground px-3 text-xs font-medium text-background hover:opacity-90 disabled:cursor-wait disabled:opacity-70"
>
{builderReconnect.connecting ? (
) : (
)}
{builderReconnect.connecting
? "Connecting Builder.io"
: "Reconnect Builder.io"}
)}
{canRecover && (
<>
Continue
{isQueryError ? "Diagnose and retry" : "Retry"}
>
)}
{canRecover && isConnectionRecoveryError && (
New chat
)}
{canRecover && onFork && !isConnectionRecoveryError && (
{forking ? (
) : (
)}
{forking ? "Forking..." : "Fork chat"}
)}
{copyState === "copied" ? (
) : copyState === "failed" ? (
) : (
)}
{copyState === "copied"
? "Copied"
: copyState === "failed"
? "Copy failed"
: copyLabel}
{shouldShowBuilderReconnect && builderReconnect.error && (
{builderReconnect.error}
)}
{forkError && (
{forkError}
)}
);
}
// ─── LoopLimitContinueCard ────────────────────────────────────────────────────
export function LoopLimitContinueCard({
info,
onContinue,
}: {
info: LoopLimitInfo;
onContinue: () => void;
}) {
const [settings, setSettings] = useState(
null,
);
const [value, setValue] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
const load = useCallback(() => {
let cancelled = false;
fetch(agentNativePath("/_agent-native/agent-loop-settings"))
.then((r) => (r.ok ? r.json() : null))
.then((data: AgentLoopSettingsResponse | null) => {
if (cancelled || !data) return;
setSettings(data);
setValue(String(data.maxIterations));
})
.catch(() => {
if (!cancelled) setValue(String(info.maxIterations ?? ""));
});
return () => {
cancelled = true;
};
}, [info.maxIterations]);
useEffect(() => load(), [load]);
const currentLimit = settings?.maxIterations ?? info.maxIterations;
const numericValue = Number(value);
const hasPendingChange =
!!settings &&
settings.canUpdate &&
Number.isInteger(numericValue) &&
numericValue !== settings.maxIterations;
const scopeLabel =
settings?.scope === "org"
? settings.orgName
? `${settings.orgName} org`
: "org"
: "your account";
const saveLimit = useCallback(async (): Promise => {
if (!settings?.canUpdate) return false;
setSaving(true);
setSaved(false);
setError(null);
try {
const res = await fetch(
agentNativePath("/_agent-native/agent-loop-settings"),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxIterations: numericValue }),
},
);
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body?.error ?? `Save failed (${res.status})`);
}
setSettings(body as AgentLoopSettingsResponse);
setValue(String((body as AgentLoopSettingsResponse).maxIterations));
setSaved(true);
window.dispatchEvent(
new CustomEvent("agent-loop-settings:changed", { detail: body }),
);
setTimeout(() => setSaved(false), 2000);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
return false;
} finally {
setSaving(false);
}
}, [numericValue, settings?.canUpdate]);
const handleContinue = useCallback(async () => {
if (hasPendingChange) {
const ok = await saveLimit();
if (!ok) return;
}
onContinue();
}, [hasPendingChange, onContinue, saveLimit]);
const openSettings = useCallback(() => {
try {
window.location.hash = "agent-limits";
} catch {}
window.dispatchEvent(new CustomEvent("agent-panel:open-settings"));
}, []);
return (
Step limit reached
The agent used{" "}
{currentLimit
? `${currentLimit.toLocaleString()} steps`
: "all available steps"}
. Keep going in a fresh turn, or raise the {scopeLabel} limit first.
Max steps
{
setValue(e.target.value);
setError(null);
}}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring disabled:opacity-60"
/>
{saving ? (
) : saved ? (
) : (
"Save"
)}
Settings
{hasPendingChange ? "Save and keep going" : "Keep going"}
{settings && !settings.canUpdate && (
Only organization owners and admins can change this limit.
)}
{error &&
{error}
}
);
}
// ─── PlanModeCallout ──────────────────────────────────────────────────────────
//
// Renders inside the same width-constrained column as the composer (see
// `.agent-plan-mode-callout` in agent-native.css and the fullscreen rule
// injected by AgentPanel) so the pill hugs the composer's right edge in both
// narrow sidebar chats and wide/centered page layouts, instead of floating
// against the full pane width.
export function PlanModeCallout({
canImplementPlan,
onImplementPlan,
onSwitchToAct,
}: {
canImplementPlan: boolean;
onImplementPlan: () => void;
onSwitchToAct: () => void;
}) {
return (
{canImplementPlan ? "Plan ready" : "Plan mode"}
{canImplementPlan ? (
Implement
) : (
Act
)}
);
}