/** * AgentPanel — unified agent component with chat, CLI, and workspace modes. * * A self-contained panel with no layout opinions — drop it into a sidebar, * popover, dialog, full page, or any container. It fills its parent via * flex and min-h-0. * * Features: * - Chat mode: assistant-ui powered chat with tool calls * - CLI mode: embedded xterm.js terminal (dev mode only) * - Toggle between modes via header buttons * * Usage: * // In a sidebar *
* * // In a popover * * * // Full page chat surface * */ import { Tooltip as DesignSystemTooltip } from "@agent-native/toolkit/design-system"; import { IconMessageCircle, IconMessageDots, IconTerminal2, IconSettings, IconLayoutSidebarRightCollapse, IconLayoutGrid, IconCheck, IconPlus, IconX, IconDotsVertical, IconHistory, IconArrowsMaximize, IconArrowsMinimize, IconExternalLink, } from "@tabler/icons-react"; import React, { useState, useEffect, useRef, useCallback, useMemo, lazy, Suspense, startTransition, } from "react"; import type { AgentRun } from "../progress/types.js"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, } from "./components/ui/dropdown-menu.js"; import { normalizeTooltipText } from "./components/ui/tooltip.js"; import { ErrorReportActions } from "./ErrorReportActions.js"; import { FeedbackButton, resolveFeedbackUrl } from "./FeedbackButton.js"; import { RunsTrayMenuItem } from "./progress/RunsTray.js"; import { ShareButton } from "./sharing/ShareButton.js"; // Lazy-load the full assistant-ui chat stack (tiptap composer + react-markdown + // assistant-ui + zod block schemas) so it is NOT in the static import closure of // every page. The header/tab chrome renders immediately; chat streams in once the // chunk lands (~650-700 KB gzip saved from the critical path). const MultiTabAssistantChatLazy = lazy(() => import("./MultiTabAssistantChat.js").then((m) => ({ default: m.MultiTabAssistantChat, })), ); import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useLocation, useNavigate } from "react-router"; import { withBuilderUtmTrackingParams } from "../shared/builder-link-tracking.js"; import type { AgentChatSurfaceKind } from "./agent-chat-adapter.js"; import { consumeAgentSidebarUrlOpenOverride, dispatchAgentSidebarStateChange, getInitialAgentSidebarOpen, setAgentSidebarOpenPreference, subscribeAgentSidebarUrlChanges, SIDEBAR_STATE_CHANGE_EVENT, type AgentSidebarStateChangeDetail, } from "./agent-sidebar-state.js"; import { trackEvent } from "./analytics.js"; import { agentNativePath, appPath } from "./api-path.js"; import { assistantUiRecoverableRenderErrorKind } from "./assistant-ui-recovery.js"; import type { AssistantChatProps } from "./AssistantChat.js"; import { shouldParentFrameOwnAgentPanel } from "./builder-frame.js"; import { AGENT_CHAT_VIEW_TRANSITION_CLASS, getAgentChatViewTransitionStyle, } from "./chat-view-transition.js"; import { RealtimeVoiceModeProvider } from "./composer/index.js"; import { getFramePostMessageTargetOrigin, isTrustedFrameMessage, } from "./frame.js"; import { useT } from "./i18n.js"; import type { MultiTabAssistantChatHeaderProps, MultiTabAssistantChatProps, } from "./MultiTabAssistantChat.js"; import { recoverFromStaleChunkError } from "./route-chunk-recovery.js"; import { AgentNativeRouteWarmup } from "./route-warmup.js"; import { withBuilderConnectTrackingParams } from "./settings/useBuilderStatus.js"; import { useScreenRefreshKey } from "./use-db-sync.js"; import { useDevMode } from "./use-dev-mode.js"; import { cn } from "./utils.js"; // Lazy-load AgentTerminal to avoid bundling xterm.js when not needed const AgentTerminal = lazy(() => import("./terminal/index.js").then((m) => ({ default: m.AgentTerminal })), ); const AGENT_PANEL_PREPARE_EVENT = "agent-panel:prepare"; const AGENT_PANEL_SET_MODE_EVENT = "agent-panel:set-mode"; const AGENT_PANEL_OPEN_SETTINGS_EVENT = "agent-panel:open-settings"; function settingsRouteHashForSection(section?: string | null): string { const normalized = section?.replace(/^#/, "").toLowerCase() ?? ""; if (normalized === "voice") return "#voice"; if ( normalized.startsWith("secrets") || normalized.includes("api") || normalized === "integrations" || normalized === "email" || normalized === "browser" ) { return "#connections"; } if ( normalized === "account" || normalized === "workspace" || normalized === "workspace-settings" || normalized === "organization" || normalized === "org" || normalized === "hosting" || normalized === "database" || normalized === "uploads" || normalized === "auth" || normalized === "demo-mode" ) { return "#workspace"; } return "#agent"; } const AGENT_CHAT_RUNNING_EVENT = "agentNative.chatRunning"; function parentFrameTargetOrigin(): string { return getFramePostMessageTargetOrigin() ?? window.location.origin; } // Lazy-load ResourcesPanel to avoid bundling when not needed const ResourcesPanel = lazy(() => import("./resources/ResourcesPanel.js").then((m) => ({ default: m.ResourcesPanel, })), ); // Lazy-load SettingsPanel to avoid bundling when not needed const SettingsPanel = lazy(() => import("./settings/index.js").then((m) => ({ default: m.SettingsPanel, })), ); // Lazy-load OnboardingPanel — only pulled in when onboarding is active. const OnboardingPanel = lazy(() => import("./onboarding/OnboardingPanel.js").then((m) => ({ default: m.OnboardingPanel, })), ); // Lazy-load SetupButton — the header entry-point that re-opens the // onboarding panel after the user has dismissed it. const SetupButton = lazy(() => import("./onboarding/SetupButton.js").then((m) => ({ default: m.SetupButton, })), ); // The setup/onboarding checklist that used to appear above chat is disabled // for every app — setup (AI engine, image/video gen, asset storage, email, // GitHub, etc.) is surfaced in better places (the settings panel and the // per-feature setup affordances). Keep this off; do not re-enable globally. const SHOW_ONBOARDING = false; const CLI_STORAGE_KEY = "agent-native-cli-command"; const CLI_DEFAULT = "claude"; const EXEC_MODE_KEY = "agent-native-exec-mode"; type ExecMode = "build" | "plan"; type PanelMode = "chat" | "cli" | "resources" | "settings"; export function normalizeAgentPanelModeForSurface( mode: PanelMode, allowSettingsMode: boolean, ): PanelMode { return mode === "settings" && !allowSettingsMode ? "chat" : mode; } const AGENT_PANEL_FONT_FAMILY = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; const AGENT_PANEL_ROOT_STYLE = { fontFamily: AGENT_PANEL_FONT_FAMILY, fontSize: 13, lineHeight: 1.2, } satisfies React.CSSProperties; type AgentPanelStyle = React.CSSProperties & { "--agent-sidebar-background"?: string; "--agent-sidebar-closed-transform"?: string; "--agent-sidebar-inner-closed-transform"?: string; "--agent-sidebar-width"?: string; }; const AGENT_PANEL_HEADER_CLASS = "agent-native-shell-topbar relative z-[240] flex h-12 shrink-0 items-center justify-between gap-2"; const AGENT_PANEL_HEADER_STYLE = { paddingLeft: 8, paddingRight: 8, } satisfies React.CSSProperties; const AGENT_PANEL_CONTROL_STYLE = { fontSize: 12, lineHeight: 1, } satisfies React.CSSProperties; const ACTIVATE_KEYS = new Set(["Enter", " "]); interface AvailableCli { command: string; label: string; available: boolean; } function useAvailableClis() { const [clis, setClis] = useState([]); useEffect(() => { // Try to fetch available CLIs — endpoint is provided by the terminal plugin. // Returns 404 gracefully when the plugin isn't loaded. fetch(agentNativePath("/_agent-native/available-clis")) .then((r) => (r.ok ? r.json() : [])) .then((data) => setClis(Array.isArray(data) ? data : [])) .catch(() => {}); }, []); return clis; } function useCliSelection(keyPrefix: string) { const cliKey = `${CLI_STORAGE_KEY}${keyPrefix}`; const [selected, setSelected] = useState(CLI_DEFAULT); useEffect(() => { try { const saved = localStorage.getItem(cliKey); if (saved) setSelected(saved); } catch {} }, [cliKey]); const select = (cmd: string) => { setSelected(cmd); try { localStorage.setItem(cliKey, cmd); } catch {} }; return [selected, select] as const; } // ─── Settings panel components moved to ./settings/ ──────────────────────── function IconTooltip({ content, children, }: { content: string; children: React.ReactElement; }) { return ( ); } // AgentSettingsPopover and AgentsSection moved to ./settings/ // ─── ChatLoadingSkeleton ───────────────────────────────────────────────────── // Renders the sidebar header chrome immediately while the lazy assistant-ui // chunk is in flight. Matches the composer-area height so layout does not // shift when the real chat surface mounts. type ChatHeaderRenderer = ( props: MultiTabAssistantChatHeaderProps, ) => React.ReactNode; function ChatLoadingSkeleton({ renderHeader, centerComposerWhenEmpty = false, composerSlot, composerAreaClassName, composerLayoutVariant = "default", }: { renderHeader?: ChatHeaderRenderer; centerComposerWhenEmpty?: boolean; composerSlot?: React.ReactNode; composerAreaClassName?: string; composerLayoutVariant?: AssistantChatProps["composerLayoutVariant"]; }) { // Provide empty no-op implementations so renderHeader can render the real // tab/mode buttons without needing actual chat state. const noop = useCallback(() => {}, []); const noopStr = useCallback((_id: string) => {}, []); const stubProps: MultiTabAssistantChatHeaderProps = { tabs: [], activeTabId: "", activeTabMessageCount: 0, setActiveTabId: noopStr, addTab: noop, closeTab: noopStr, closeOtherTabs: noopStr, closeAllTabs: noop, clearActiveTab: noop, showHistory: false, tabCount: 0, toggleHistory: noop, }; if (centerComposerWhenEmpty) { return (
{renderHeader ? renderHeader(stubProps) : null}
Loading chat...
{composerSlot}
); } return (
{renderHeader ? renderHeader(stubProps) : null} {/* Composer-shaped placeholder keeps layout stable during chunk load */}
); } export function getAgentPanelChatTabGroups( tabs: MultiTabAssistantChatHeaderProps["tabs"], activeTabId: string, ) { const activeTab = tabs.find((t) => t.id === activeTabId); const focusParentId = activeTab?.parentThreadId || activeTabId; const childTabs = tabs.filter((t) => t.parentThreadId === focusParentId); const mainTabs = tabs.filter((t) => !t.parentThreadId); return { activeTab, childTabs, focusParentId, hasSubTabs: childTabs.length > 0, mainTabs, }; } export function shouldShowAgentPanelChatTabBar( tabs: MultiTabAssistantChatHeaderProps["tabs"], activeTabId: string, ) { const { hasSubTabs, mainTabs } = getAgentPanelChatTabGroups( tabs, activeTabId, ); return mainTabs.length > 1 || hasSubTabs; } export function shouldShowAgentPanelSidebarChatTabs( tabs: MultiTabAssistantChatHeaderProps["tabs"], ) { return tabs.filter((tab) => !tab.parentThreadId).length > 1; } export function shouldShowAgentPanelPageNewChatButton( tabs: MultiTabAssistantChatHeaderProps["tabs"], activeTabId: string, activeTabMessageCount: number, ) { if (!activeTabId) return false; if (activeTabMessageCount > 0) return true; const activeTab = tabs.find((tab) => tab.id === activeTabId); return activeTab?.status === "running" || activeTab?.status === "completed"; } export function shouldShowAgentPanelCliTabBar(cliTabs: string[]) { return cliTabs.length > 1; } export function shouldShowAgentPanelModeButtons(isSidebar: boolean) { return !isSidebar; } export function shouldShowAgentPanelFullViewAction( agentPageHref: string | undefined, mode: PanelMode, ) { return ( Boolean(agentPageHref) && (mode === "resources" || mode === "settings") ); } // ─── AgentPanel ───────────────────────────────────────────────────────────── export interface AgentPanelCodeAccess { /** Whether this surface can safely edit source and run shell commands. */ enabled: boolean; /** Heading shown when code access is unavailable. */ unavailableTitle?: string; /** Detail copy shown when code access is unavailable. */ unavailableDescription?: string; /** Optional CTA label for the unavailable state. */ unavailableCtaLabel?: string; /** Optional CTA URL for the unavailable state. */ unavailableCtaHref?: string; /** Optional secondary CTA label, usually for Builder cloud code changes. */ unavailableSecondaryCtaLabel?: string; /** Optional secondary CTA URL, usually the Builder connect URL. */ unavailableSecondaryCtaHref?: string; /** @deprecated Chat stays available when code access is unavailable. */ unavailableComposerPlaceholder?: string; } function useBuilderConnectUrl() { const [connectUrl, setConnectUrl] = useState(null); const [configured, setConfigured] = useState(false); useEffect(() => { let cancelled = false; // Track previous configured state so we only fanout the // `agent-engine:configured-changed` event on a real false→true // transition. Without this, every `/builder/status` response with // `configured: true` dispatched the event, our own `onConfigured` // listener caught it (because we both fire AND listen on the same // global), refresh fired again, and we'd loop forever. let lastConfigured = false; const refresh = () => { fetch(agentNativePath("/_agent-native/builder/status")) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (cancelled || !data) return; if (data.cliAuthUrl || data.connectUrl) { setConnectUrl(data.cliAuthUrl || data.connectUrl); } const nextConfigured = !!data.configured; setConfigured(nextConfigured); if (nextConfigured && !lastConfigured) { lastConfigured = true; // Tell other listeners (the agent panel's "Use Builder" CTA // lives in a different React tree than the connect-flow popup // poller, so a fresh status read here is the only thing that // flips its UI). Dispatch only on transition so listeners // that share this hook don't bounce the event back here. window.dispatchEvent( new CustomEvent("agent-engine:configured-changed", { detail: { source: "builder-status" }, }), ); } else if (!nextConfigured) { lastConfigured = false; } }) .catch(() => {}); }; refresh(); // The "Use Builder" CTA opens Builder in a `` tab // (not a popup), so the previous one-shot fetch never noticed the // connect succeeded when the user came back to the original tab. const onFocus = () => refresh(); const onVisibility = () => { if (document.visibilityState === "visible") refresh(); }; const onConfigured = (e: Event) => { // Ignore our own dispatch — refresh() already wrote the new state. // Other dispatchers (the connect-flow popup poller, an external // tab that completed connect, etc.) get the refresh they need. const detail = (e as CustomEvent).detail as | { source?: string } | undefined; if (detail?.source === "builder-status") return; refresh(); }; window.addEventListener("focus", onFocus); document.addEventListener("visibilitychange", onVisibility); window.addEventListener("agent-engine:configured-changed", onConfigured); let channel: BroadcastChannel | null = null; try { channel = new BroadcastChannel(`builder-connect:${window.location.host}`); channel.onmessage = (e: MessageEvent) => { const data = e.data as { type?: string } | undefined; if (data?.type === "builder-connect-success") refresh(); }; } catch { // BroadcastChannel missing — focus/visibility refresh still covers it. } const onMessage = (e: MessageEvent) => { if (e.origin !== window.location.origin) return; const data = e.data as { type?: string } | undefined; if (data?.type === "builder-connect-success") refresh(); }; window.addEventListener("message", onMessage); return () => { cancelled = true; window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener( "agent-engine:configured-changed", onConfigured, ); window.removeEventListener("message", onMessage); channel?.close(); }; }, []); return { connectUrl, configured }; } export interface AgentPanelProps extends Omit< AssistantChatProps, "onSwitchToCli" > { /** Initial mode. Default: "chat" */ defaultMode?: "chat" | "cli"; /** CSS class for the outer container */ className?: string; /** Inline styles for the outer container. */ style?: React.CSSProperties; /** Called when the user clicks the collapse button. If provided, a collapse button appears in the header. */ onCollapse?: () => void; /** Whether the panel is currently in fullscreen (Claude-style centered) mode. */ isFullscreen?: boolean; /** Called when the user clicks the maximize/minimize button. If provided, the button appears next to the collapse button. */ onToggleFullscreen?: () => void; /** URL of the app being developed (shown as "Open app in new tab" in settings). Set by frame. */ devAppUrl?: string; /** Namespace for localStorage keys — used to isolate chat state per app in the frame. */ storageKey?: string; /** Restore the previously active chat thread on mount. Default: true. */ restoreActiveThread?: boolean; /** Ambient resource context rendered as a composer chip. */ scope?: import("./use-chat-threads.js").ChatThreadScope | null; /** @deprecated Scope context now appears inside the composer. */ showScopeBadge?: MultiTabAssistantChatProps["showScopeBadge"]; /** Stable browser tab id used for tab-scoped app-state context. */ browserTabId?: string; /** Keep chat thread selection in URL state. */ threadUrlSync?: MultiTabAssistantChatProps["threadUrlSync"]; /** Optional notice rendered below the main header while Chat mode is active. */ chatNotice?: React.ReactNode; /** Show the chat thread tab row when the panel header is hidden. Default: true. */ showTabBar?: boolean; /** Show a compact New chat action in page chat when the main header is hidden. */ showPageNewChatButton?: boolean; /** Allow the sidebar settings view to render inside this panel. Default: true. */ allowSettingsMode?: boolean; /** Optional link shown in Resources and Settings modes for the full Agent page. */ agentPageHref?: string; /** Capability gate for source edits and CLI access. */ codeAccess?: AgentPanelCodeAccess; } function useClientOnly() { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted; } const DESKTOP_CODE_SURFACE_QUERY_PARAM = "_agentNativeDesktopCode"; const DESKTOP_CODE_SURFACE_SESSION_KEY = "agent-native:desktop-code-surface"; function isDesktopCodeSurfaceRequested(): boolean { if (typeof window === "undefined") return false; try { const requested = new URLSearchParams(window.location.search).get( DESKTOP_CODE_SURFACE_QUERY_PARAM, ) === "1"; if (requested) { window.sessionStorage.setItem(DESKTOP_CODE_SURFACE_SESSION_KEY, "1"); return true; } return ( window.sessionStorage.getItem(DESKTOP_CODE_SURFACE_SESSION_KEY) === "1" ); } catch { return false; } } export function resolveAgentPanelChatSurface( explicitSurface: AgentChatSurfaceKind | undefined, desktopCodeSurfaceRequested: boolean, ): AgentChatSurfaceKind { if (explicitSurface) return explicitSurface; return desktopCodeSurfaceRequested ? "desktop" : "app"; } function CodeAccessUnavailablePanel({ title, description, ctaLabel, ctaHref, secondaryCtaLabel = "Use Builder", secondaryCtaHref, compact = false, }: { title: string; description: string; ctaLabel: string; ctaHref?: string; secondaryCtaLabel?: string; secondaryCtaHref?: string; compact?: boolean; }) { const { connectUrl: builderConnectUrl } = useBuilderConnectUrl(); const builderHref = secondaryCtaHref ? withBuilderUtmTrackingParams(secondaryCtaHref, { campaign: "product", content: "code_access_unavailable_panel", }) : builderConnectUrl ? withBuilderConnectTrackingParams(builderConnectUrl, { source: "code_access_unavailable_panel", flow: "background_agent", }) : withBuilderUtmTrackingParams("https://builder.io", { content: "code_access_unavailable_panel", }); return ( ); } function AgentPanelInner({ defaultMode = "chat", className, style, apiUrl, emptyStateText, emptyStateAddon, suggestions, dynamicSuggestions, showHeader = true, onCollapse, isFullscreen, onToggleFullscreen, devAppUrl, storageKey, restoreActiveThread = true, scope, showScopeBadge, browserTabId, threadUrlSync, chatNotice, showTabBar = true, showPageNewChatButton = false, allowSettingsMode = true, agentPageHref, codeAccess, ...assistantChatProps }: AgentPanelProps) { const t = useT(); const feedbackEnabled = resolveFeedbackUrl() !== null; const navigate = useNavigate(); const mounted = useClientOnly(); const keyPrefix = storageKey ? `:${storageKey}` : ""; const execModeKey = `${EXEC_MODE_KEY}${keyPrefix}`; const panelModeKey = `agent-native-panel-mode${keyPrefix}`; const isMac = useMemo( () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent), [], ); const closeTabHint = isMac ? "\u2303W" : "Alt+W"; const closeAllTabsHint = isMac ? "\u2303\u2325W" : "Ctrl+Alt+W"; const toggleSidebarHint = isMac ? "\u2318\\" : "Ctrl+\\"; const [execMode, setExecMode] = useState(() => { try { const saved = localStorage.getItem(execModeKey); if (saved === "build" || saved === "plan") return saved; } catch {} return "build"; }); const switchExecMode = useCallback( (next: ExecMode) => { setExecMode(next); try { localStorage.setItem(execModeKey, next); } catch {} window.dispatchEvent( new CustomEvent("agent-panel:exec-mode-change", { detail: { mode: next }, }), ); }, [execModeKey], ); const [mode, setMode] = useState(() => { try { const saved = localStorage.getItem(panelModeKey); if ( saved === "chat" || saved === "cli" || saved === "resources" || saved === "settings" ) return normalizeAgentPanelModeForSurface(saved, allowSettingsMode); } catch {} return normalizeAgentPanelModeForSurface(defaultMode, allowSettingsMode); }); useEffect(() => { try { localStorage.setItem(panelModeKey, mode); } catch {} }, [mode, panelModeKey]); const [settingsSection, setSettingsSection] = useState<{ section: string | null; requestKey: number; }>({ section: null, requestKey: 0 }); const switchMode = useCallback( (m: PanelMode) => { startTransition(() => setMode(normalizeAgentPanelModeForSurface(m, allowSettingsMode)), ); }, [allowSettingsMode], ); useEffect(() => { const nextMode = normalizeAgentPanelModeForSurface(mode, allowSettingsMode); if (nextMode !== mode) switchMode(nextMode); }, [mode, allowSettingsMode, switchMode]); const openRunThread = useCallback( (threadId: string, run?: AgentRun) => { switchMode("chat"); const metadata = run?.metadata ?? {}; const parentThreadId = typeof metadata.parentThreadId === "string" ? metadata.parentThreadId.trim() : ""; const isAgentTeam = metadata.kind === "agent-team" || metadata.source === "agent-teams"; if (isAgentTeam && parentThreadId && parentThreadId !== threadId) { window.dispatchEvent( new CustomEvent("agent-task-open", { detail: { threadId, parentThreadId, description: typeof metadata.description === "string" ? metadata.description : run?.title || "", name: typeof metadata.name === "string" ? metadata.name : "", }, }), ); return; } window.dispatchEvent( new CustomEvent("agent-chat:open-thread", { detail: { threadId }, }), ); }, [switchMode], ); const activateOnKeyDown = useCallback( (activate: () => void) => (event: React.KeyboardEvent) => { if (!ACTIVATE_KEYS.has(event.key)) return; event.preventDefault(); activate(); }, [], ); // Listen for mode changes from the frame parent (via AgentSidebar) useEffect(() => { function handler(e: Event) { const detail = (e as CustomEvent).detail; if (detail?.mode) switchMode(detail.mode); } window.addEventListener(AGENT_PANEL_SET_MODE_EVENT, handler); return () => window.removeEventListener(AGENT_PANEL_SET_MODE_EVENT, handler); }, [switchMode]); // Open settings tab when requested (replaces the old popover open event) useEffect(() => { function handleOpenSettings(event: Event) { const section = (event as CustomEvent<{ section?: string }>).detail ?.section; setSettingsSection((prev) => ({ section: section ?? null, requestKey: prev.requestKey + 1, })); if (!allowSettingsMode) { navigate({ pathname: "/settings", hash: settingsRouteHashForSection(section), }); switchMode("chat"); return; } switchMode("settings"); } window.addEventListener( AGENT_PANEL_OPEN_SETTINGS_EVENT, handleOpenSettings, ); return () => window.removeEventListener( AGENT_PANEL_OPEN_SETTINGS_EVENT, handleOpenSettings, ); }, [allowSettingsMode, navigate, switchMode]); // CLI terminal tabs (ephemeral — not persisted to SQL) const [cliTabs, setCliTabs] = useState(["cli-1"]); const [activeCliTab, setActiveCliTab] = useState("cli-1"); const cliCounter = useRef(1); const addCliTab = useCallback(() => { const id = `cli-${++cliCounter.current}`; setCliTabs((prev) => [...prev, id]); setActiveCliTab(id); }, []); const closeCliTab = useCallback( (id: string) => { setCliTabs((prev) => { if (prev.length <= 1) { // Last tab — replace with a new one (acts as "clear") const newId = `cli-${++cliCounter.current}`; setActiveCliTab(newId); return [newId]; } const next = prev.filter((t) => t !== id); if (id === activeCliTab) { const idx = prev.indexOf(id); setActiveCliTab(next[Math.min(idx, next.length - 1)]); } return next; }); }, [activeCliTab], ); const closeOtherCliTabs = useCallback((id: string) => { setCliTabs([id]); setActiveCliTab(id); }, []); const closeAllCliTabs = useCallback(() => { const id = `cli-${++cliCounter.current}`; setCliTabs([id]); setActiveCliTab(id); }, []); // Tab close shortcuts. Avoid Cmd+W (browser/OS) and (on Windows) Ctrl+W. // Mac: Ctrl+W → close tab, Ctrl+Alt+W → close all // Windows/Linux: Alt+W → close tab, Ctrl+Alt+W → close all // Use e.code (physical key) — on Mac, Alt+W inserts ∑ and e.key isn't "w". useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.code !== "KeyW" || e.metaKey || e.shiftKey) return; const isCloseAll = e.ctrlKey && e.altKey; const isCloseOne = isMac ? e.ctrlKey && !e.altKey : e.altKey && !e.ctrlKey; if (!isCloseAll && !isCloseOne) return; e.preventDefault(); if (mode === "chat") { window.dispatchEvent( new CustomEvent( isCloseAll ? "agent-chat:close-all-tabs" : "agent-chat:close-current-tab", ), ); } else if (mode === "cli") { if (isCloseAll) closeAllCliTabs(); else if (activeCliTab) closeCliTab(activeCliTab); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [mode, activeCliTab, closeCliTab, closeAllCliTabs, isMac]); const availableClis = useAvailableClis(); const [selectedCli, selectCli] = useCliSelection(keyPrefix); const { isDevMode, canToggle, setDevMode } = useDevMode(apiUrl); const effectiveAgentChatSurface = resolveAgentPanelChatSurface( assistantChatProps.agentChatSurface, isDesktopCodeSurfaceRequested(), ); const isDevFrameChatSurface = effectiveAgentChatSurface === "dev-frame"; const isCodeEditingChatSurface = isDevFrameChatSurface || effectiveAgentChatSurface === "desktop"; const inferredCodeAccessEnabled = !isDevMode || isCodeEditingChatSurface; const codeAccessEnabled = codeAccess?.enabled ?? inferredCodeAccessEnabled; const codeUnavailableTitle = codeAccess?.unavailableTitle ?? t("agentPanel.openDesktopToEditCode"); const codeUnavailableDescription = codeAccess?.unavailableDescription ?? t("agentPanel.codeUnavailableDescription"); const codeUnavailableCtaLabel = codeAccess?.unavailableCtaLabel ?? t("agentPanel.downloadDesktop"); const codeUnavailableCtaHref = codeAccess?.unavailableCtaHref ?? "https://www.agent-native.com/download"; const codeUnavailableSecondaryCtaLabel = codeAccess?.unavailableSecondaryCtaLabel ?? t("agentPanel.useBuilder"); const codeUnavailableSecondaryCtaHref = codeAccess?.unavailableSecondaryCtaHref; const canUseCodeTools = isDevMode && codeAccessEnabled && isCodeEditingChatSurface; // Hide the CLI tab when embedded in the Builder.io frame — code editing // there happens via Builder, and the CLI panel only offers a Download // Desktop CTA, which adds clutter without value. const showCliMode = (isDevMode || !codeAccessEnabled) && isCodeEditingChatSurface; useEffect(() => { if (mode === "cli" && !showCliMode) switchMode("chat"); }, [mode, showCliMode, switchMode]); // Notify frame when dev mode changes — use both a local CustomEvent (for // when AgentPanel is rendered directly in the frame) AND postMessage (for // when AgentPanel is inside the iframe and needs to cross the boundary). const prevIsDevMode = useRef(isDevMode); useEffect(() => { if (prevIsDevMode.current !== isDevMode) { prevIsDevMode.current = isDevMode; window.dispatchEvent( new CustomEvent("agent-panel:dev-mode-change", { detail: { isDevMode }, }), ); // Cross iframe boundary to the frame parent if (window.parent !== window) { window.parent.postMessage( { type: "agentNative.devModeChange", data: { isDevMode } }, parentFrameTargetOrigin(), ); } } }, [isDevMode]); const isLocalhost = mounted && typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" || window.location.hostname === "::1"); const showDevToggle = canToggle && isLocalhost && isCodeEditingChatSurface; const renderModeButtons = useCallback( (activeMode: PanelMode) => (
switchMode("chat")} aria-label={t("agentPanel.chatMode")} className={cn( "flex items-center gap-1 rounded-md px-2 py-1 text-[12px] leading-none", activeMode === "chat" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} style={AGENT_PANEL_CONTROL_STYLE} > {t("agentPanel.chat")} } content={t("agentPanel.chatMode")} delayMs={200} /> {showCliMode && ( switchMode("cli")} aria-label={t("agentPanel.cliTerminalMode")} className={cn( "flex items-center gap-1 rounded-md px-2 py-1 text-[12px] leading-none", activeMode === "cli" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} style={AGENT_PANEL_CONTROL_STYLE} > {t("agentPanel.cli")} } content={ codeAccessEnabled ? t("agentPanel.cliTerminalMode") : codeUnavailableDescription } className="max-w-[260px]" delayMs={200} /> )} switchMode("resources")} aria-label={t("agentPanel.workspaceMode")} className={cn( "flex items-center gap-1 rounded-md px-2 py-1 text-[12px] leading-none", activeMode === "resources" ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} style={AGENT_PANEL_CONTROL_STYLE} > {t("agentPanel.workspace")} } content={t("agentPanel.workspaceMode")} delayMs={200} />
), [codeAccessEnabled, codeUnavailableDescription, showCliMode, t], ); const [headerMenuOpen, setHeaderMenuOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); const getChatThreadShareUrl = useCallback( (threadId: string) => { if (typeof window === "undefined") return undefined; if ( threadUrlSync && typeof threadUrlSync === "object" && typeof threadUrlSync.getPath === "function" ) { return new URL( appPath(threadUrlSync.getPath(threadId)), window.location.origin, ).toString(); } const url = new URL(window.location.href); url.searchParams.set("thread", threadId); return url.toString(); }, [threadUrlSync], ); const renderHeaderActions = useCallback( ({ activeChatSessionId, activeTabId, activeTabMessageCount, addTab, clearActiveTab, closeAllTabs, closeOtherTabs, closeTab, showHistory, tabs, toggleHistory, }: Pick< MultiTabAssistantChatHeaderProps, | "activeTabId" | "activeTabMessageCount" | "addTab" | "clearActiveTab" | "closeAllTabs" | "closeOtherTabs" | "closeTab" | "showHistory" | "tabs" | "toggleHistory" > & { activeChatSessionId?: string }) => (
{!onCollapse && SHOW_ONBOARDING && ( )} {!onCollapse && (() => { const activeTab = mode === "chat" && activeChatSessionId ? tabs.find((tab) => tab.id === activeChatSessionId) : undefined; if ( !activeTab || (activeTabMessageCount <= 0 && activeTab.status === "idle") ) { return null; } return ( ); })()} {feedbackEnabled ? (
), [ activeCliTab, addCliTab, allowSettingsMode, availableClis, canUseCodeTools, closeAllCliTabs, closeAllTabsHint, closeCliTab, closeOtherCliTabs, closeTabHint, feedbackOpen, feedbackEnabled, getChatThreadShareUrl, headerMenuOpen, isFullscreen, mode, agentPageHref, onCollapse, onToggleFullscreen, openRunThread, selectCli, selectedCli, storageKey, switchMode, t, ], ); const renderPageChatOverlay = useCallback( ({ activeTabId, activeTabMessageCount, addTab, tabs, }: MultiTabAssistantChatHeaderProps) => { if ( !shouldShowAgentPanelPageNewChatButton( tabs, activeTabId, activeTabMessageCount, ) ) { return null; } const activeTab = activeTabId ? tabs.find((tab) => tab.id === activeTabId) : undefined; const canShareActiveTab = activeTab && (activeTabMessageCount > 0 || activeTab.status !== "idle"); return ( <>