"use client" /** * LeoLandingClient — body of the per-product Leo route (`//leo`). * * Two modes on the same canvas, driven by `messages.length`: * 1. Empty — vertically-centered hero (greeting + composer + tiny chip rail). * 2. Thread — chat-style conversation, composer pinned at the bottom. * * Recents are PERSISTED and surfaced in the sidebar drill-in * (`LeoSidebarDrillInPanel`) — not on the canvas. Sticky back trail lives in * `SiteHeader` on `LeoPage`; the canvas is the conversation surface. * * The `AskLeoSidebar` Sheet stays mounted at the app shell for ⌘⌥K + * inline KPI/chart quick-asks. Those are ephemeral and do NOT push to * recents (only landing submissions do, per the brief). */ import * as React from "react" import { useLocation } from "react-router-dom" import { Avatar, AvatarFallback, AvatarImage, AvatarLeoAssistant } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { AskLeoComposer } from "@/components/ask-leo-composer" import { AskLeoViewToggle } from "@/components/ask-leo-sidebar" import { LeoSidebarToggle } from "@/components/leo-page-chrome" import { useLeoThread } from "@/lib/use-leo-thread" import { useLeoRecents } from "@/lib/leo-recents" import { NAV_USER } from "@/lib/mock/navigation" import { cn } from "@/lib/utils" const LeoIconLazy = React.lazy(() => import("@/components/ui/leo-icon").then((m) => ({ default: m.LeoIcon })), ) function LeoIcon(props: React.ComponentProps) { return ( ) } /** * Three chips only — picked to bias the user toward action, not feature * discovery. Capability discovery moved out of the canvas per user * feedback ("too much"). */ const HERO_CHIPS = [ "What needs my attention today?", "Find students missing immunization docs", "Draft a status note to a preceptor", ] const NEW_CHAT_EVENT = "exxat:leo:new-chat" /** * Imperative trigger fired by the sidebar drill-in's "New chat" button — * the landing listens and resets its thread without round-tripping through * URL state. Keeps the canvas the source of truth for thread membership. */ export function dispatchLeoNewChat() { if (typeof window === "undefined") return window.dispatchEvent(new CustomEvent(NEW_CHAT_EVENT)) } export interface LeoLandingClientProps { /** URL slug of the active product — applied to the canvas as a data hook. */ productSlug: string } export function LeoLandingClient({ productSlug }: LeoLandingClientProps) { const [composerValue, setComposerValue] = React.useState("") const composerRef = React.useRef(null) const { pathname } = useLocation() const { push: pushRecent } = useLeoRecents() const greetingName = React.useMemo(() => firstName(NAV_USER.name), []) // Landing pushes every successful turn into recents. Sheet quick-asks // do NOT push (ephemeral) — see `AskLeoSidebar` which uses the same // hook without an `onUserTurn`. const handleUserTurn = React.useCallback( (prompt: string) => { pushRecent(prompt, pathname) }, [pushRecent, pathname], ) const { messages, isThinking, send, stop, reset, scrollRef } = useLeoThread({ onUserTurn: handleUserTurn, }) const isEmpty = messages.length === 0 // Listen for "New chat" from the sidebar drill-in. React.useEffect(() => { const onNewChat = () => { reset() setComposerValue("") queueMicrotask(() => composerRef.current?.focus()) } window.addEventListener(NEW_CHAT_EVENT, onNewChat) return () => window.removeEventListener(NEW_CHAT_EVENT, onNewChat) }, [reset]) // Autofocus the composer on first paint so a keyboard user can type // immediately (P6 keyboard parity; ChatGPT / Claude / Linear pattern). React.useEffect(() => { const id = window.setTimeout(() => composerRef.current?.focus(), 50) return () => window.clearTimeout(id) }, []) const handleSubmit = React.useCallback( (prompt: string) => { const trimmed = prompt.trim() if (!trimmed) return send(trimmed) setComposerValue("") }, [send], ) const composerProps = { value: composerValue, onChange: setComposerValue, onSubmit: handleSubmit, isAnalyzing: isThinking, onStop: stop, } as const return (
{isEmpty ? ( } /> ) : ( } /> )}
) } // ───────────────────────────────────────────────────────────────────────────── // Empty hero // ───────────────────────────────────────────────────────────────────────────── function EmptyHero({ greetingName, composer, onChipSelect, }: { greetingName: string composer: React.ReactNode onChipSelect: (prompt: string) => void }) { const greeting = React.useMemo(() => pickGreeting(), []) return (

{greeting}, {greetingName}.

What's on your mind?

{composer}
    {HERO_CHIPS.map((q, i) => (
  • ))}
) } // ───────────────────────────────────────────────────────────────────────────── // Conversation pane (after first turn) // ───────────────────────────────────────────────────────────────────────────── function ConversationPane({ messages, isThinking: _isThinking, scrollRef, composer, }: { messages: ReturnType["messages"] isThinking: boolean scrollRef: React.RefObject composer: React.ReactNode }) { return ( <>
{messages.map((m) => m.role === "user" ? (
{NAV_USER.name.slice(0, 2).toUpperCase()}
{m.content}
) : (
{m.pending ? (

Thinking…

) : (

{m.content}

)}
), )}
{/* Composer pinned to the bottom, with a soft fade-to-background so scrolled content doesn't slam into it. */}
) } // ───────────────────────────────────────────────────────────────────────────── // Composer chrome — wraps `AskLeoComposer` in a Leo-themed card // ───────────────────────────────────────────────────────────────────────────── const HeroComposer = React.forwardRef< HTMLTextAreaElement, { value: string onChange: (v: string) => void onSubmit: (v: string) => void isAnalyzing?: boolean onStop?: () => void /** `default` (hero) / `compact` (conversation footer). */ size?: "default" | "compact" } >(function HeroComposer( { value, onChange, onSubmit, isAnalyzing, onStop, size = "default" }, ref, ) { return ( ) }) // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function firstName(full: string): string { return full.split(/\s+/)[0] ?? full } function pickGreeting(): string { if (typeof window === "undefined") return "Hi" const h = new Date().getHours() if (h < 5) return "Working late" if (h < 12) return "Good morning" if (h < 17) return "Good afternoon" if (h < 21) return "Good evening" return "Good night" }