"use client" /** * Contextual rewrite bar — Leo attached to the field being edited. * * Shares the Leo search-bar pill and its thinking wash, but the job is * different: the search bar finds records, this one writes or changes the text * already in front of the user. * * One intent, two content states: * empty — nothing written yet, so Leo drafts from a description. * filled — there is text, so Leo rewrites it. Quick actions live in the * overflow menu; undo / redo sit after it once Leo has written. * * Scope is a third axis, independent of both: with `selection` set, Leo sees * only that span and the result is spliced back into place. Undo still restores * the whole field, so a scoped rewrite is no harder to take back than any other. * * The bar owns the undo / redo stack for Leo's writes only. Typing by the user * is not tracked, so undo always means "put back what Leo replaced". * * @see components/ask-leo-composer.tsx * @see components/leo-assist-field.tsx — selection scope and the collapsed form */ import * as React from "react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { AskLeoButton } from "@/components/ask-leo-button" import { AskLeoComposer } from "@/components/ask-leo-composer" import { useLeoAmbience } from "@/components/leo-ambience-context" import { searchBarShellClassName } from "@/components/search-bar-shell" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Kbd } from "@/components/ui/kbd" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" /** Which content state the field is in. Drives copy, actions, and placeholder. */ export type LeoAssistMode = "generate" | "rewrite" export interface LeoAssistAction { id: string /** Button or menu label. Also the instruction when `instruction` is omitted. */ label: string /** FontAwesome name without the style prefix, e.g. `fa-wand-magic-sparkles`. */ icon?: string /** Sent to Leo when the label is too terse to act on. */ instruction?: string } /** A span of the field Leo has been narrowed to. */ export interface LeoAssistSelection { start: number end: number text: string } export interface LeoAssistRequest { mode: LeoAssistMode /** What the user asked for, typed or from an action. */ instruction: string /** * The text in scope when the request is made: the selected span when there * is one, the whole field otherwise, empty in generate mode. */ text: string /** The span in scope, or null when Leo is working on the whole field. */ selection: LeoAssistSelection | null } export interface LeoAssistBarProps { /** The text Leo works on. Controlled by the host field. */ text: string onTextChange: (next: string) => void /** * Runs the request and resolves with the new text **for the scope it was * given**: the replacement for the span when `request.selection` is set, the * whole field otherwise. The bar does the splicing, so a host never has to * reason about offsets. Reject to surface an inline error; the bar never * writes on a rejection, so the undo stack stays truthful. */ onRun: (request: LeoAssistRequest) => Promise /** * Narrows Leo to a span of `text`. Everything outside it is left alone, byte * for byte, which is the whole reason a user selects before asking. */ selection?: LeoAssistSelection | null /** What the scope chip calls the narrowed span. */ scopeLabel?: string /** * Renders as a single button until the user asks for Leo. For forms where a * bar per field would be ten bars, and for fields the user mostly types in * unaided. */ collapsible?: boolean /** Controlled open state. Leave unset to let the bar own it. */ open?: boolean onOpenChange?: (open: boolean) => void /** Label on the collapsed trigger. */ collapsedLabel?: string /** Offered once the field has content. All entries live in the overflow. */ actions?: readonly LeoAssistAction[] /** Offered while the field is empty. All entries live in the overflow. */ emptyActions?: readonly LeoAssistAction[] /** Names the field Leo is editing, for screen readers. */ fieldLabel?: string /** Rotating example instructions for the filled state. */ examples?: readonly string[] /** Rotating example instructions for the empty state. */ emptyExamples?: readonly string[] /** * Fires when Leo starts and stops. Hosts use it to lock the field and the * rest of the form, so nothing is edited underneath a run in flight. */ onRunningChange?: (running: boolean) => void disabled?: boolean className?: string } const MODE_COPY: Record< LeoAssistMode, { placeholder: string; inputLabel: string; submitLabel: string } > = { generate: { placeholder: "Describe what to write", inputLabel: "Describe what Leo should write", submitLabel: "Write with Leo", }, rewrite: { placeholder: "Describe your change", inputLabel: "Describe a change for Leo", submitLabel: "Rewrite with Leo", }, } /** What Leo is told to do. The label is the fallback when it says it plainly. */ function instructionFor(action: LeoAssistAction) { return action.instruction ?? action.label } const GHOST_ICON_BTN = "icon-button-chrome size-9 shrink-0 rounded-full hover:bg-accent hover:text-interactive-hover-foreground" /** * The two widths the bar lives at, in pixels rather than utilities because the * open gesture animates between them. * * The collapsed width is the pill's own resting height, so the trigger is not a * button that happens to be round: it is the pill closed down to a circle. The * shell keeps a 25px radius, which at 50px tall *is* a circle, so the frame the * user clicks is the frame that grows. */ const BAR_COLLAPSED_PX = 50 const BAR_OPEN_PX = 448 /** * The open gesture is a reveal, not a resize. The pill is already at its full * width behind the circle, so moving the clip edge shows more of a box that * never changes shape, and the browser never has to lay the page out again * mid-animation. The radius matches the circle so the moving edge stays a cap * rather than a straight cut. * * The settled shape reaches past the box on every side. Left at the border the * clip would shave off the focus ring and the working wash. */ const CLIP_RADIUS_PX = BAR_COLLAPSED_PX / 2 const CLIP_COLLAPSED = `inset(0px ${BAR_OPEN_PX - BAR_COLLAPSED_PX}px 0px 0px round ${CLIP_RADIUS_PX}px)` const CLIP_OPEN = `inset(0px 0px 0px 0px round ${CLIP_RADIUS_PX}px)` const CLIP_SETTLED = `inset(-64px -64px -64px -64px round ${CLIP_RADIUS_PX}px)` export function LeoAssistBar({ text, onTextChange, onRun, actions, emptyActions, fieldLabel, examples, emptyExamples, onRunningChange, selection, scopeLabel = "Selection", collapsible = false, open: openProp, onOpenChange, collapsedLabel = "Edit with Leo", disabled = false, className, }: LeoAssistBarProps) { const { previewThinking } = useLeoAmbience() const reducedMotion = useReducedMotion() ?? false /** True once the open gesture has finished and the pill is at full width. */ const [settled, setSettled] = React.useState(false) const rootRef = React.useRef(null) const [draft, setDraft] = React.useState("") const [running, setRunning] = React.useState(false) const [error, setError] = React.useState(null) const [menuOpen, setMenuOpen] = React.useState(false) // Leo's writes only. `past` holds what the text was before each one. const [past, setPast] = React.useState([]) const [future, setFuture] = React.useState([]) const runIdRef = React.useRef(0) const onRunningChangeRef = React.useRef(onRunningChange) React.useEffect(() => { onRunningChangeRef.current = onRunningChange }) React.useEffect(() => { onRunningChangeRef.current?.(running) }, [running]) // A collapsed range is not a scope: clicking into a field to place the caret // would otherwise narrow Leo to nothing and leave him unable to write. const scope = selection && selection.end > selection.start ? selection : null const scopedText = scope ? scope.text : text const mode: LeoAssistMode = scopedText.trim() ? "rewrite" : "generate" const copy = MODE_COPY[mode] const modeActions = (mode === "rewrite" ? actions : emptyActions) ?? [] const modeExamples = mode === "rewrite" ? examples : emptyExamples const canUndo = past.length > 0 const canRedo = future.length > 0 const busy = running || previewThinking const locked = disabled || running const [openState, setOpenState] = React.useState(false) const open = !collapsible || (openProp ?? openState) const setOpen = React.useCallback( (next: boolean) => { setOpenState(next) onOpenChange?.(next) }, [onOpenChange], ) // The user asked for the bar, so the bar takes the caret. Without this the // expand costs a second click before anything can be typed. React.useEffect(() => { if (!collapsible || !open) return rootRef.current?.querySelector("textarea")?.focus() }, [collapsible, open]) // Closing arms the next open gesture: the pill has to start clipped again or // it would appear at full width inside a 50px frame. React.useEffect(() => { if (!open) setSettled(false) }, [open]) function applyWrite(next: string) { setPast(stack => [...stack, text]) setFuture([]) onTextChange(next) } function undo() { if (!canUndo) return const previous = past[past.length - 1] setPast(stack => stack.slice(0, -1)) setFuture(stack => [text, ...stack]) onTextChange(previous) } function redo() { if (!canRedo) return const next = future[0] setFuture(stack => stack.slice(1)) setPast(stack => [...stack, text]) onTextChange(next) } // A stopped or superseded run must not write into the field, so both // outcomes go through a run-id check before touching state. function settle(runId: number, next: string) { if (runIdRef.current !== runId) return applyWrite(next) setDraft("") } function fail(runId: number) { if (runIdRef.current !== runId) return setError("Leo could not finish that. Try again.") } async function run(instruction: string) { if (locked || !instruction.trim()) return const runId = ++runIdRef.current // Pinned for the duration: the user can drag a new selection while Leo // thinks, and the result belongs to the span that was asked about. const target = scope setRunning(true) setError(null) try { const result = await onRun({ mode, instruction, text: scopedText, selection: target, }) settle( runId, target ? text.slice(0, target.start) + result + text.slice(target.end) : result, ) } catch { fail(runId) } finally { if (runIdRef.current === runId) setRunning(false) } } function stop() { runIdRef.current += 1 setRunning(false) } function handleKeyDown(event: React.KeyboardEvent) { // Escape is the way back out of the expanded bar. It only closes once the // instruction is empty, so a half-typed thought is never thrown away by a // keystroke the user meant for a menu. if (event.key === "Escape" && collapsible && !running) { if (draft) { event.preventDefault() setDraft("") return } event.preventDefault() setOpen(false) return } if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") { return } if (locked) return event.preventDefault() if (event.shiftKey) redo() else undo() } /** * History rides after the more menu, once Leo has written. * * Undo and redo are always two separate buttons (never one control that * flips). They appear together after the first Leo write; the inactive side * stays visible and disabled so the pair does not read as a toggle. */ const historyActions = !running && (canUndo || canRedo) ? ( <> {/* Actions act on the instruction; history acts on the field. */} {modeActions.length > 0 ? ( ) : null}