import { IconPlus, IconHistory, IconX } from "@tabler/icons-react"; import React, { useState, useRef, useEffect, useCallback } from "react"; import { DEFAULT_MODEL } from "../agent/default-model.js"; import { DEFAULT_REASONING_EFFORT, isReasoningEffort, resolveReasoningEffortSelection, type ReasoningEffort, } from "../shared/reasoning-effort.js"; import { AGENT_CHAT_CLEAR_CONTEXT_MESSAGE_TYPE, AGENT_CHAT_REMOVE_CONTEXT_MESSAGE_TYPE, AGENT_CHAT_SET_CONTEXT_MESSAGE_TYPE, appendAgentChatContextToMessage, claimAgentChatOpenRequest, claimAgentChatSubmit, drainBufferedAgentChatOpenRequests, drainBufferedAgentChatSubmits, getAgentChatContextState, isAgentChatSubmitCancelled, normalizeAgentChatContextItem, parseSubmitChatMessage, removeAgentChatContextItem, reportAgentChatSubmitResult, setAgentChatContextItem, type AgentChatContextItem, } from "./agent-chat.js"; import { agentNativePath, appPath } from "./api-path.js"; import { AssistantChat, type AssistantChatProps, type AssistantChatHandle, } from "./AssistantChat.js"; import { buildChatModelGroups, type EngineModelGroup, } from "./chat-model-groups.js"; import { ChatHistoryList, type ChatHistoryItem, type ChatHistorySection, } from "./chat/ChatHistoryList.js"; import { Popover, PopoverAnchor, PopoverContent, } from "./components/ui/popover.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./components/ui/tooltip.js"; import { isTrustedFrameMessage } from "./frame.js"; import { RunStuckBanner } from "./RunStuckBanner.js"; import { callAction } from "./use-action.js"; import { useChangeVersion } from "./use-change-version.js"; import { CHAT_MODEL_SELECTION_CHANGED_EVENT } from "./use-chat-models.js"; import { useChatThreads, type ChatThreadScope, type ChatThreadSummary, } from "./use-chat-threads.js"; import { cn } from "./utils.js"; interface ModelSelection { model: string; engine?: string; effort?: ReasoningEffort; } interface PendingSend { message: string; images?: string[]; submit: boolean; trackInRunsTray?: boolean; requestMode?: "act" | "plan"; /** Correlates with `AGENT_CHAT_SUBMIT_RESULT_EVENT` — see agent-chat.ts. */ submitMessageId?: string; } /** * A send queued until its target thread is ready. `threadId: null` targets the * first thread to become active (cold start); a concrete id is a thread whose * chat ref hasn't mounted yet. Drained by the flush effect. */ interface PendingDelivery { threadId: string | null; send: PendingSend; } /** The single path that hands a queued send to a mounted chat ref. */ function deliverPendingSend(ref: AssistantChatHandle, send: PendingSend): void { if (isAgentChatSubmitCancelled(send.submitMessageId)) return; if (!send.submit) { ref.prefillMessage(send.message); return; } if (send.trackInRunsTray || send.requestMode || send.submitMessageId) { ref.sendMessage(send.message, send.images, { ...(send.trackInRunsTray ? { trackInRunsTray: true } : {}), ...(send.requestMode ? { requestMode: send.requestMode } : {}), ...(send.submitMessageId ? { submitMessageId: send.submitMessageId } : {}), }); } else { ref.sendMessage(send.message, send.images); } } const MODEL_SELECTION_STORAGE_KEY = "agent-native:chat-models:selection"; function readStoredModelSelection(key: string): ModelSelection | undefined { if (typeof window === "undefined") return undefined; try { const raw = window.localStorage.getItem(key); if (!raw) return undefined; const parsed = JSON.parse(raw) as Partial; if (typeof parsed.model !== "string" || !parsed.model.trim()) { return undefined; } const selection: ModelSelection = { model: parsed.model, effort: resolveReasoningEffortSelection( parsed.model, isReasoningEffort(parsed.effort) ? parsed.effort : undefined, ), }; if (typeof parsed.engine === "string") selection.engine = parsed.engine; return selection; } catch { return undefined; } } function writeStoredModelSelection(key: string, selection: ModelSelection) { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, JSON.stringify(selection)); queueMicrotask(() => { window.dispatchEvent( new CustomEvent(CHAT_MODEL_SELECTION_CHANGED_EVENT, { detail: { key }, }), ); }); } catch {} } function resolveModelSelection( selection: ModelSelection | undefined, groups: EngineModelGroup[], ): ModelSelection | undefined { if (!selection?.model) return undefined; if (groups.length === 0) { return { model: selection.model, effort: resolveReasoningEffortSelection( selection.model, selection.effort, ), }; } const preferredGroup = groups.find( (group) => group.engine === selection.engine && group.models.includes(selection.model), ); const fallbackGroup = groups.find((group) => group.models.includes(selection.model), ); if (groups.length > 0 && !preferredGroup && !fallbackGroup) { return undefined; } const engine = preferredGroup?.engine ?? fallbackGroup?.engine ?? selection.engine; if (!engine && groups.length > 0) return undefined; const effort = resolveReasoningEffortSelection( selection.model, selection.effort, ); const resolved: ModelSelection = { model: selection.model, effort }; if (engine) resolved.engine = engine; return resolved; } // ─── Skeleton Loader ───────────────────────────────────────────────────────── function ChatSkeleton({ header, headerOnly = false, }: { header?: React.ReactNode; headerOnly?: boolean; }) { return (
{header ?? (
)} {!headerOnly && (
)}
); } // ─── Resource context ──────────────────────────────────────────────────────── function formatScopeType(type: string) { return type.replace(/[-_]+/g, " "); } // ─── History Popover ───────────────────────────────────────────────────────── function formatThreadTime(ts: number): string { const d = new Date(ts); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / 86400000); if (diffDays === 0) return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" }); return d.toLocaleDateString([], { month: "short", day: "numeric" }); } function HistoryPopover({ threads, openTabIds, activeThreadId, hasMoreThreads = false, isLoadingMoreThreads = false, loadError, onSelect, onClose, onLoadMore, onSearch, onTogglePin, onRename, }: { threads: ChatThreadSummary[]; openTabIds: Set; activeThreadId: string | null; hasMoreThreads?: boolean; isLoadingMoreThreads?: boolean; loadError?: string | null; onSelect: (id: string) => void; onClose: () => void; onLoadMore?: () => void; onSearch?: (query: string) => Promise; /** Presence enables the pin/unpin row action. Receives the thread's * current pinned state so the caller can flip it. */ onTogglePin?: (id: string, pinned: boolean) => void; /** Presence enables the inline rename row action. */ onRename?: (id: string, nextTitle: string) => void; }) { const [search, setSearch] = useState(""); const [searchResults, setSearchResults] = useState< ChatThreadSummary[] | null >(null); const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); const debounceRef = useRef>(undefined); useEffect(() => { inputRef.current?.focus(); }, []); // Debounced server-side search const searchIdRef = useRef(0); useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); const q = search.trim(); if (!q) { searchIdRef.current++; setSearchResults(null); setIsSearching(false); return; } setIsSearching(true); const id = ++searchIdRef.current; debounceRef.current = setTimeout(async () => { if (onSearch) { const results = await onSearch(q); if (id !== searchIdRef.current) return; setSearchResults(results); } else { // Fallback to client-side filtering setSearchResults(null); } setIsSearching(false); }, 250); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [search, onSearch]); // Hide empty threads from the history list — except the currently-active // one. The active thread always belongs in the list so the user can see // they're in it (the previous filter dropped a brand-new chat the user // had just opened, making them think their chat had vanished). const visibleThreads = threads.filter( (t) => t.messageCount > 0 || t.id === activeThreadId, ); const filtered = search.trim() ? (searchResults ?? visibleThreads).filter( (t) => t.messageCount > 0 || t.id === activeThreadId, ) : visibleThreads; // Pinned threads always float to the top of `threads` already (see // `sortThreadSummaries` in use-chat-threads.ts and the matching server // ORDER BY), but pulling them into their own labeled section — instead of // just relying on sort order within "All chats" — makes the pin affordance // visible at a glance, matching common chat-history UX. const pinnedThreads = filtered.filter((t) => t.pinnedAt != null); const unpinnedThreads = filtered.filter((t) => t.pinnedAt == null); const toHistoryItem = (thread: ChatThreadSummary): ChatHistoryItem => { const isActive = thread.id === activeThreadId; const title = thread.title || thread.preview || "Chat"; return { id: thread.id, title, titleText: title, subtitle: thread.preview && thread.title !== thread.preview ? thread.preview : undefined, timestamp: isActive ? "Active" : openTabIds.has(thread.id) ? "Open" : formatThreadTime(thread.updatedAt), pinned: thread.pinnedAt != null, }; }; const historySections: ChatHistorySection[] = [ ...(pinnedThreads.length > 0 ? [ { id: "pinned", label: "Pinned", items: pinnedThreads.map(toHistoryItem), }, ] : []), { id: "all", items: unpinnedThreads.map(toHistoryItem) }, ]; return ( !open && onClose()}> { event.preventDefault(); inputRef.current?.focus(); }} className="w-72 rounded-lg p-0" > { onSelect(id); onClose(); }} onTogglePin={ onTogglePin ? (id) => { const isPinned = threads.find((t) => t.id === id)?.pinnedAt != null; onTogglePin(id, !isPinned); } : undefined } onRename={onRename} searchValue={search} onSearchChange={setSearch} searchPlaceholder="Search chats..." searchInputRef={inputRef} loading={isSearching} loadingLabel="Searching..." error={loadError && !search.trim() ? loadError : undefined} emptyLabel="No chats yet" emptySearchLabel="No matching chats" footer={ !search.trim() && hasMoreThreads ? ( ) : undefined } /> ); } // ─── Help Popover ──────────────────────────────────────────────────────────── function HelpPopover({ onClose }: { onClose: () => void }) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [onClose]); const commands = [ { name: "/clear", description: "Start a new chat (keeps current chat in history)", }, { name: "/new", description: "Same as /clear" }, { name: "/history", description: "Browse all chats" }, { name: "/plan", description: "Switch to read-only planning" }, { name: "/act", description: "Switch back to acting" }, { name: "/help", description: "Show this list of commands" }, { name: "@", description: "Mention files, agents, or resources" }, ]; return ( <>
Available Commands
{commands.map((cmd) => (
{cmd.name}
{cmd.description}
))}
); } // ─── Types ────────────────────────────────────────────────────────────────── export interface ChatTab { id: string; label: string; status: "idle" | "running" | "completed"; /** If this tab is a sub-agent, the parent thread ID */ parentThreadId?: string; /** Short name for sub-agent tabs (e.g. "Research", "Draft email") */ subAgentName?: string; } type AgentTeamRunStatus = | "queued" | "running" | "paused" | "needs-approval" | "completed" | "errored" | "unknown"; interface AgentTeamRunSummary { title?: string; status?: AgentTeamRunStatus; sourceRecord?: { type?: string; threadId?: unknown; parentThreadId?: unknown; name?: unknown; }; metadata?: Record; } interface AgentTeamTabInfo { threadId: string; parentThreadId: string; name: string; status: AgentTeamRunStatus; } function readString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function requestModeFromExecMode( value: AssistantChatProps["execMode"], ): "act" | "plan" | undefined { if (value === "plan") return "plan"; if (value === "build") return "act"; return undefined; } function isActiveAgentTeamStatus(status?: AgentTeamRunStatus): boolean { return ( status === "queued" || status === "running" || status === "paused" || status === "needs-approval" ); } function chatTabStatusFromAgentTeamStatus( status?: AgentTeamRunStatus, ): ChatTab["status"] | undefined { if (!status) return undefined; return isActiveAgentTeamStatus(status) ? "running" : "completed"; } const STALE_THREAD_THRESHOLD_MS = 12 * 60 * 60 * 1000; const DEFAULT_THREAD_URL_PARAM = "thread"; const THREAD_URL_CHANGED_EVENT = "agent-chat:url-thread-changed"; const hasOwn = Object.prototype.hasOwnProperty; // The history patch is installed once and shared via a ref count so that // multiple synced chats (or a remount) don't restore a stale `pushState` // reference and silently drop a wrapper installed by another instance. let historyPatchRefCount = 0; let restoreHistoryPatch: (() => void) | null = null; function installHistoryThreadUrlPatch(): () => void { if (typeof window === "undefined") return () => {}; historyPatchRefCount += 1; if (historyPatchRefCount === 1) { const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; const dispatchUrlChange = () => { window.dispatchEvent(new Event(THREAD_URL_CHANGED_EVENT)); }; window.history.pushState = function pushState(...args) { const result = originalPushState.apply(this, args); dispatchUrlChange(); return result; }; window.history.replaceState = function replaceState(...args) { const result = originalReplaceState.apply(this, args); dispatchUrlChange(); return result; }; restoreHistoryPatch = () => { window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; }; } return () => { historyPatchRefCount = Math.max(0, historyPatchRefCount - 1); if (historyPatchRefCount === 0) { restoreHistoryPatch?.(); restoreHistoryPatch = null; } }; } export interface ChatThreadUrlSyncOptions { /** Query-string parameter used by the generic URL adapter. Default: `thread`. */ paramName?: string; /** * Route-owned thread id. Pass `null` for the create route and a string for * thread routes like `/chat/:threadId`. */ routeThreadId?: string | null; /** Build the URL path for a thread id, or for create mode when id is null. */ getPath?: (threadId: string | null) => string; /** Optional router navigation callback used with `getPath`. */ navigate?: (path: string, options?: { replace?: boolean }) => void; } function normalizeUrlThreadId(value: string | null | undefined): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed || null; } function resolveThreadUrlSync( value: MultiTabAssistantChatProps["threadUrlSync"], ): { enabled: boolean; paramName: string; routeThreadId?: string | null; getPath?: (threadId: string | null) => string; navigate?: (path: string, options?: { replace?: boolean }) => void; } { if (!value) return { enabled: false, paramName: DEFAULT_THREAD_URL_PARAM }; if (value === true) { return { enabled: true, paramName: DEFAULT_THREAD_URL_PARAM }; } return { enabled: true, paramName: value.paramName?.trim() || DEFAULT_THREAD_URL_PARAM, ...(hasOwn.call(value, "routeThreadId") ? { routeThreadId: normalizeUrlThreadId(value.routeThreadId) } : {}), ...(value.getPath ? { getPath: value.getPath } : {}), ...(value.navigate ? { navigate: value.navigate } : {}), }; } function readUrlThreadId(paramName: string): string | null { if (typeof window === "undefined") return null; try { const params = new URLSearchParams(window.location.search); return normalizeUrlThreadId( params.get(paramName) ?? (paramName === DEFAULT_THREAD_URL_PARAM ? params.get("threadId") : null), ); } catch { return null; } } function runToAgentTeamTabInfo( run: AgentTeamRunSummary, ): AgentTeamTabInfo | null { if (run.sourceRecord?.type && run.sourceRecord.type !== "agent-team-task") { return null; } const metadata = run.metadata ?? {}; const threadId = readString(run.sourceRecord?.threadId) || readString(metadata.threadId); const parentThreadId = readString(run.sourceRecord?.parentThreadId) || readString(metadata.parentThreadId); if (!threadId || !parentThreadId || threadId === parentThreadId) return null; const name = readString(run.sourceRecord?.name) || readString(metadata.name) || readString(run.title) || "Sub-agent"; return { threadId, parentThreadId, name, status: run.status ?? "unknown", }; } export interface MultiTabAssistantChatHeaderProps { tabs: ChatTab[]; activeTabId: string; activeTabMessageCount: number; setActiveTabId: (tabId: string) => void; addTab: () => void; closeTab: (tabId: string) => void; closeOtherTabs: (tabId: string) => void; closeAllTabs: () => void; clearActiveTab: () => void; /** Open the history popover */ showHistory?: boolean; toggleHistory?: () => void; /** Number of open tabs (useful for triggering scroll on tab count change) */ tabCount: number; } // ─── Component ────────────────────────────────────────────────────────────── export type MultiTabAssistantChatProps = Omit< AssistantChatProps, "tabId" | "threadId" > & { /** Show the tab bar. Default: true */ showTabBar?: boolean; /** Optional custom single-row header renderer */ renderHeader?: (props: MultiTabAssistantChatHeaderProps) => React.ReactNode; /** Optional page-level top-bar actions renderer for the active tab. */ renderOverlay?: (props: MultiTabAssistantChatHeaderProps) => React.ReactNode; /** Hide the chat content while keeping the header visible. Used when CLI/resources mode is active. */ contentHidden?: boolean; /** Namespace for localStorage keys — used to isolate chat state per app in the frame. */ storageKey?: string; /** Restore the previously active thread and open tabs from localStorage. */ restoreActiveThread?: boolean; /** Stable browser tab id used for tab-scoped app-state context. */ browserTabId?: string; /** * Keep the active thread in URL state. `true` uses the generic `?thread=id` * adapter; passing `routeThreadId` + `getPath` lets an app bind chats to * route params such as `/chat/:threadId`. */ threadUrlSync?: boolean | ChatThreadUrlSyncOptions; /** Ambient resource context to show as a composer chip. */ scope?: ChatThreadScope | null; /** @deprecated Scope context is now rendered in the composer. */ showScopeBadge?: boolean; }; export function MultiTabAssistantChat({ showTabBar = true, renderHeader, renderOverlay, contentHidden = false, apiUrl = agentNativePath("/_agent-native/agent-chat"), storageKey, restoreActiveThread = true, browserTabId, threadUrlSync = false, scope = null, ...props }: MultiTabAssistantChatProps) { const { enabled: threadUrlSyncEnabled, paramName: threadUrlParamName, routeThreadId, getPath: getThreadPath, navigate: navigateThreadUrl, } = resolveThreadUrlSync(threadUrlSync); const threadRouteControlsActiveThread = threadUrlSyncEnabled && threadUrlSync !== true && typeof threadUrlSync === "object" && hasOwn.call(threadUrlSync, "routeThreadId"); const [urlThreadId, setUrlThreadId] = useState(() => threadUrlSyncEnabled ? threadRouteControlsActiveThread ? (routeThreadId ?? readUrlThreadId(threadUrlParamName)) : readUrlThreadId(threadUrlParamName) : null, ); const [deepLinkedThreadId] = useState(() => threadUrlSyncEnabled ? null : readUrlThreadId(DEFAULT_THREAD_URL_PARAM), ); const [activeDeepLinkedThreadId, setActiveDeepLinkedThreadId] = useState(deepLinkedThreadId); const urlThreadIdRef = useRef(urlThreadId); urlThreadIdRef.current = urlThreadId; useEffect(() => { if (!threadUrlSyncEnabled || threadRouteControlsActiveThread) return; const update = () => setUrlThreadId(readUrlThreadId(threadUrlParamName)); const uninstallHistoryPatch = installHistoryThreadUrlPatch(); update(); window.addEventListener("popstate", update); window.addEventListener(THREAD_URL_CHANGED_EVENT, update); return () => { uninstallHistoryPatch(); window.removeEventListener("popstate", update); window.removeEventListener(THREAD_URL_CHANGED_EVENT, update); }; }, [ threadRouteControlsActiveThread, threadUrlParamName, threadUrlSyncEnabled, ]); useEffect(() => { if (!threadUrlSyncEnabled || !threadRouteControlsActiveThread) return; setUrlThreadId(routeThreadId ?? readUrlThreadId(threadUrlParamName)); }, [ routeThreadId, threadRouteControlsActiveThread, threadUrlParamName, threadUrlSyncEnabled, ]); useEffect(() => { if (threadUrlSyncEnabled) return; const update = () => setActiveDeepLinkedThreadId(readUrlThreadId(DEFAULT_THREAD_URL_PARAM)); const uninstallHistoryPatch = installHistoryThreadUrlPatch(); update(); window.addEventListener("popstate", update); window.addEventListener(THREAD_URL_CHANGED_EVENT, update); return () => { uninstallHistoryPatch(); window.removeEventListener("popstate", update); window.removeEventListener(THREAD_URL_CHANGED_EVENT, update); }; }, [threadUrlSyncEnabled]); const writeThreadUrl = useCallback( (threadId: string | null, options: { replace?: boolean } = {}): void => { if (!threadUrlSyncEnabled || typeof window === "undefined") return; try { const normalizedThreadId = normalizeUrlThreadId(threadId); let next: string; if (getThreadPath) { next = getThreadPath(normalizedThreadId); } else { const url = new URL(window.location.href); if (normalizedThreadId) { url.searchParams.set(threadUrlParamName, normalizedThreadId); } else { url.searchParams.delete(threadUrlParamName); } if (threadUrlParamName !== "threadId") { url.searchParams.delete("threadId"); } next = `${url.pathname}${url.search}${url.hash}`; } const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (next === current) { setUrlThreadId(normalizedThreadId); return; } if (getThreadPath && navigateThreadUrl) { navigateThreadUrl(next, { replace: options.replace === true }); setUrlThreadId(normalizedThreadId); return; } const method = options.replace ? "replaceState" : "pushState"; // `getThreadPath` returns a router-local path (no app basename). When we // fall back to the raw History API instead of a router navigate, resolve // the basename so deep-link reloads work in mounted apps. const historyTarget = getThreadPath ? appPath(next) : next; window.history[method](window.history.state, "", historyTarget); setUrlThreadId(normalizedThreadId); window.dispatchEvent(new Event(THREAD_URL_CHANGED_EVENT)); const popstate = typeof PopStateEvent === "function" ? new PopStateEvent("popstate", { state: window.history.state }) : new Event("popstate"); window.dispatchEvent(popstate); } catch {} }, [ getThreadPath, navigateThreadUrl, threadUrlParamName, threadUrlSyncEnabled, ], ); const { threads, activeThreadId, isLoading, createThread, switchThread: switchThreadState, forkThread, saveThreadData, generateTitle, searchThreads, loadMoreThreads, refreshThreads, hasMoreThreads, isLoadingMoreThreads, threadsLoadError, isNewThread, pinThread, renameThread, } = useChatThreads(apiUrl, storageKey, null, { restoreActiveThread, routeThreadId: threadUrlSyncEnabled ? urlThreadId : (activeDeepLinkedThreadId ?? undefined), }); const switchThread = useCallback( (threadId: string, options: { replace?: boolean } = {}) => { switchThreadState(threadId); writeThreadUrl(threadId, options); }, [switchThreadState, writeThreadUrl], ); // Namespace all localStorage keys by storageKey when provided (for per-app isolation in frame) const keyPrefix = storageKey ? `:${storageKey}` : ""; const modelSelectionKey = `${MODEL_SELECTION_STORAGE_KEY}${keyPrefix}`; // Track which tabs have been focused at least once (lazy mount for sub-agent tabs) const mountedTabsRef = useRef>(new Set()); const activeThreadIdRef = useRef(activeThreadId); activeThreadIdRef.current = activeThreadId; // Mark the active tab as mounted so it persists when switched away if (activeThreadId) mountedTabsRef.current.add(activeThreadId); const chatRefs = useRef>(new Map()); // Sends queued until their target thread is ready (see PendingDelivery). const pendingDeliveries = useRef([]); const pendingContextItems = useRef>( new Map(), ); const [runningThreads, setRunningThreads] = useState>(new Set()); const [showHistory, setShowHistory] = useState(false); const [pageOverlayScrolled, setPageOverlayScrolled] = useState(false); const newThreadIds = useRef>(new Set()); useEffect(() => { setPageOverlayScrolled(false); }, [activeThreadId]); const handlePageOverlayScroll = useCallback( (event: React.UIEvent) => { const target = event.target; if ( !renderOverlay || !(target instanceof HTMLElement) || !target.closest(".agent-chat-scroll") ) { return; } setPageOverlayScrolled(target.scrollTop > 1); }, [renderOverlay], ); // ─── Model state ───────────────────────────────────────────────────────── const [availableModels, setAvailableModels] = useState( [], ); const [modelListLoading, setModelListLoading] = useState(true); const [defaultModel, setDefaultModel] = useState(DEFAULT_MODEL); const threadModelRef = useRef< Map >(new Map()); const [persistedModelSelection, setPersistedModelSelection] = useState< ModelSelection | undefined >(() => readStoredModelSelection(modelSelectionKey)); const [modelSelectionVersion, setModelSelectionVersion] = useState(0); useEffect(() => { setPersistedModelSelection(readStoredModelSelection(modelSelectionKey)); }, [modelSelectionKey]); const bumpModelSelectionVersion = useCallback(() => { setModelSelectionVersion((version) => version + 1); }, []); useEffect(() => { if (typeof window === "undefined") return; const syncPersistedSelection = (event?: Event) => { const detail = (event as CustomEvent<{ key?: string }> | undefined) ?.detail; if (detail?.key && detail.key !== modelSelectionKey) return; const next = readStoredModelSelection(modelSelectionKey); if (!next) return; const activeThreadId = activeThreadIdRef.current; if (activeThreadId) { threadModelRef.current.set(activeThreadId, next); } setPersistedModelSelection(next); bumpModelSelectionVersion(); }; const handleStorage = (event: StorageEvent) => { if (event.key === modelSelectionKey) syncPersistedSelection(); }; window.addEventListener( CHAT_MODEL_SELECTION_CHANGED_EVENT, syncPersistedSelection, ); window.addEventListener("storage", handleStorage); return () => { window.removeEventListener( CHAT_MODEL_SELECTION_CHANGED_EVENT, syncPersistedSelection, ); window.removeEventListener("storage", handleStorage); }; }, [bumpModelSelectionVersion, modelSelectionKey]); const postMessageSubmissionsDisabled = props.composerDisabled === true; const setContextInTab = useCallback( ( threadId: string, item: AgentChatContextItem, options?: { focus?: boolean }, ) => { const ref = chatRefs.current.get(threadId); if (ref) { ref.setComposerContextItem(item, options); return; } const existing = pendingContextItems.current.get(threadId) ?? []; const index = existing.findIndex((current) => current.key === item.key); const next = index === -1 ? [...existing, item] : existing.map((current, currentIndex) => currentIndex === index ? item : current, ); pendingContextItems.current.set(threadId, next); }, [], ); const removeContextInTab = useCallback((threadId: string, key: string) => { const ref = chatRefs.current.get(threadId); if (ref) { ref.removeComposerContextItem(key); return; } const existing = pendingContextItems.current.get(threadId); if (!existing) return; const next = existing.filter((item) => item.key !== key); if (next.length === 0) { pendingContextItems.current.delete(threadId); } else { pendingContextItems.current.set(threadId, next); } }, []); const clearContextInTab = useCallback((threadId: string) => { const ref = chatRefs.current.get(threadId); if (ref) { ref.clearComposerContextItems(); return; } pendingContextItems.current.delete(threadId); }, []); const resolveThreadModelSelection = useCallback( (threadId: string) => resolveModelSelection( threadModelRef.current.get(threadId) ?? persistedModelSelection, availableModels, ), [availableModels, persistedModelSelection, modelSelectionVersion], ); const persistModelSelection = useCallback( (selection: ModelSelection) => { setPersistedModelSelection(selection); writeStoredModelSelection(modelSelectionKey, selection); }, [modelSelectionKey], ); const handleModelChange = useCallback( (model: string, engine: string) => { const threadId = activeThreadIdRef.current; if (!threadId) return; const existing = threadModelRef.current.get(threadId); const effort = resolveReasoningEffortSelection(model, existing?.effort); const selection = { model, engine, effort }; threadModelRef.current.set(threadId, selection); persistModelSelection(selection); bumpModelSelectionVersion(); }, [bumpModelSelectionVersion, persistModelSelection], ); const handleEffortChange = useCallback( (effort: ReasoningEffort) => { const threadId = activeThreadIdRef.current; if (!threadId) return; const existing = resolveThreadModelSelection(threadId); const model = existing?.model ?? defaultModel; const engine = existing?.engine ?? availableModels.find((group) => group.models.includes(model))?.engine ?? availableModels[0]?.engine; const selection: ModelSelection = { model, effort }; if (engine) selection.engine = engine; threadModelRef.current.set(threadId, selection); persistModelSelection(selection); bumpModelSelectionVersion(); }, [ availableModels, bumpModelSelectionVersion, defaultModel, persistModelSelection, resolveThreadModelSelection, ], ); const refreshEngines = useCallback(() => { setModelListLoading(true); Promise.all([ callAction("manage-agent-engine" as any, { action: "list" } as any).catch( () => null, ), fetch(agentNativePath("/_agent-native/env-status")) .then((r) => (r.ok ? r.json() : [])) .catch(() => []), fetch(agentNativePath("/_agent-native/builder/status")) .then((r) => (r.ok ? r.json() : null)) .catch(() => null), ]) .then(([enginesData, envKeys, builderStatus]) => { if (!enginesData?.engines) return; const configuredKeys = new Set( (envKeys as Array<{ key: string; configured: boolean }>) .filter((k) => k.configured) .map((k) => k.key), ); const builderConnected = builderStatus?.configured === true; const currentEngineName: string | undefined = enginesData.current?.engine; const currentModel: string | undefined = enginesData.current?.model; const groups = buildChatModelGroups({ engines: enginesData.engines, configuredKeys, builderConnected, currentEngineName, currentModel, }); setAvailableModels(groups); setDefaultModel(currentModel ?? DEFAULT_MODEL); }) .catch(() => {}) .finally(() => setModelListLoading(false)); }, []); useEffect(() => { refreshEngines(); window.addEventListener("agent-engine:configured-changed", refreshEngines); return () => window.removeEventListener( "agent-engine:configured-changed", refreshEngines, ); }, [refreshEngines]); // Parent-child thread mapping — persisted to localStorage. // Maps childThreadId → parentThreadId for sub-agent tabs. const PARENT_MAP_KEY = `agent-chat-parent-map${keyPrefix}`; const [parentMap, setParentMap] = useState>(() => { try { const saved = localStorage.getItem(PARENT_MAP_KEY); if (saved) return JSON.parse(saved); } catch {} return {}; }); const parentMapRef = useRef(parentMap); parentMapRef.current = parentMap; const dismissedSubAgentTabsRef = useRef>(new Set()); // Persist parent map to localStorage useEffect(() => { try { localStorage.setItem(PARENT_MAP_KEY, JSON.stringify(parentMap)); } catch {} }, [parentMap, PARENT_MAP_KEY]); // Sub-agent display names — persisted to localStorage. // Maps childThreadId → short name (e.g. "Research", "Draft email"). const SUB_AGENT_NAMES_KEY = `agent-chat-sub-agent-names${keyPrefix}`; const [subAgentNames, setSubAgentNames] = useState>( () => { try { const saved = localStorage.getItem(SUB_AGENT_NAMES_KEY); if (saved) return JSON.parse(saved); } catch {} return {}; }, ); const subAgentNamesRef = useRef(subAgentNames); subAgentNamesRef.current = subAgentNames; const [subAgentStatuses, setSubAgentStatuses] = useState< Record >({}); useEffect(() => { try { localStorage.setItem(SUB_AGENT_NAMES_KEY, JSON.stringify(subAgentNames)); } catch {} }, [subAgentNames, SUB_AGENT_NAMES_KEY]); // Open tabs — persisted to localStorage so they survive refresh. const OPEN_TABS_KEY = `agent-chat-open-tabs${keyPrefix}`; const [openTabIds, setOpenTabIds] = useState(() => { if (!restoreActiveThread && activeThreadId) { for (const id of [activeThreadId]) mountedTabsRef.current.add(id); return [activeThreadId]; } try { const saved = localStorage.getItem(OPEN_TABS_KEY); if (saved) { const parsed = JSON.parse(saved); if (Array.isArray(parsed) && parsed.length > 0) { // Mark restored tabs as mounted for (const id of parsed) mountedTabsRef.current.add(id); return parsed; } } } catch {} return []; }); const openTabIdsRef = useRef(openTabIds); openTabIdsRef.current = openTabIds; const initializedRef = useRef(false); const openTabsKeyRef = useRef(OPEN_TABS_KEY); useEffect(() => { const nextScope = scope; if (!nextScope) return; const type = formatScopeType(nextScope.type); const title = nextScope.label?.trim() || type.replace(/^./, (character) => character.toUpperCase()); const key = nextScope.contextKey || "agent-current-resource-context"; const marker = `Resource context: ${nextScope.type}:${nextScope.id}`; const existing = getAgentChatContextState().items.find( (item) => item.key === key, ); let ownsContextItem = false; if (!existing || existing.context.startsWith(marker)) { setAgentChatContextItem({ key, title, context: [ marker, `The user is currently viewing this ${type}.`, nextScope.label ? `Resource name: ${nextScope.label}` : "", `Resource id: ${nextScope.id}`, typeof window !== "undefined" ? `Current URL: ${window.location.pathname}${window.location.search}` : "", ] .filter(Boolean) .join("\n"), openSidebar: false, focus: false, }); ownsContextItem = true; } return () => { const current = getAgentChatContextState().items.find( (item) => item.key === key, ); if (ownsContextItem && current?.context.startsWith(marker)) { removeAgentChatContextItem(key); } }; }, [scope?.contextKey, scope?.id, scope?.label, scope?.type]); // Persist open tab IDs to localStorage (exclude sub-agent tabs — they're session-only) useEffect(() => { if (openTabsKeyRef.current !== OPEN_TABS_KEY) return; const mainTabs = openTabIds.filter((id) => !parentMap[id]); if (mainTabs.length > 0) { try { localStorage.setItem(OPEN_TABS_KEY, JSON.stringify(mainTabs)); } catch {} } }, [openTabIds, parentMap, OPEN_TABS_KEY]); // Initialize open tabs once threads load — validate saved tabs still exist useEffect(() => { if (initializedRef.current || !activeThreadId || threads.length === 0) return; initializedRef.current = true; const threadIds = new Set(threads.map((t) => t.id)); const threadMap = new Map(threads.map((t) => [t.id, t])); // Hide tabs that have had no activity for more than 12 hours. Stale tabs // are removed from the sidebar on load but remain accessible via history. const now = Date.now(); const isStale = (id: string) => { const thread = threadMap.get(id); return thread ? now - thread.updatedAt > STALE_THREAD_THRESHOLD_MS : false; }; // If the active thread is a sub-agent, switch to its parent or the most recent main thread if (parentMap[activeThreadId]) { const parent = parentMap[activeThreadId]; if (parent && threadIds.has(parent)) { switchThread(parent); } else { // Fall back to most recent main thread const mainThread = threads.find((t) => !parentMap[t.id]); if (mainThread) switchThread(mainThread.id); } } setOpenTabIds((prev) => { // Filter out tabs that no longer exist, sub-agent tabs, or stale tabs (>12h inactive) const valid = prev.filter( (id) => threadIds.has(id) && !parentMap[id] && !isStale(id), ); // Ensure active thread is included (only if it's not a sub-agent and not stale) if ( !parentMap[activeThreadId] && !valid.includes(activeThreadId) && !isStale(activeThreadId) ) { valid.push(activeThreadId); } return valid; }); // If active thread is stale, start fresh if (!parentMap[activeThreadId] && isStale(activeThreadId)) { createThread().then((id) => { if (id) writeThreadUrl(null); }); } }, [ activeThreadId, threads, parentMap, switchThread, createThread, writeThreadUrl, ]); // Ensure active thread is always in open tabs. // Use functional update to check inside the setter — avoids race with the // initialization effect that may have already added the ID in the same batch. // // Re-check after tab-list resets so the sidebar cannot end up with a live // active thread but no mounted chat. useEffect(() => { if (!activeThreadId || openTabIds.includes(activeThreadId)) return; if (parentMap[activeThreadId]) return; const activeThread = threads.find((thread) => thread.id === activeThreadId); if ( activeThread && Date.now() - activeThread.updatedAt > STALE_THREAD_THRESHOLD_MS ) { return; } setOpenTabIds((prev) => prev.includes(activeThreadId) ? prev : [...prev, activeThreadId], ); }, [activeThreadId, openTabIds, parentMap, threads]); // Ensure at least one tab is always open — auto-create if sidebar is empty. // Skipped when an active thread already exists (e.g. the hook generated an // optimistic id for a brand-new session); the activeThreadId effect above // adds it to openTabIds without spinning up a duplicate thread. const autoCreatingRef = useRef(false); useEffect(() => { if (isLoading || autoCreatingRef.current) return; if (openTabIds.length === 0 && !activeThreadId) { autoCreatingRef.current = true; createThread().then((id) => { autoCreatingRef.current = false; if (id) { newThreadIds.current.add(id); setOpenTabIds([id]); writeThreadUrl(null, { replace: true }); } }); } }, [isLoading, openTabIds, activeThreadId, createThread, writeThreadUrl]); useEffect(() => { let stopped = false; let timer: ReturnType | null = null; const runsUrl = `${apiUrl.replace(/\/$/, "")}/runs/list?goalId=agent-team`; async function hydrateAgentTeamTabs() { try { const res = await fetch(runsUrl); if (res.ok) { const data = (await res.json()) as { runs?: AgentTeamRunSummary[] }; const infos = Array.isArray(data.runs) ? data.runs .map(runToAgentTeamTabInfo) .filter((info): info is AgentTeamTabInfo => Boolean(info)) : []; setSubAgentStatuses((prev) => { let changed = false; const next: Record = {}; for (const info of infos) { next[info.threadId] = info.status; if (prev[info.threadId] !== info.status) changed = true; } if (Object.keys(prev).length !== Object.keys(next).length) { changed = true; } return changed ? next : prev; }); const openSet = new Set(openTabIdsRef.current); const candidates = infos.filter( (info) => !dismissedSubAgentTabsRef.current.has(info.threadId) && (openSet.has(info.parentThreadId) || openSet.has(info.threadId)), ); if (candidates.length > 0) { const shouldRefreshThreads = candidates.some( (info) => !openSet.has(info.threadId) && openSet.has(info.parentThreadId), ); const candidateParents = new Map( candidates.map((info) => [info.threadId, info.parentThreadId]), ); setParentMap((prev) => { let next = prev; for (const info of candidates) { if (next[info.threadId] === info.parentThreadId) continue; next = next === prev ? { ...prev, [info.threadId]: info.parentThreadId } : { ...next, [info.threadId]: info.parentThreadId }; } return next; }); setSubAgentNames((prev) => { let next = prev; for (const info of candidates) { if (!info.name || next[info.threadId] === info.name) continue; next = next === prev ? { ...prev, [info.threadId]: info.name } : { ...next, [info.threadId]: info.name }; } return next; }); setOpenTabIds((prev) => { let next = prev; for (const info of candidates) { if (next.includes(info.threadId)) continue; const parentIdx = next.indexOf(info.parentThreadId); if (parentIdx === -1) continue; if (next === prev) next = [...prev]; let insertIdx = parentIdx + 1; while (insertIdx < next.length) { const siblingParent = parentMapRef.current[next[insertIdx]] || candidateParents.get(next[insertIdx]); if (siblingParent !== info.parentThreadId) break; insertIdx++; } next.splice(insertIdx, 0, info.threadId); } return next; }); if (shouldRefreshThreads) { refreshThreads(); } } } } catch { // Best effort: task cards and manual history still work if this poll fails. } finally { if (!stopped) { timer = setTimeout(hydrateAgentTeamTabs, 3000); } } } hydrateAgentTeamTabs(); return () => { stopped = true; if (timer) clearTimeout(timer); }; }, [apiUrl, refreshThreads]); // Focus the composer when switching tabs useEffect(() => { if (!activeThreadId) return; // Small delay to ensure the tab is visible before focusing const t = setTimeout(() => { chatRefs.current.get(activeThreadId)?.focusComposer(); }, 50); return () => clearTimeout(t); }, [activeThreadId]); // Ref callback: scroll the active tab into view in the overflow container. // Uses getBoundingClientRect for reliable positioning regardless of offsetParent. // A margin keeps the active tab from sitting flush against either container // edge — at the right edge it was landing directly under the +/history/menu // buttons, which visually clipped the tab label. const activeTabRefCb = useCallback((el: HTMLElement | null) => { if (!el) return; const container = el.parentElement; if (!container) return; const MARGIN = 24; requestAnimationFrame(() => { const containerRect = container.getBoundingClientRect(); const tabRect = el.getBoundingClientRect(); if (tabRect.left < containerRect.left + MARGIN) { container.scrollLeft += tabRect.left - containerRect.left - MARGIN; } else if (tabRect.right > containerRect.right - MARGIN) { container.scrollLeft += tabRect.right - containerRect.right + MARGIN; } }); }, []); const [messageCounts, setMessageCounts] = useState>( () => Object.fromEntries(threads.map((t) => [t.id, t.messageCount ?? 0])), ); // Sync message counts from threads when they load useEffect(() => { if (threads.length > 0) { setMessageCounts((prev) => { const next = { ...prev }; for (const t of threads) { if (!(t.id in next)) { next[t.id] = t.messageCount ?? 0; } } return next; }); } }, [threads]); // Listen for builder.submitChat postMessages useEffect(() => { const handler = (event: MessageEvent) => { if (!isTrustedFrameMessage(event)) return; if (event.data?.type === AGENT_CHAT_SET_CONTEXT_MESSAGE_TYPE) { const item = normalizeAgentChatContextItem(event.data.data); if (!item) return; const openSidebar = event.data.data?.openSidebar as boolean | undefined; if (openSidebar !== false) { window.dispatchEvent(new CustomEvent("agent-panel:open")); } if (postMessageSubmissionsDisabled) return; const currentTabId = activeThreadIdRef.current; if (!currentTabId) return; // Focus defaults to true; a caller opts out with `focus: false` for // passive context (e.g. a canvas selection) so staging never steals // focus from an in-progress inline editor. const focus = (event.data.data?.focus as boolean | undefined) !== false; setContextInTab(currentTabId, item, { focus }); return; } if (event.data?.type === AGENT_CHAT_REMOVE_CONTEXT_MESSAGE_TYPE) { const key = typeof event.data.data?.key === "string" ? event.data.data.key.trim() : ""; if (!key) return; const openSidebar = event.data.data?.openSidebar as boolean | undefined; if (openSidebar === true) { window.dispatchEvent(new CustomEvent("agent-panel:open")); } if (postMessageSubmissionsDisabled) return; const currentTabId = activeThreadIdRef.current; if (!currentTabId) return; removeContextInTab(currentTabId, key); return; } if (event.data?.type === AGENT_CHAT_CLEAR_CONTEXT_MESSAGE_TYPE) { const openSidebar = event.data.data?.openSidebar as boolean | undefined; if (openSidebar === true) { window.dispatchEvent(new CustomEvent("agent-panel:open")); } if (postMessageSubmissionsDisabled) return; const currentTabId = activeThreadIdRef.current; if (!currentTabId) return; clearContextInTab(currentTabId); return; } const parsed = parseSubmitChatMessage(event); if (!parsed) return; // Dedup the live post against the cold-start replay; first one wins. if (!claimAgentChatSubmit(parsed.submitMessageId)) return; const { message, context, openSidebar, model, effort, newTab, background, submit, images, submitMessageId, } = parsed; const requestedTabId = parsed.tabId; const requestMode = parsed.requestMode ?? requestModeFromExecMode(props.execMode); // Make sure the sidebar is visible to show the response, unless the // caller explicitly opted out or it's a background send. if (openSidebar !== false && !background) { window.dispatchEvent(new CustomEvent("agent-panel:open")); } if (postMessageSubmissionsDisabled) { reportAgentChatSubmitResult( submitMessageId, false, "composer-disabled", ); return; } // Plan mode is sent as request metadata by the chat adapter. Keep the // user-visible message clean so mode instructions never enter history. const fullMessage = context ? appendAgentChatContextToMessage(message, context) : message; const send: PendingSend = { message: fullMessage, images, submit, ...(background ? { trackInRunsTray: true } : {}), ...(requestMode ? { requestMode } : {}), ...(submitMessageId ? { submitMessageId } : {}), }; const sendToTab = (threadId: string) => { if (isAgentChatSubmitCancelled(submitMessageId)) return; // If a model override was specified, apply it only if we recognize it if (model) { const matchedGroup = availableModels.find((g) => g.models.includes(model), ); if (matchedGroup) { const selectedEffort = resolveReasoningEffortSelection( model, isReasoningEffort(effort) ? effort : undefined, ); threadModelRef.current.set(threadId, { model, engine: matchedGroup.engine, effort: selectedEffort, }); bumpModelSelectionVersion(); } } const ref = chatRefs.current.get(threadId); if (ref) { deliverPendingSend(ref, send); } else { pendingDeliveries.current.push({ threadId, send }); } }; if (newTab) { const previousTabId = activeThreadIdRef.current; createThread(requestedTabId) .then((newId) => { if (isAgentChatSubmitCancelled(submitMessageId)) return; if (!newId) { reportAgentChatSubmitResult( submitMessageId, false, "thread-create-failed", ); return; } newThreadIds.current.add(newId); if (background) { mountedTabsRef.current.add(newId); } setOpenTabIds((prev) => prev.includes(newId) ? prev : [...prev, newId], ); if (!background) { writeThreadUrl(newId); } sendToTab(newId); if (background && previousTabId) { switchThreadState(previousTabId); } }) .catch(() => { reportAgentChatSubmitResult( submitMessageId, false, "thread-create-failed", ); }); } else { const currentTabId = activeThreadIdRef.current; if (currentTabId) { sendToTab(currentTabId); } else { // Cold start: no thread yet. Queue for the first active thread (the // bootstrap effect creates it) rather than racing a second create. pendingDeliveries.current.push({ threadId: null, send }); } } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [ availableModels, bumpModelSelectionVersion, clearContextInTab, createThread, postMessageSubmissionsDisabled, props.execMode, removeContextInTab, setContextInTab, switchThread, switchThreadState, writeThreadUrl, ]); // Replay submits posted before this lazy panel's listener attached. Dedup in // the handler keeps a live + replayed message single. useEffect(() => { const buffered = drainBufferedAgentChatSubmits(); for (const data of buffered) { window.dispatchEvent( new MessageEvent("message", { data: { type: "agentNative.submitChat", data }, origin: window.location.origin, }), ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Flush queued context items and sends once their thread's ref is mounted // (re-runs on ref mount via openTabIds and on cold-start target via // activeThreadId). useEffect(() => { for (const [tabId, items] of pendingContextItems.current) { const ref = chatRefs.current.get(tabId); if (!ref) continue; for (const item of items) ref.setComposerContextItem(item); pendingContextItems.current.delete(tabId); } if (pendingDeliveries.current.length === 0) return; const active = activeThreadIdRef.current; const remaining: PendingDelivery[] = []; for (const delivery of pendingDeliveries.current) { if (isAgentChatSubmitCancelled(delivery.send.submitMessageId)) continue; const threadId = delivery.threadId ?? active ?? null; const ref = threadId ? chatRefs.current.get(threadId) : null; if (threadId && ref) { const { send } = delivery; setTimeout(() => deliverPendingSend(ref, send), 50); } else { // Not ready — keep it, pinning the resolved threadId once known. remaining.push(threadId ? { threadId, send: delivery.send } : delivery); } } pendingDeliveries.current = remaining; }, [openTabIds, activeThreadId]); // Listen for chatRunning completion events useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail) return; const { isRunning, tabId } = detail; if (!tabId) return; setRunningThreads((prev) => { const next = new Set(prev); if (isRunning) { next.add(tabId); } else { next.delete(tabId); } return next; }); }; window.addEventListener("agentNative.chatRunning", handler); return () => window.removeEventListener("agentNative.chatRunning", handler); }, []); const addTab = useCallback(async () => { const id = await createThread(); if (id) { newThreadIds.current.add(id); writeThreadUrl(null); } return id; }, [createThread, writeThreadUrl]); const cleanupClosedTab = useCallback((tabId: string) => { if (parentMapRef.current[tabId]) { dismissedSubAgentTabsRef.current.add(tabId); } chatRefs.current.delete(tabId); pendingDeliveries.current = pendingDeliveries.current.filter( (d) => d.threadId !== tabId, ); pendingContextItems.current.delete(tabId); newThreadIds.current.delete(tabId); threadModelRef.current.delete(tabId); // Clean up parent map and sub-agent names setParentMap((prev) => { if (!(tabId in prev)) return prev; const { [tabId]: _, ...rest } = prev; return rest; }); setSubAgentNames((prev) => { if (!(tabId in prev)) return prev; const { [tabId]: _, ...rest } = prev; return rest; }); setSubAgentStatuses((prev) => { if (!(tabId in prev)) return prev; const { [tabId]: _, ...rest } = prev; return rest; }); }, []); const closeTab = useCallback( (tabId: string) => { setOpenTabIds((prev) => { if (prev.length <= 1) { // Last tab — create a new one and replace the old tab atomically createThread().then((newId) => { if (newId) { newThreadIds.current.add(newId); setOpenTabIds([newId]); writeThreadUrl(null); } }); return prev; // Keep old tab until new one is ready } const next = prev.filter((id) => id !== tabId); if (tabId === activeThreadIdRef.current && next.length > 0) { const idx = prev.indexOf(tabId); switchThread(next[Math.min(idx, next.length - 1)]); } return next; }); cleanupClosedTab(tabId); }, [switchThread, createThread, cleanupClosedTab, writeThreadUrl], ); const closeOtherTabs = useCallback( (tabId: string) => { for (const id of openTabIdsRef.current) { if (id !== tabId && parentMapRef.current[id]) { dismissedSubAgentTabsRef.current.add(id); } } setOpenTabIds([tabId]); if (activeThreadIdRef.current !== tabId) { switchThread(tabId); } // Clean up refs for closed tabs for (const key of chatRefs.current.keys()) { if (key !== tabId) { if (parentMapRef.current[key]) { dismissedSubAgentTabsRef.current.add(key); } chatRefs.current.delete(key); pendingDeliveries.current = pendingDeliveries.current.filter( (d) => d.threadId !== key, ); pendingContextItems.current.delete(key); newThreadIds.current.delete(key); threadModelRef.current.delete(key); } } // Clean up parent map and sub-agent names — only keep entries for the surviving tab setParentMap((prev) => { if (tabId in prev) return { [tabId]: prev[tabId] }; return {}; }); setSubAgentNames((prev) => { if (tabId in prev) return { [tabId]: prev[tabId] }; return {}; }); setSubAgentStatuses((prev) => { if (tabId in prev) return { [tabId]: prev[tabId] }; return {}; }); }, [switchThread], ); const closeAllTabs = useCallback(async () => { const id = await createThread(); if (id) { newThreadIds.current.add(id); setOpenTabIds([id]); switchThreadState(id); writeThreadUrl(null); dismissedSubAgentTabsRef.current.clear(); // Clean up all old refs chatRefs.current.clear(); pendingDeliveries.current = []; pendingContextItems.current.clear(); threadModelRef.current.clear(); setParentMap({}); setSubAgentNames({}); setSubAgentStatuses({}); } }, [createThread, switchThreadState, writeThreadUrl]); // Keyboard shortcuts dispatched from AgentPanel based on the active mode useEffect(() => { const handleCloseCurrent = () => { const id = activeThreadIdRef.current; if (id) closeTab(id); }; const handleCloseAll = () => { void closeAllTabs(); }; const handleNewChat = () => { void addTab(); }; window.addEventListener("agent-chat:close-current-tab", handleCloseCurrent); window.addEventListener("agent-chat:close-all-tabs", handleCloseAll); window.addEventListener("agent-chat:new-chat", handleNewChat); return () => { window.removeEventListener( "agent-chat:close-current-tab", handleCloseCurrent, ); window.removeEventListener("agent-chat:close-all-tabs", handleCloseAll); window.removeEventListener("agent-chat:new-chat", handleNewChat); }; }, [closeTab, closeAllTabs, addTab]); useEffect(() => { const handleOpenThread = (event: Event) => { const detail = (event as CustomEvent).detail as | { threadId?: unknown; newThread?: unknown; onlyIfActiveThreadId?: unknown; openRequestId?: unknown; } | undefined; const threadId = typeof detail?.threadId === "string" ? detail.threadId : ""; if (!detail || !threadId) return; if (!claimAgentChatOpenRequest(detail.openRequestId)) return; const onlyIfActiveThreadId = typeof detail.onlyIfActiveThreadId === "string" ? detail.onlyIfActiveThreadId.trim() : ""; const activeThreadId = activeThreadIdRef.current; if ( onlyIfActiveThreadId && activeThreadId && activeThreadId !== onlyIfActiveThreadId ) { return; } if (detail?.newThread === true) { newThreadIds.current.add(threadId); void createThread(threadId).then((createdId) => { if (!createdId) return; setOpenTabIds((prev) => prev.includes(createdId) ? prev : [...prev, createdId], ); writeThreadUrl(null); }); return; } setOpenTabIds((prev) => prev.includes(threadId) ? prev : [...prev, threadId], ); switchThread(threadId); }; window.addEventListener("agent-chat:open-thread", handleOpenThread); return () => window.removeEventListener("agent-chat:open-thread", handleOpenThread); }, [createThread, switchThread, writeThreadUrl]); const clearActiveTab = useCallback(() => { const tabIdToClear = activeThreadIdRef.current; void addTab().then((newTabId) => { if (!tabIdToClear || !newTabId || tabIdToClear === newTabId) return; setOpenTabIds((prev) => { const next = prev.filter((id) => id !== tabIdToClear); return next.includes(newTabId) ? next : [...next, newTabId]; }); cleanupClosedTab(tabIdToClear); }); }, [addTab, cleanupClosedTab]); const openFromHistory = useCallback( (threadId: string) => { if (!openTabIds.includes(threadId)) { setOpenTabIds((prev) => [...prev, threadId]); } switchThread(threadId); }, [openTabIds, switchThread], ); // Listen for agent-task-open events (from AgentTaskCard "Open" button) useEffect(() => { function handleOpenTask(e: Event) { const detail = (e as CustomEvent).detail; const threadId = detail?.threadId; if (!threadId) return; if (!claimAgentChatOpenRequest(detail.openRequestId)) return; dismissedSubAgentTabsRef.current.delete(threadId); // Prefer an explicit parent (RunsTray/background hydration knows it); // inline task cards fall back to the active orchestrator thread. const explicitParentId = typeof detail?.parentThreadId === "string" ? detail.parentThreadId.trim() : ""; const parentId = explicitParentId || activeThreadIdRef.current; if (parentId && parentId !== threadId) { setParentMap((prev) => prev[threadId] === parentId ? prev : { ...prev, [threadId]: parentId }, ); } // Store the sub-agent name/description for the tab label const name = detail.name || detail.description || ""; if (name) { setSubAgentNames((prev) => prev[threadId] === name ? prev : { ...prev, [threadId]: name }, ); } // Refresh thread list so the new sub-agent thread appears with its title refreshThreads(); // Open the sub-agent thread as a tab — insert after parent for visual grouping if (!openTabIds.includes(threadId)) { setOpenTabIds((prev) => { if (parentId) { const parentIdx = prev.indexOf(parentId); if (parentIdx !== -1) { // Insert after the parent (and any existing children of that parent) const next = [...prev]; let insertIdx = parentIdx + 1; // Skip past any existing children of the same parent while ( insertIdx < next.length && parentMap[next[insertIdx]] === parentId ) { insertIdx++; } next.splice(insertIdx, 0, threadId); return next; } } return [...prev, threadId]; }); } switchThread(threadId); } window.addEventListener("agent-task-open", handleOpenTask); return () => window.removeEventListener("agent-task-open", handleOpenTask); }, [openTabIds, switchThread, refreshThreads, parentMap]); // Replay thread/task opens requested before this lazy panel's listeners // attached. Live events claim their id; replay drains only unclaimed requests. useEffect(() => { const buffered = drainBufferedAgentChatOpenRequests(); for (const request of buffered) { window.dispatchEvent( new CustomEvent(request.eventType, { detail: request.detail }), ); } }, []); // Watch for agent-issued chat-command in application-state. The shared // DB-sync transport advances this key-specific version, so the command gets // one initial read and one read per actual write instead of a 2s loop. const lastChatCommandRef = useRef(0); const chatCommandVersion = useChangeVersion("app-state:chat-command"); useEffect(() => { let stopped = false; async function readChatCommand() { if (stopped) return; try { const res = await fetch( agentNativePath("/_agent-native/application-state/chat-command"), ); if (res.ok) { const data = await res.json(); if ( data?.value?.command === "open-thread" && data.value.threadId && data.value.timestamp > lastChatCommandRef.current ) { lastChatCommandRef.current = data.value.timestamp; const threadId = data.value.threadId as string; // Open the thread as a tab and focus it setOpenTabIds((prev) => prev.includes(threadId) ? prev : [...prev, threadId], ); switchThread(threadId); // Clear the command fetch( agentNativePath("/_agent-native/application-state/chat-command"), { method: "DELETE", headers: { "X-Agent-Native-CSRF": "1" }, }, ).catch(() => {}); } } } catch {} } void readChatCommand(); return () => { stopped = true; }; }, [chatCommandVersion, switchThread]); const handleGenerateTitle = useCallback( (threadId: string, message: string) => { generateTitle(threadId, message).then((title) => { if (title) { // Persist the generated title to the server saveThreadData(threadId, { threadData: "", title, preview: message.slice(0, 120), titleSource: "generated", }); } }); }, [generateTitle, saveThreadData], ); const handleSaveThread = useCallback( ( threadId: string, data: { threadData: string; title: string; preview: string; messageCount: number; }, ) => { saveThreadData(threadId, data); if ( data.messageCount > 0 && threadId === activeThreadIdRef.current && urlThreadIdRef.current !== threadId ) { writeThreadUrl(threadId); } }, [saveThreadData, writeThreadUrl], ); // ─── Slash command handler ────────────────────────────────────────── const [helpVisible, setHelpVisible] = useState(false); const handleSlashCommand = useCallback( (command: string) => { switch (command) { case "clear": case "new": addTab(); break; case "history": setShowHistory(true); break; case "plan": props.onExecModeChange?.("plan"); break; case "act": props.onExecModeChange?.("build"); break; case "help": setHelpVisible(true); break; } }, [addTab, props.onExecModeChange], ); const handleForkChat = useCallback( async (sourceThreadId: string) => { const sourceSnapshot = chatRefs.current.get(sourceThreadId)?.exportThreadSnapshot() ?? null; const forkedId = await forkThread(sourceThreadId, sourceSnapshot); if (!forkedId) return false; setOpenTabIds((prev) => { const idx = prev.indexOf(sourceThreadId); if (idx !== -1) { const next = [...prev]; next.splice(idx + 1, 0, forkedId); return next; } return [...prev, forkedId]; }); switchThread(forkedId); return true; }, [forkThread, switchThread], ); // Build tabs from open thread IDs. During the first thread-list fetch, // `activeThreadId` is seeded synchronously so the chat can mount before // persisted open tabs have been reconciled. const visibleOpenTabIds = activeThreadId && !openTabIds.includes(activeThreadId) ? [...openTabIds, activeThreadId] : openTabIds; const threadMap = new Map(threads.map((t) => [t.id, t])); const tabs: ChatTab[] = visibleOpenTabIds .filter((id) => threadMap.has(id) || id === activeThreadId) .map((id) => { const t = threadMap.get(id); const agentTeamStatus = chatTabStatusFromAgentTeamStatus( subAgentStatuses[id], ); return { id, label: t?.title || t?.preview?.slice(0, 30) || "New chat", status: agentTeamStatus ?? (runningThreads.has(id) ? ("running" as const) : (messageCounts[id] ?? t?.messageCount ?? 0) > 0 ? ("completed" as const) : ("idle" as const)), parentThreadId: parentMap[id], subAgentName: subAgentNames[id], }; }); // Include sub-agent tabs that aren't in threadMap yet (just created, not refreshed) for (const id of visibleOpenTabIds) { if (!tabs.some((t) => t.id === id)) { tabs.push({ id, label: subAgentNames[id] || (parentMap[id] ? "Sub-agent..." : "New chat"), status: chatTabStatusFromAgentTeamStatus(subAgentStatuses[id]) ?? ("running" as const), parentThreadId: parentMap[id], subAgentName: subAgentNames[id], }); } } const headerProps: MultiTabAssistantChatHeaderProps = { tabs, activeTabId: activeThreadId ?? "", activeTabMessageCount: activeThreadId ? (messageCounts[activeThreadId] ?? 0) : 0, setActiveTabId: switchThread, addTab, closeTab, closeOtherTabs, closeAllTabs, clearActiveTab, showHistory, toggleHistory: () => setShowHistory((v) => !v), tabCount: visibleOpenTabIds.length, }; // Wait for the first thread-list pass only when there is no synchronously // seeded active thread. Suggestion loading and thread-list reconciliation // should not block the chat shell from mounting. if (isLoading && !activeThreadId) { return ( ); } return (
{/* Tailwind group-hover/tab doesn't work in core package — inject directly */}