Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s
A provider set under Settings (OpenAI-compatible; key encrypted in flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs the FastMCP tools in-process as the person chatting, streams the rounds over SSE and keeps one transcript per user. The panel floats on any screen and shares the editor's lane: a settings panel keeps the top half, the chat the bottom. The MCP client is now built once, for the endpoint and the chat. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
326 lines
8.9 KiB
TypeScript
326 lines
8.9 KiB
TypeScript
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 (
|
|
<Tooltip open={open} onOpenChange={setOpen}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={`About ${label}`}
|
|
className="inline-flex size-11 shrink-0 items-center justify-center text-muted-foreground md:size-5"
|
|
onClick={() => setOpen((was) => !was)}
|
|
>
|
|
<Info className="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs text-balance">
|
|
{children}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="flex items-center gap-1">
|
|
<Label htmlFor={htmlFor} className="text-sm font-normal">
|
|
{children}
|
|
</Label>
|
|
{help ? <InfoTip label={children}>{help}</InfoTip> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className={cn("grid gap-2", className)}>
|
|
<div className="flex items-center gap-1">
|
|
<span className={PANEL_SECTION}>{title}</span>
|
|
{help ? <InfoTip label={title}>{help}</InfoTip> : null}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<>
|
|
<Input
|
|
value={draft}
|
|
placeholder={placeholder}
|
|
aria-label={label}
|
|
autoComplete="off"
|
|
className="h-8 min-w-0 flex-1 text-sm font-medium"
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && changed) onConfirm(next)
|
|
}}
|
|
/>
|
|
{changed ? (
|
|
<Button
|
|
size="sm"
|
|
className="h-8 shrink-0"
|
|
onClick={() => onConfirm(next)}
|
|
data-testid="confirm-rename"
|
|
>
|
|
Confirm
|
|
</Button>
|
|
) : 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) => {
|
|
if (event.key === "Escape") onClose()
|
|
}
|
|
window.addEventListener("keydown", onKey)
|
|
return () => window.removeEventListener("keydown", onKey)
|
|
}, [open, onClose])
|
|
|
|
const contents = (
|
|
<>
|
|
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
|
|
{header}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="shrink-0 text-muted-foreground"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col",
|
|
expanded ? "overflow-hidden" : "overflow-y-auto",
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{footer ? (
|
|
<div className="shrink-0 border-t border-border px-4 py-3">
|
|
{footer}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
|
|
<SheetContent
|
|
side="right"
|
|
data-testid={testId}
|
|
// The panel header carries its own close button, and opening should
|
|
// not drop the caret into the first field.
|
|
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
|
|
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
>
|
|
<SheetTitle className="sr-only">{label}</SheetTitle>
|
|
{open ? (
|
|
<div key={bodyKey} className="contents">
|
|
{contents}
|
|
</div>
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open ? (
|
|
<motion.aside
|
|
key={bodyKey}
|
|
variants={panelSlide}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
role="complementary"
|
|
aria-label={label}
|
|
data-testid={testId}
|
|
data-expanded={expanded || undefined}
|
|
className={cn(
|
|
"pointer-events-auto z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
|
|
fixed ? "fixed" : "absolute",
|
|
expanded
|
|
? // Still a floating surface, given the whole inset: the toolbar
|
|
// and the flow-name box translate off screen while it is open.
|
|
"inset-4"
|
|
: "right-4 w-[400px]",
|
|
!expanded &&
|
|
(half === "top"
|
|
? "top-4 bottom-[calc(50%+0.5rem)]"
|
|
: half === "bottom"
|
|
? "top-[calc(50%+0.5rem)] bottom-4"
|
|
: "inset-y-4"),
|
|
)}
|
|
>
|
|
{contents}
|
|
</motion.aside>
|
|
) : null}
|
|
</AnimatePresence>
|
|
)
|
|
}
|