"use client" /** * AskLeoSidebar — app-wide right sidebar for the AI assistant. * Mirrors the left sidebar behavior: slides in/out with a toggle button. * Lives in the (app) layout so it persists across all pages. */ import * as React from "react" import { useLocation, useNavigate } from "react-router-dom" import { isExamLockPath } from "@/lib/exam-lock-shell" import { useProduct } from "@/contexts/product-context" import { useProductDashboardHref } from "@/contexts/product-route-sync" import { productSlug } from "@/stores/app-store" import { cn } from "@/lib/utils" import { Avatar, AvatarFallback, AvatarImage, AvatarLeoAssistant } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { AskLeoComposer } from "@/components/ask-leo-composer" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { useSidebar } from "@/components/ui/sidebar" import { StatusBadge } from "@/components/ui/status-badge" import { ASK_LEO_PANEL_WIDTH_DEFAULT, ASK_LEO_PANEL_WIDTH_KEY, NestedSecondaryPanelShell, } from "@/components/templates/nested-secondary-panel-shell" import { useSidebarReflowZoom } from "@/hooks/use-sidebar-reflow-zoom" // React.lazy + Suspense for the chart bundle. // PR-6. Same call-site shape (``) — Suspense boundary is internal. const LeoIconLazy = React.lazy(() => import("@/components/ui/leo-icon").then(m => ({ default: m.LeoIcon })), ) function LeoIcon(props: React.ComponentProps) { return ( ) } import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label" import { ASK_LEO_GENERIC_SUGGESTIONS, getAskLeoRouteContext } from "@/lib/ask-leo-route-context" import { isEditableTarget } from "@/lib/editable-target" import { NAV_USER } from "@/lib/mock/navigation" import { useLeoThread } from "@/lib/use-leo-thread" // ───────────────────────────────────────────────────────────────────────────── // Context — share open state with any page (e.g. "Ask Leo" buttons on cards) // ───────────────────────────────────────────────────────────────────────────── /** * Page context that pages register with `useAskLeoPageContext` so Leo knows * what the user is currently looking at. Title is shown in the welcome * bubble; `suggestions` replace the generic prompt list when present; and * `data` is an opaque payload the downstream API call can echo back. */ export interface AskLeoPageContext { /** Human-readable page name, e.g. "Placements" or "Compliance dashboard". */ title: string /** Optional one-line description ("42 active placements, 3 pending review"). */ description?: string /** Page-specific starter prompts — replace the generic 4 when provided. */ suggestions?: string[] /** Arbitrary payload handed to the assistant API at send time. */ data?: Record } interface AskLeoContextValue { open: boolean setOpen: (open: boolean) => void toggle: () => void /** Open the sidebar and prefill the composer (e.g. command palette AI suggestions). */ openWithPrompt: (prompt: string) => void /** Internal — AskLeoSidebar consumes pending text when opening. */ consumePendingComposerPrompt: () => string | null /** Current page context (or null if no page has registered one). */ pageContext: AskLeoPageContext | null /** Register/replace the current page's context. */ setPageContext: (ctx: AskLeoPageContext | null) => void } export type AskLeoViewMode = "panel" | "fullscreen" function isLeoLandingPath(pathname: string, leoHref: string) { return pathname === leoHref || pathname.startsWith(`${leoHref}/`) } function useAskLeoViewMode(): AskLeoViewMode { const { pathname } = useLocation() const { product } = useProduct() const leoHref = `/${productSlug(product)}/leo` return isLeoLandingPath(pathname, leoHref) ? "fullscreen" : "panel" } const AskLeoContext = React.createContext({ open: false, setOpen: () => {}, toggle: () => {}, openWithPrompt: () => {}, consumePendingComposerPrompt: () => null, pageContext: null, setPageContext: () => {}, }) export function useAskLeo() { return React.useContext(AskLeoContext) } /** * Pages call this at the top of their client component to tell Leo what * surface the user is on. Unregisters on unmount (so route changes clear * stale context). Memoize `ctx` to avoid update loops — use `React.useMemo`. * * @example * useAskLeoPageContext(React.useMemo(() => ({ * title: "Placements", * description: `${rows.length} rows, ${filters.active} filters active`, * suggestions: [ * "Summarize placements ending this month", * "Which sites are at capacity?", * ], * }), [rows.length, filters.active])) */ export function useAskLeoPageContext(ctx: AskLeoPageContext | null) { const { setPageContext } = React.useContext(AskLeoContext) React.useEffect(() => { setPageContext(ctx) return () => setPageContext(null) }, [ctx, setPageContext]) } export function AskLeoProvider({ children }: { children: React.ReactNode }) { const { pathname } = useLocation() const examLock = isExamLockPath(pathname) const [open, setOpen] = React.useState(false) const [pageContext, setPageContext] = React.useState(null) const toggle = React.useCallback(() => setOpen(v => !v), []) const pendingComposerPromptRef = React.useRef(null) const openWithPrompt = React.useCallback((prompt: string) => { pendingComposerPromptRef.current = prompt setOpen(true) }, []) const consumePendingComposerPrompt = React.useCallback(() => { const p = pendingComposerPromptRef.current pendingComposerPromptRef.current = null return p }, []) const value = React.useMemo( () => ({ open, setOpen, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext, setPageContext }), [open, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext], ) const { product } = useProduct() const leoHref = `/${productSlug(product)}/leo` /** Full-screen Leo route owns the canvas — keep the rail closed. */ React.useEffect(() => { if (isLeoLandingPath(pathname, leoHref)) { setOpen(false) } }, [pathname, leoHref]) /** ⌘⌥K / Ctrl+Alt+K — disabled on exam lock routes. */ const toggleRef = React.useRef(toggle) toggleRef.current = toggle React.useEffect(() => { if (examLock) { setOpen(false) return } function onGlobalKeyDown(e: KeyboardEvent) { if (!e.altKey || (!e.metaKey && !e.ctrlKey)) return if (e.key.toLowerCase() !== "k") return if (isEditableTarget(e.target)) return e.preventDefault() toggleRef.current() } document.addEventListener("keydown", onGlobalKeyDown) return () => document.removeEventListener("keydown", onGlobalKeyDown) }, [examLock]) return ( {children} ) } // ───────────────────────────────────────────────────────────────────────────── // App shell — main column + Ask Leo rail (flex siblings, not overlay) // ───────────────────────────────────────────────────────────────────────────── /** * Panel ↔ full-screen Leo — icon menu in the rail / landing chrome. * Panel keeps the right rail on the current hub; full screen navigates to `//leo`. */ export function AskLeoViewToggle({ className }: { className?: string }) { const navigate = useNavigate() const { open, setOpen } = useAskLeo() const { product } = useProduct() const dashboardHref = useProductDashboardHref() const leoHref = `/${productSlug(product)}/leo` const viewMode = useAskLeoViewMode() const handleViewChange = React.useCallback( (next: AskLeoViewMode) => { if (next === viewMode) return if (next === "fullscreen") { setOpen(false) navigate(leoHref) return } if (viewMode === "fullscreen") { navigate(dashboardHref) queueMicrotask(() => setOpen(true)) return } if (!open) setOpen(true) }, [dashboardHref, leoHref, navigate, open, setOpen, viewMode], ) return (