"use client"
/**
* The Ask Leo conversation itself — transcript, starter cards, composer.
*
* Extracted from `AskLeoSidebar` when Leo gained a second shell (the floating
* window). Both shells render this, so the two views cannot drift: one empty
* state, one composer, one set of route-seeded starters.
*
* Header chrome is deliberately *not* here. The two shells need different
* controls — the window adds a drag handle and a minimise button — and the
* header is the one part that legitimately differs. The two pieces both headers
* need are exported alongside the body: `AskLeoIdentity` and
* `AskLeoNewChatButton`.
*
* Thread state lives here rather than in a provider, which means switching
* shells starts a fresh conversation. That matches how panel → full screen has
* always behaved, and the panel already resets on close.
*
* Ambience is not here either. It belongs to `LeoAmbientSurface`, which the
* full-screen route mounts as well — a layer only these two shells could reach
* is a layer full screen was always going to be missing.
*
* @see components/ask-leo-sidebar.tsx — docked rail
* @see components/ask-leo-window.tsx — floating window
* @see components/leo-ambient-surface.tsx — the shared ambience
*/
import * as React from "react"
import { useLocation } from "react-router"
import { AskLeoComposer } from "@/components/ask-leo-composer"
import { useAskLeo } from "@/components/ask-leo-context"
import {
LeoAmbientSurface,
LeoComposerVeil,
} from "@/components/leo-ambient-surface"
import { useLeoAmbience } from "@/components/leo-ambience-context"
import { LeoThreadMessages } from "@/components/leo-thread-messages"
import { StatusBadge } from "@/components/ui/status-badge"
import { LeoIcon } from "@/components/ui/leo-icon"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
ASK_LEO_GENERIC_SUGGESTIONS,
getAskLeoRouteContext,
} from "@/lib/ask-leo-route-context"
import { dispatchLeoNewChat, useLeoNewChat } from "@/lib/leo-new-chat"
import { useLeoThread } from "@/lib/use-leo-thread"
import { cn } from "@/lib/utils"
/**
* Identity block shared by both shells: mark, name, beta status, and the
* "responses may vary" disclosure that has to appear wherever Leo answers.
*/
export function AskLeoIdentity({ className }: { className?: string }) {
return (
Ask Leo
Powered by AI · responses may vary
)
}
/**
* New chat, for either shell's header. Hidden until there is something to
* clear, so an untouched panel is not offering to reset itself.
*
* Same icon and wording as the sidebar drill-in's New chat, and the same
* mechanism — one event, whichever shell is mounted hears it.
*/
export function AskLeoNewChatButton() {
const { threadActive } = useAskLeo()
if (!threadActive) return null
return (
New chat. Clears this conversation.
)
}
export function AskLeoThreadBody() {
const {
open,
consumePendingComposerPrompt,
pageContext,
setBusy,
setThreadActive,
} = useAskLeo()
const { previewThinking } = useLeoAmbience()
const { pathname } = useLocation()
const [composerValue, setComposerValue] = React.useState("")
const composerTextareaRef = React.useRef(null)
const { messages: threadMessages, isThinking, send, stop, reset } = useLeoThread()
const routeContext = React.useMemo(() => getAskLeoRouteContext(pathname), [pathname])
const showThinkingChrome = isThinking || previewThinking
const suggestions =
pageContext?.suggestions && pageContext.suggestions.length > 0
? pageContext.suggestions
: routeContext.suggestions ?? []
const suggestionCards =
suggestions.length > 0 ? suggestions : ASK_LEO_GENERIC_SUGGESTIONS
const appendUserTurn = React.useCallback((text: string) => send(text), [send])
// "New chat" lives in each shell's header, outside this component, so it
// arrives as an event rather than a prop. Clearing the composer too: a
// half-typed follow-up to a thread that no longer exists is just litter.
useLeoNewChat(() => {
reset()
setComposerValue("")
queueMicrotask(() => composerTextareaRef.current?.focus())
})
React.useEffect(() => {
if (!open) {
reset()
setComposerValue("")
return
}
// Only written when a prompt was actually waiting. Consuming is a one-shot
// read, so `setComposerValue(pending ?? "")` erased the prompt the moment
// this effect ran a second time against the same `open` — which StrictMode
// does on every mount in dev, leaving every `openWithPrompt` entry point
// (command menu, insight cards, home) opening an empty composer. The close
// branch above is what clears the field, so nothing here needs to.
const pending = consumePendingComposerPrompt()
if (pending !== null) setComposerValue(pending)
// Focus lands here on every open, not only prompt-seeded ones. Both shells
// portal to the end of the body, so leaving focus where it was stranded the
// whole surface behind every other control on the page.
queueMicrotask(() => composerTextareaRef.current?.focus())
}, [open, consumePendingComposerPrompt, reset])
// Republish thinking so entry points outside this component (the floating
// launcher) can show it. Cleared on unmount, or a shell closed mid-answer
// would leave the launcher spinning forever.
React.useEffect(() => {
setBusy(isThinking)
return () => setBusy(false)
}, [isThinking, setBusy])
// Same contract for "is there a thread here" — the header's New chat button
// is outside this component and must not offer to clear an empty panel.
const hasThread = threadMessages.length > 0
React.useEffect(() => {
setThreadActive(hasThread)
return () => setThreadActive(false)
}, [hasThread, setThreadActive])
return (
{hasThread ? (
) : (
// Star sits in the open field between the header and the suggestions.
)}
{/* Transparent foot — soft blur only, no opaque fill (when veil on). */}