import { Info, X } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import type { ReactNode } from "react" import { useEffect, useState } from "react" import { chatStore, useChat } from "@/components/Chat/chatStore" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { useIsMobile } from "@/hooks/useMobile" import { duration, easeEmphasized, easeStandard } from "@/lib/motion" import { cn } from "@/lib/utils" /** Same grammar as the shared `slideUp`, on the axis this panel travels. */ const panelSlide = { hidden: { opacity: 0, x: 16 }, visible: { opacity: 1, x: 0, transition: { duration: duration.base, ease: easeEmphasized }, }, exit: { opacity: 0, x: 16, transition: { duration: duration.fast, ease: easeStandard }, }, } export const PANEL_SECTION = "text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground" /** * The explanation behind a caption, one tap or one hover away. * * Panels label their controls and put the rest here, so a panel reads as a * list of settings rather than as a page of prose. Keep it to a sentence or * two: a tooltip nobody can scroll is the wrong home for more. * * `open` is held here because a Radix tooltip never opens on touch — it * ignores a touch pointer entirely — and below `md` the whole panel is a * full-screen sheet, so a phone would get the caption and nothing else. * Clicking toggles it; hover still drives it on a pointer device. */ export function InfoTip({ label, children, }: { label: string children: ReactNode }) { const [open, setOpen] = useState(false) return ( {children} ) } /** * A field's label, with its explanation behind an `InfoTip` rather than in a * paragraph under the control. Sits inside the usual `grid gap-1.5` block. */ export function FieldLabel({ children, help, htmlFor, }: { children: string help?: ReactNode htmlFor?: string }) { return (
{help ? {help} : null}
) } /** * A captioned block in a panel: the caption, its explanation behind an * `InfoTip`, and the controls under both. */ export function PanelSection({ title, help, className, children, }: { title: string help?: ReactNode className?: string children: ReactNode }) { return (
{title} {help ? {help} : null}
{children}
) } /** * What the thing in this panel is called, and the one place to rename it. * * Renaming is an explicit act: the field keeps what is typed until it is * confirmed, so a half-typed name never reaches the canvas. */ export function PanelTitle({ value, placeholder, label, onConfirm, }: { value: string placeholder?: string /** Names the field for screen readers. */ label: string onConfirm: (next: string) => void }) { const [draft, setDraft] = useState(value) // Another thing to name is another field. useEffect(() => setDraft(value), [value]) const next = draft.trim() const changed = next !== value return ( <> setDraft(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && changed) onConfirm(next) }} /> {changed ? ( ) : null} ) } /** * The editor's settings panel: floating over the canvas so the graph stays * visible and running behind it, a full-screen sheet where there is no room * for that. * * Node settings and flow settings share it, so the two read as one surface. * The assistant shares it too, and the lane: while it is open, a settings * panel keeps the top half and the chat takes the bottom. */ export function SidePanel({ open, label, testId, bodyKey, expanded = false, fixed = false, stacked, header, footer, children, onClose, }: { open: boolean /** Names the panel for screen readers. */ label: string testId: string /** Remounts the contents when the thing being edited changes. */ bodyKey: string /** Take the width a code editor needs, still floating over the canvas. */ expanded?: boolean /** Pinned to the viewport rather than the canvas: a panel on any screen. */ fixed?: boolean /** * Which half of the lane to keep. Left unset, the panel counts itself as * open and keeps the top whenever the assistant is open beside it. */ stacked?: "top" | "bottom" | false header: ReactNode footer?: ReactNode children: ReactNode onClose: () => void }) { const isMobile = useIsMobile() const chat = useChat() const shares = stacked === undefined useEffect(() => { if (!shares || !open || isMobile) return return chatStore.registerPanel() }, [shares, open, isMobile]) const half = shares ? (chat.open ? "top" : false) : stacked useEffect(() => { if (!open) return const onKey = (event: KeyboardEvent) => { // A dialog opened from the panel closes on its own Escape and marks it // handled; that one press should not take the panel with it. if (event.key === "Escape" && !event.defaultPrevented) onClose() } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [open, onClose]) const contents = ( <>
{header}
{children}
{footer ? (
{footer}
) : null} ) if (isMobile) { return ( !next && onClose()}> event.preventDefault()} > {label} {open ? (
{contents}
) : null}
) } return ( {open ? ( {contents} ) : null} ) }