// 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 (
{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}

}
); } // ─── 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 ( ); })}
{ 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" />
{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.", })}

{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) && ( )} {detailsOpen && (
{info.runId &&
run: {info.runId}
} {info.errorCode &&
code: {info.errorCode}
} {info.details && (
                  {info.details}
                
)}
)}
{shouldShowBuilderReconnect && !builderReconnectResolved && ( )} {canRecover && ( <> )} {canRecover && isConnectionRecoveryError && ( )} {canRecover && onFork && !isConnectionRecoveryError && ( )}
{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.

{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 ? ( ) : ( )}
); }