Add flow settings, pulse emitting nodes, and simplify node state
- One dot per node now carries the whole story: primary while running, sage after a good run, red when anything is wrong, with the explanation on hover. The corner badge is gone, along with the second way of saying the same thing. - A node that publishes something flashes a ring, so a running flow is legible without reading the edge values. Nodes that consume but publish nothing stay quiet, which is why the event carries an output count. - Flow settings open in the same panel its nodes use, from a pencil in the dock: the title, the name, and deleting the flow. NodePanel and FlowPanel share the panel chrome rather than each drawing their own. - Renaming is a server operation, because a flow's name is the namespace of its messages: the directory moves and every other flow reading `old.message` is repointed, instead of being left pointing at a flow that no longer exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
co-authored by
Claude Fable 5
parent
c254d487ba
commit
fd666743d2
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { X } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
||||
@@ -22,33 +21,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { duration, easeEmphasized, easeStandard } from "@/lib/motion"
|
||||
import { nodeSourceQueryOptions } from "./queries"
|
||||
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
||||
|
||||
const NodeEditor = lazy(() => import("./NodeEditor"))
|
||||
|
||||
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
|
||||
|
||||
const SECTION =
|
||||
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
||||
|
||||
/** 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 },
|
||||
},
|
||||
}
|
||||
const SECTION = PANEL_SECTION
|
||||
|
||||
/**
|
||||
* A message name, typed freely or picked from the names already in play.
|
||||
@@ -297,8 +278,6 @@ function PanelBody({
|
||||
suggestions,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: {
|
||||
node: NodeDef_Input
|
||||
flow: string
|
||||
@@ -306,8 +285,6 @@ function PanelBody({
|
||||
suggestions: PortSuggestions
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const hasSource = nodeType?.has_source ?? node.type === "python"
|
||||
const { data: source } = useQuery({
|
||||
@@ -343,78 +320,47 @@ function PanelBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Input
|
||||
value={node.title || node.id}
|
||||
aria-label="Node name"
|
||||
className="h-8 flex-1 text-sm font-medium"
|
||||
onChange={(event) => onChange({ ...node, title: event.target.value })}
|
||||
<div className="grid gap-5 p-4">
|
||||
<PortList
|
||||
title="Consumes"
|
||||
specs={node.requires ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
suggestions={suggestions.consumes}
|
||||
onChange={(requires) => onChange({ ...node, requires })}
|
||||
/>
|
||||
<PortList
|
||||
title="Provides"
|
||||
specs={node.provides ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => onChange({ ...node, provides })}
|
||||
/>
|
||||
<ParamsForm
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
onChange={(params) => onChange({ ...node, params })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="grid gap-5 p-4">
|
||||
<PortList
|
||||
title="Consumes"
|
||||
specs={node.requires ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
suggestions={suggestions.consumes}
|
||||
onChange={(requires) => onChange({ ...node, requires })}
|
||||
/>
|
||||
<PortList
|
||||
title="Provides"
|
||||
specs={node.provides ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => onChange({ ...node, provides })}
|
||||
/>
|
||||
<ParamsForm
|
||||
schema={nodeType?.params_schema}
|
||||
params={node.params ?? {}}
|
||||
onChange={(params) => onChange({ ...node, params })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSource ? (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
||||
<span className={SECTION}>Code</span>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full w-full animate-pulse bg-muted" />
|
||||
}
|
||||
>
|
||||
<NodeEditor
|
||||
value={code ?? source?.code ?? ""}
|
||||
onChange={editCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
{hasSource ? (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
||||
<span className={SECTION}>Code</span>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full w-full animate-pulse bg-muted" />
|
||||
}
|
||||
>
|
||||
<NodeEditor
|
||||
value={code ?? source?.code ?? ""}
|
||||
onChange={editCode}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete node
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -445,73 +391,48 @@ export function NodePanel({
|
||||
onClose: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const nodeType = nodeTypes.find((entry) => entry.type === node?.type)
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [node, onClose])
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={Boolean(node)} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
// The panel header carries its own close button, and opening should
|
||||
// not drop the caret into the node's name.
|
||||
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">Node settings</SheetTitle>
|
||||
{node ? (
|
||||
<PanelBody
|
||||
key={node.id}
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
suggestions={suggestions}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{node ? (
|
||||
<motion.aside
|
||||
key={node.id}
|
||||
variants={panelSlide}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
role="complementary"
|
||||
aria-label="Node settings"
|
||||
data-testid="node-panel"
|
||||
className="pointer-events-auto absolute inset-y-4 right-4 z-10 flex w-[400px] flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<PanelBody
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
suggestions={suggestions}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
<SidePanel
|
||||
open={Boolean(node)}
|
||||
label="Node settings"
|
||||
testId="node-panel"
|
||||
bodyKey={node?.id ?? "none"}
|
||||
onClose={onClose}
|
||||
header={
|
||||
node ? (
|
||||
<Input
|
||||
value={node.title || node.id}
|
||||
aria-label="Node name"
|
||||
className="h-8 flex-1 text-sm font-medium"
|
||||
onChange={(event) =>
|
||||
onChange({ ...node, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
</motion.aside>
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete node
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{node ? (
|
||||
<PanelBody
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
suggestions={suggestions}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</SidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user