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 = (
<>