import { CHAT_FIRST_MODE_CHANGED_EVENT, AgentChatSurface, chatFirstSurfaceTabId, AgentSidebar, ChatFirstSurfacePanelToggle, closeChatFirstSessionWatch, emitChatFirstSessionWatch, getChatFirstSurfaceTabsStore, focusAgentChat, navigateWithAgentChatViewTransition, readChatFirstAppLayout, readChatFirstMode, resolveChatFirstBrowserTarget, resolveChatFirstAppTarget, subscribeChatFirstOpenBrowser, subscribeChatFirstOpenApp, useChatFirstSessionWatch, useChatFirstSurfacePanel, useChatFirstSurfaceResize, useChatFirstSurfaceTabs, type ChatFirstAppRegistration, type ChatFirstAppLayoutPreference, type ChatFirstAppResolution, type ChatFirstAppSurfacePlacement, type ChatFirstOpenBrowserDetail, type ChatFirstOpenAppDetail, type ChatFirstSessionReference, type ChatFirstSurfaceTab, type ChatFirstSurfaceKind, useAgentChatHomeHandoff, useAgentChatHomeHandoffLinks, useChatThreads, type ChatThreadSummary, } from "@agent-native/core/client/agent-chat"; import { appBasePath, appPath } from "@agent-native/core/client/api-path"; import { readClientAppState, writeClientAppState, } from "@agent-native/core/client/application-state"; import { ChatFirstAgentsPane, ChatFirstAppPane, ChatFirstAppsRail, ChatFirstBrowserPane, ChatFirstChatHistory, ChatFirstPrimaryNavigation, ChatFirstSessionWatchPane, ChatFirstSurfacePanel, ChatFirstSurfaceContent, ChatFirstSurfaceTabs, defaultChatFirstCopy, type ChatFirstAgentActivity, type ChatFirstAppItem, type ChatFirstCopy, type ChatFirstEmbedTarget, type ChatFirstPrimaryTab, } from "@agent-native/core/client/chat-first"; import { writeClipboardText } from "@agent-native/core/client/clipboard"; import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { openCommandMenu } from "@agent-native/core/client/navigation"; import { InvitationBanner, OrgSwitcher } from "@agent-native/core/client/org"; import { FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; import { ChatHistoryRail, type ChatHistoryItem, } from "@agent-native/toolkit/chat-history"; import { IconApps, IconBrandSlack, IconBrandTelegram, IconCopy, IconEye, IconHierarchy2, IconMessageQuestion, IconBroadcast, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconSettings, IconShield, IconSearch, IconWorld, IconDeviceDesktop, IconPlus, } from "@tabler/icons-react"; import { createContext, useEffect, useMemo, useRef, useState, useContext, useCallback, type ComponentType, type ReactNode, } from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; import { useIsMobile } from "../../hooks/use-mobile"; import { cn } from "../../lib/utils"; import { isDispatchWorkspaceAppId, isPathMountedWorkspaceApp, isWorkspaceAppVisibleInDefaultLaunchers, isWorkspaceSsoApp, mergeChatFirstWorkspaceApps, navigateToWorkspaceApp, workspaceAppIdFromRoute, workspaceAppDirectHref, workspaceAppRoute, type WorkspaceAppSummary, } from "../../lib/workspace-apps"; import { CHAT_FIRST_PANE_STATE_KEY } from "../../shared/chat-first-pane"; import { AppIcon } from "../app-icon"; import { CreateAppPopover } from "../create-app-popover"; import { Sheet, SheetContent, SheetDescription, SheetTitle } from "../ui/sheet"; import { Skeleton } from "../ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceAppChatRail, WorkspaceAppFrame, WorkspaceAppKeepAlive, } from "../workspace-app-host"; import { Header } from "./Header"; import { HeaderActionsProvider } from "./HeaderActions"; export { buildChatFirstEmbedSessionInput } from "../workspace-app-host"; export type DispatchNavSection = "primary" | "operations"; export type DispatchNavIcon = ComponentType<{ size?: number | string; className?: string; }>; export interface DispatchNavItem { /** Stable id used for keys and navigation.view. Avoid built-in ids. */ id: string; /** React Router path for the tab, usually backed by an app/routes/*.tsx file. */ to: string; label: string; icon?: DispatchNavIcon; /** Defaults to "operations", which renders under the Admin control plane. */ section?: DispatchNavSection; /** Override active matching for nested or multi-route tools. */ match?: (pathname: string) => boolean; /** Canonical path inside the Admin shell for management tabs. */ adminTo?: string; } export interface DispatchExtensionConfig { /** Opt into the Codex/T3-like chat-first shell for chat routes. */ chatFirst?: boolean; /** Extra sidebar tabs supplied by the generated workspace. */ navItems?: readonly DispatchNavItem[]; /** Extra React Query keys to invalidate when Dispatch receives DB sync events. */ queryKeys?: readonly string[]; } const PRIMARY_NAV_ITEMS = [ { id: "overview", to: "/overview", label: "Overview", icon: IconBroadcast, section: "primary", }, { id: "chat", to: "/chat", label: "Chat", icon: IconMessageQuestion, section: "primary", }, { id: "apps", to: "/apps", label: "Apps", icon: IconApps, section: "primary", }, { id: "agents", to: "/agents", label: "Agents", icon: IconHierarchy2, section: "primary", }, ] as const satisfies readonly DispatchNavItem[]; const BOTTOM_NAV_ITEMS = [ { id: "admin", to: "/admin", label: "Admin", icon: IconShield, }, { id: "settings", to: "/settings", label: "Settings", icon: IconSettings, }, ] as const satisfies readonly DispatchNavItem[]; const EMPTY_NAV_ITEMS: readonly DispatchNavItem[] = []; const DISPATCH_SIDEBAR_LABEL = "Dispatch"; const CHROMELESS_PATHS = ["/approval", "/browser-chat", "/browser-connect"]; const SIDEBAR_COLLAPSE_KEY = "dispatch.sidebar.collapsed"; const CHAT_HISTORY_SOURCE_KEY = "dispatch.chat-history.source"; // Below 768px, ChatFirstSurfacePanel becomes a max-[767px]:z-10 full-screen // overlay (surface-panel.tsx). This toggle is the only way to dismiss it, so // its z-index must stay above that overlay in every stacking context or the // panel becomes undismissable on mobile. export const CHAT_FIRST_SURFACE_PANEL_TOGGLE_CLASS_NAME = "absolute right-3 top-2 z-20"; interface DispatchChatFirstPane { appId: string; placement?: ChatFirstAppSurfacePlacement; path?: string; view?: string; } interface ChatFirstGrantedAppSummary { id: string; name: string; url?: string | null; } interface ChatFirstGrantedAppsResult { apps: ChatFirstGrantedAppSummary[]; } interface DispatchAgentThreadSummary { id: string; title: string; preview?: string; snippet?: string; updatedAt?: number; } interface SearchAgentThreadsResult { threads: DispatchAgentThreadSummary[]; } const DispatchExtensionsContext = createContext< DispatchExtensionConfig | undefined >(undefined); export function useDispatchExtensions(): DispatchExtensionConfig | undefined { return useContext(DispatchExtensionsContext); } // Routes whose page renders its own toolbar. // Layout still mounts the sidebar + AgentSidebar, but skips its own Header so // there's no double-header. function pageOwnsToolbar(pathname: string): boolean { if (pathname === "/tools" || pathname.startsWith("/tools/")) return true; if (pathname === "/extensions" || pathname.startsWith("/extensions/")) return true; if (pathname.startsWith("/apps/")) return true; return false; } function sectionFor(item: DispatchNavItem): DispatchNavSection { return item.section ?? "operations"; } function navItemMatchesPath(item: DispatchNavItem, pathname: string): boolean { if (item.match) { try { if (item.match(pathname)) return true; } catch { return false; } } return pathname === item.to || pathname.startsWith(`${item.to}/`); } function navItemsForSection( items: readonly DispatchNavItem[], section: DispatchNavSection, ): DispatchNavItem[] { return items.filter((item) => sectionFor(item) === section); } function localDispatchPath(pathname: string): string { const basePath = appBasePath(); if (!basePath) return pathname; if (pathname === basePath) return "/"; if (pathname.startsWith(`${basePath}/`)) { return pathname.slice(basePath.length) || "/"; } return pathname; } export function isElectronEmbeddedSearch(search: string): boolean { return new URLSearchParams(search).get("electron") === "1"; } export function shouldAutoCollapseDispatchSidebar(pathname: string): boolean { return localDispatchPath(pathname).startsWith("/apps/"); } function chatFirstPrimaryTabForPath( pathname: string, ): ChatFirstPrimaryTab | undefined { if (pathname === "/chat" || pathname.startsWith("/chat/")) { return "new-chat"; } if ( pathname === "/integrations" || pathname.startsWith("/integrations/") || pathname === "/admin/integrations" || pathname.startsWith("/admin/integrations/") ) { return "integrations"; } if ( pathname === "/automations" || pathname.startsWith("/automations/") || pathname === "/admin/automations" || pathname.startsWith("/admin/automations/") ) { return "scheduled"; } return undefined; } function dispatchNavLinkTarget(path: string): string { if (typeof window === "undefined") return path; const basePath = appBasePath(); if (!basePath) return path; // Mirror the basename calculation entry.client.tsx uses to configure the // router (basePath iff the current URL is under that mount, "" otherwise). // Reading the live URL directly avoids races with the previous check on // `__reactRouterContext.basename`, which could read undefined before the // entry script set it — that race produced /dispatch/dispatch/ // history entries that 404'd on back-button navigation. const pathname = window.location.pathname; const routerHasBasename = pathname === basePath || pathname.startsWith(`${basePath}/`); return routerHasBasename ? path : appPath(path); } function chatFirstResolutionMessage( reason: Exclude["reason"], ): string { switch (reason) { case "empty-detail": return "The agent did not provide an app target to open."; case "invalid-url": return "The requested app route was not registered for this workspace app."; case "unknown-app": return "That app is not available in this workspace."; } return "The requested app could not be opened."; } function chatFirstBrowserResolutionMessage( reason: "empty-detail" | "invalid-url", ): string { return reason === "empty-detail" ? "The agent did not provide a browser URL to open." : "The requested browser URL is not a safe HTTP(S) address."; } const DISPATCH_CHAT_FIRST_COPY_KEYS: Record = { workspaceApps: "chatFirstWorkspaceApps", createWorkspaceApp: "chatFirstCreateWorkspaceApp", openApp: "chatFirstOpenApp", appsLoadError: "chatFirstAppsLoadError", noWorkspaceApps: "chatFirstNoWorkspaceApps", createApp: "chatFirstCreateApp", retry: "chatFirstRetry", dismiss: "chatFirstDismiss", unpinApp: "chatFirstUnpinApp", pinApp: "chatFirstPinApp", removePinned: "chatFirstRemovePinned", pinTop: "chatFirstPinTop", openSideSurfaces: "chatFirstOpenSideSurfaces", closeTab: "chatFirstCloseTab", close: "chatFirstClose", closeOthers: "chatFirstCloseOthers", closeToRight: "chatFirstCloseToRight", closeAll: "chatFirstCloseAll", unavailable: "chatFirstUnavailable", openActivity: "chatFirstOpenActivity", deferred: "chatFirstDeferred", browserBack: "chatFirstBrowserBack", browserForward: "chatFirstBrowserForward", browserReload: "chatFirstBrowserReload", browserAddress: "chatFirstBrowserAddress", browserOpenExternal: "chatFirstBrowserOpenExternal", browserClose: "chatFirstBrowserClose", browserPage: "chatFirstBrowserPage", browserInvalidUrl: "chatFirstBrowserInvalidUrl", browserPreviewStarting: "chatFirstBrowserPreviewStarting", browserPreviewError: "chatFirstBrowserPreviewError", appUnavailable: "chatFirstAppUnavailable", appLoading: "chatFirstLoadingApp", agentActivityEyebrow: "chatFirstAgentActivityEyebrow", agentActivityTitle: "chatFirstAgentActivityTitle", agentActivityDescription: "chatFirstAgentActivityDescription", refreshAgentActivity: "chatFirstRefreshAgentActivity", noAgentSessions: "chatFirstNoAgentSessions", startAgentSession: "chatFirstStartAgentSession", watchSession: "chatFirstWatchSession", copySessionId: "chatFirstCopySessionId", copySessionIdFor: "chatFirstCopySessionIdFor", sessionIdCopied: "chatFirstSessionIdCopied", sessionIdShort: "chatFirstSessionIdShort", copied: "chatFirstCopied", agentActivityStatusQueued: "chatFirstAgentActivityStatusQueued", agentActivityStatusRunning: "chatFirstAgentActivityStatusRunning", agentActivityStatusPaused: "chatFirstAgentActivityStatusPaused", agentActivityStatusNeedsApproval: "chatFirstAgentActivityStatusNeedsApproval", agentActivityStatusCompleted: "chatFirstAgentActivityStatusCompleted", agentActivityStatusErrored: "chatFirstAgentActivityStatusErrored", agentActivityStatusRecent: "chatFirstAgentActivityStatusRecent", agentActivityStatusUnknown: "chatFirstAgentActivityStatusUnknown", watchingSession: "chatFirstWatchingSession", session: "chatFirstSession", stopWatchingSession: "chatFirstStopWatchingSession", stopWatching: "chatFirstStopWatching", watchedSession: "chatFirstWatchedSession", agentsTitle: "chatFirstAgentsTitle", }; const DISPATCH_CHAT_FIRST_SURFACE_COPY_KEYS: Record< string, { label: string; reason: string } > = { browser: { label: "chatFirstSurfaceBrowserLabel", reason: "chatFirstSurfaceBrowserReason", }, terminal: { label: "chatFirstSurfaceTerminalLabel", reason: "chatFirstSurfaceTerminalReason", }, files: { label: "chatFirstSurfaceFilesLabel", reason: "chatFirstSurfaceFilesReason", }, diff: { label: "chatFirstSurfaceDiffLabel", reason: "chatFirstSurfaceDiffReason", }, "side-chat": { label: "chatFirstSurfaceSideChatLabel", reason: "chatFirstSurfaceSideChatReason", }, agents: { label: "chatFirstSurfaceAgentsLabel", reason: "chatFirstSurfaceAgentsReason", }, }; function createDispatchChatFirstCopy( t: (key: string, options?: Record) => string, ): ChatFirstCopy { return (key, values) => { if (key.startsWith("surface.")) { const [, kind, field] = key.split("."); const surface = DISPATCH_CHAT_FIRST_SURFACE_COPY_KEYS[kind]; const translationKey = surface && (field === "label" || field === "reason") ? surface[field] : undefined; if (translationKey) { return t(`dispatch.pages.${translationKey}`, { ...(values ?? {}), defaultValue: defaultChatFirstCopy(key, values), }); } } const translationKey = DISPATCH_CHAT_FIRST_COPY_KEYS[key]; if (!translationKey) return defaultChatFirstCopy(key, values); return t(`dispatch.pages.${translationKey}`, { ...(values ?? {}), defaultValue: defaultChatFirstCopy(key, values), }); }; } function chatThreadPath(threadId: string | null): string { return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/chat"; } function persistedChatFirstPane(value: unknown): DispatchChatFirstPane | null { if (!value || typeof value !== "object") return null; const record = value as Record; if (typeof record.appId !== "string" || !record.appId.trim()) return null; return { appId: record.appId, ...(record.placement === "main" || record.placement === "side" ? { placement: record.placement } : {}), ...(typeof record.path === "string" ? { path: record.path } : {}), ...(typeof record.view === "string" ? { view: record.view } : {}), }; } function threadIdFromPath(pathname: string): string | null { const match = pathname.match(/^\/chat\/([^/]+)/); if (!match) return null; try { const value = decodeURIComponent(match[1]).trim(); return value || null; } catch { return null; } } export function formatThreadAge(updatedAt: number, now = Date.now()) { const diffMs = Math.max(0, now - updatedAt); const minutes = Math.floor(diffMs / 60_000); if (minutes < 1) return "now"; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 14) return `${days}d`; if (days < 365) return `${Math.floor(days / 7)}w`; return `${Math.floor(days / 365)}y`; } function threadTitle(thread: ChatThreadSummary, fallback: string) { return thread.title || thread.preview || fallback; } function threadSourceIcon(platform: string | undefined): ReactNode { const normalized = platform?.trim().toLowerCase(); if (!normalized) return null; if (normalized === "slack") { return