"use client" import * as React from "react" import { useLocation } from "react-router" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label" import { usePersistedState } from "@exxatdesignux/ui/lib/persisted-state" import { focusAskLeoLauncher, focusAskLeoSurface, isFocusInsideAskLeo, } from "@/lib/ask-leo-focus" import { ASK_LEO_DOCK_KEY, type AskLeoDock } from "@/lib/ask-leo-view" import { isEditableTarget } from "@/lib/editable-target" import { isExamLockPath } from "@/lib/exam-lock-shell" import { productSlug } from "@/stores/app-store" import { useProduct } from "@/contexts/product-context" /** * Page context that pages register with `useAskLeoPageContext` so Leo knows * what the user is currently looking at. */ export interface AskLeoPageContext { title: string description?: string suggestions?: string[] data?: Record } interface AskLeoContextValue { open: boolean setOpen: (open: boolean) => void toggle: () => void openWithPrompt: (prompt: string) => void consumePendingComposerPrompt: () => string | null pageContext: AskLeoPageContext | null setPageContext: (ctx: AskLeoPageContext | null) => void /** * Leo is composing an answer. The thread state itself stays inside the panel * that owns it; this is the one bit of it that entry points outside the panel * need, so they can show the working state instead of guessing. */ busy: boolean setBusy: (busy: boolean) => void /** * The open shell has at least one turn in it. Same reasoning as `busy`: the * shell owns the thread, but its header — which lives outside the body — has * to know whether "New chat" would do anything, and a control that does * nothing is worse than no control. */ threadActive: boolean setThreadActive: (active: boolean) => void /** * Which shell renders the conversation — the docked rail or the floating * window. A stored preference, so it survives a reload; full screen is not * here because it is a route, not a preference. */ dock: AskLeoDock setDock: (dock: AskLeoDock) => void /** * Window mode only: parked to the launcher. The window stays mounted while * minimised (hidden, so nothing inside it is focusable) — unmounting it would * throw away the conversation the user was in the middle of, which is the one * thing minimise must not do. */ minimized: boolean setMinimized: (minimized: boolean) => void } const AskLeoContext = React.createContext({ open: false, setOpen: () => {}, toggle: () => {}, openWithPrompt: () => {}, consumePendingComposerPrompt: () => null, pageContext: null, setPageContext: () => {}, busy: false, setBusy: () => {}, threadActive: false, setThreadActive: () => {}, dock: "panel", setDock: () => {}, minimized: false, setMinimized: () => {}, }) function isLeoLandingPath(pathname: string, leoHref: string) { return pathname === leoHref || pathname.startsWith(`${leoHref}/`) } export function useAskLeo() { return React.useContext(AskLeoContext) } 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 [busy, setBusy] = React.useState(false) const [threadActive, setThreadActive] = React.useState(false) const [pageContext, setPageContext] = React.useState(null) const [dock, setDock] = usePersistedState(ASK_LEO_DOCK_KEY, "panel", { debounceMs: 0, }) // Not persisted: minimised is a "park this for a minute" state, and coming // back tomorrow to an invisible parked window with no conversation in it // would just look like Leo was broken. const [minimized, setMinimized] = React.useState(false) const toggle = React.useCallback(() => setOpen(v => !v), []) const pendingComposerPromptRef = React.useRef(null) // Opening Leo always means showing Leo. Without this, a minimised window // would swallow the ⌘⌥K toggle and the utility-bar button. React.useEffect(() => { if (open) setMinimized(false) }, [open]) const openWithPrompt = React.useCallback((prompt: string) => { pendingComposerPromptRef.current = prompt setOpen(true) }, []) const consumePendingComposerPrompt = React.useCallback(() => { const prompt = pendingComposerPromptRef.current pendingComposerPromptRef.current = null return prompt }, []) const value = React.useMemo( () => ({ open, setOpen, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext, setPageContext, busy, setBusy, threadActive, setThreadActive, dock, setDock, minimized, setMinimized, }), [ open, toggle, openWithPrompt, consumePendingComposerPrompt, pageContext, busy, threadActive, dock, setDock, minimized, ], ) const { product } = useProduct() const leoHref = `/${productSlug(product)}/leo` React.useEffect(() => { if (isLeoLandingPath(pathname, leoHref)) { setOpen(false) } }, [pathname, leoHref]) const toggleRef = React.useRef(toggle) React.useEffect(() => { toggleRef.current = toggle }) const openRef = React.useRef(open) React.useEffect(() => { openRef.current = open }) const minimizedRef = React.useRef(minimized) React.useEffect(() => { minimizedRef.current = minimized }) React.useEffect(() => { if (examLock) { setOpen(false) return } function onGlobalKeyDown(event: KeyboardEvent) { if (!event.altKey || (!event.metaKey && !event.ctrlKey)) return if (event.key.toLowerCase() !== "k") return const insideLeo = isFocusInsideAskLeo() // Typing in a page field means the chord belongs to that field, not to // us. Typing in Leo's own composer does not: the shortcut has to keep // working from there, or opening Leo would disarm the key that closes him. if (!insideLeo && isEditableTarget(event.target)) return event.preventDefault() if (openRef.current) { // Parked in the corner. Bring the conversation back rather than // toggling `open` off, which would discard the thread the user // minimised precisely to keep. The window moves focus on restore. if (minimizedRef.current) { setMinimized(false) return } // Open, but the user is working somewhere else on the page: they want // to get back to Leo, not dismiss him. Both shells sit at the end of // the tab order, so tabbing there is not a real option. if (!insideLeo && focusAskLeoSurface()) return // Focus is inside, so this press means close, and focus has to land // somewhere deliberate instead of collapsing to the document. toggleRef.current() focusAskLeoLauncher() return } toggleRef.current() } document.addEventListener("keydown", onGlobalKeyDown) return () => document.removeEventListener("keydown", onGlobalKeyDown) }, [examLock]) return ( {children} ) } export function AskLeoShortcutKbds({ className, variant = "tile", }: { className?: string variant?: "tile" | "bare" }) { const mod = useModKeyLabel() const alt = useAltKeyLabel() if (variant === "bare") { return ( {mod}{alt}K ) } return ( {mod} {alt} K ) }