Let one effect own the flow canvas viewport
Three things moved the viewport independently — the shape-fit effect, focusNode, and React Flow's own fitView prop — so a fourth for "centre the node I just selected" would have been a fourth party to the argument. There is one effect now, and which branch it takes is decided by what changed rather than by what is true: selecting a node brings that node into the lane the panel leaves, and every other change — new wiring, a new endpoint, a panel opening — re-fits the whole flow into the same lane. A selection centres once, so the port edits that follow re-fit around it, which is what makes a new edge's far end visible. The refit triggers on the edge count, not the bindings key: that key changes on every keystroke in a message-name field, and refitting per character is not what "an edge was created" means. renderedNodes overwrote xyflow's own `selected` flag, so a box-selection of several nodes was invisible even though delete and copy acted on all of them. The logs panel was a popover anchored on its own button, which is why it sat off centre, hugged the button and closed on any outside click. It is a plain surface above the dock now, and the button is stateful. Escape still closes it. Expanding a node's editor gives the panel the whole inset and puts the code on the left with the settings beside it, while the toolbar and the flow name translate off screen. Narrowing the window past `md` gives the room back — the sheet it becomes has no second column to hold. The zoom buttons are gone: there is a mouse, or there is a pinch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
@@ -3,6 +3,7 @@ import type { ReactNode } from "react"
|
|||||||
|
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||||
import { slideUp, transitions } from "@/lib/motion"
|
import { slideUp, transitions } from "@/lib/motion"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What you are looking at, floating top-centre over a full-bleed canvas.
|
* What you are looking at, floating top-centre over a full-bleed canvas.
|
||||||
@@ -16,7 +17,14 @@ import { slideUp, transitions } from "@/lib/motion"
|
|||||||
* a panel alongside others is edited with that panel's rail on screen, because
|
* a panel alongside others is edited with that panel's rail on screen, because
|
||||||
* the wall has it too and it takes room off the canvas.
|
* the wall has it too and it takes room off the canvas.
|
||||||
*/
|
*/
|
||||||
export function CanvasTitle({ children }: { children: ReactNode }) {
|
export function CanvasTitle({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
/** Lets the shell move the bar out of the way. */
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={slideUp}
|
variants={slideUp}
|
||||||
@@ -24,7 +32,10 @@ export function CanvasTitle({ children }: { children: ReactNode }) {
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={transitions.emphasized}
|
transition={transitions.emphasized}
|
||||||
className="pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md"
|
className={cn(
|
||||||
|
"pointer-events-auto absolute left-1/2 top-4 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* The sidebar carries its own collapse control; a phone has no sidebar
|
{/* The sidebar carries its own collapse control; a phone has no sidebar
|
||||||
on screen to carry it. */}
|
on screen to carry it. */}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import {
|
|||||||
StepForward,
|
StepForward,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
X,
|
X,
|
||||||
ZoomIn,
|
|
||||||
ZoomOut,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
@@ -43,6 +41,20 @@ import { useLiveConnection } from "./liveStore"
|
|||||||
*/
|
*/
|
||||||
export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
|
export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same fit with the settings panel's lane held clear, so an opening panel
|
||||||
|
* never lands on the node you are looking at. 432px is the lane the chrome is
|
||||||
|
* pushed out of by `md:right-[27rem]`; xyflow's padding parser takes px and %,
|
||||||
|
* so it cannot be spelled in rem. 10% per side is what `padding: 0.25` above
|
||||||
|
* resolves to, so the two fits are equally generous.
|
||||||
|
*/
|
||||||
|
export const FIT_VIEW_PANEL = {
|
||||||
|
maxZoom: FIT_VIEW.maxZoom,
|
||||||
|
// `as const` because xyflow types each side as `${number}px | ${number}%`,
|
||||||
|
// which a widened `string` does not satisfy.
|
||||||
|
padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The action bar, floating bottom-centre. Run is the one brand-secondary
|
* The action bar, floating bottom-centre. Run is the one brand-secondary
|
||||||
* affordance on this view; everything else stays quiet.
|
* affordance on this view; everything else stays quiet.
|
||||||
@@ -51,7 +63,7 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
|
|||||||
* saved, the flow's own settings, and putting it live or throwing it away —
|
* saved, the flow's own settings, and putting it live or throwing it away —
|
||||||
* so the top of the canvas is left to say which flow this is.
|
* so the top of the canvas is left to say which flow this is.
|
||||||
*
|
*
|
||||||
* It wraps rather than overflows, and drops the zoom controls on a phone; see
|
* It wraps rather than overflows, and drops the fit control on a phone; see
|
||||||
* DESIGN-GUIDELINES.md → Responsive.
|
* DESIGN-GUIDELINES.md → Responsive.
|
||||||
*/
|
*/
|
||||||
export function FlowDock({
|
export function FlowDock({
|
||||||
@@ -73,6 +85,7 @@ export function FlowDock({
|
|||||||
onEditFlow,
|
onEditFlow,
|
||||||
onPublish,
|
onPublish,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
flow: string
|
flow: string
|
||||||
issues: ValidationIssue[]
|
issues: ValidationIssue[]
|
||||||
@@ -93,8 +106,9 @@ export function FlowDock({
|
|||||||
onEditFlow: () => void
|
onEditFlow: () => void
|
||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
onDiscard: () => void
|
onDiscard: () => void
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const connected = useLiveConnection()
|
const connected = useLiveConnection()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,7 +121,10 @@ export function FlowDock({
|
|||||||
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
// Capped and wrapping: the canvas shell clips, so an uncapped row would
|
||||||
// put the buttons at its ends out of reach on a phone rather than merely
|
// put the buttons at its ends out of reach on a phone rather than merely
|
||||||
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
|
||||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
className={cn(
|
||||||
|
"pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -130,17 +147,8 @@ export function FlowDock({
|
|||||||
className="mx-0.5 !h-5 hidden md:block"
|
className="mx-0.5 !h-5 hidden md:block"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* A phone pinches to zoom and the graph fits itself, so these three
|
{/* A phone pinches to zoom and the graph fits itself, so this would only
|
||||||
would only be taking room the rest of the bar needs. */}
|
be taking room the rest of the bar needs. */}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
|
||||||
onClick={() => zoomOut()}
|
|
||||||
aria-label="Zoom out"
|
|
||||||
>
|
|
||||||
<ZoomOut />
|
|
||||||
</Button>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -155,15 +163,6 @@ export function FlowDock({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Fit to screen</TooltipContent>
|
<TooltipContent>Fit to screen</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="hidden text-muted-foreground md:inline-flex md:size-8"
|
|
||||||
onClick={() => zoomIn()}
|
|
||||||
aria-label="Zoom in"
|
|
||||||
>
|
|
||||||
<ZoomIn />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{issues.length > 0 ? (
|
{issues.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import {
|
|||||||
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
import { EdgeInspector, type InspectedEdge } from "./EdgeInspector"
|
||||||
import { EndpointNode } from "./EndpointNode"
|
import { EndpointNode } from "./EndpointNode"
|
||||||
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints"
|
||||||
import { FIT_VIEW, FlowDock } from "./FlowDock"
|
import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock"
|
||||||
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
import { FlowNode, type FlowNodeData } from "./FlowNode"
|
||||||
import { FlowPanel } from "./FlowPanel"
|
import { FlowPanel } from "./FlowPanel"
|
||||||
import { LiveEdge } from "./LiveEdge"
|
import { LiveEdge } from "./LiveEdge"
|
||||||
@@ -352,7 +352,7 @@ function FlowEditorInner({
|
|||||||
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
|
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
selected: node.id === selectedId,
|
selected: node.selected || node.id === selectedId,
|
||||||
data: {
|
data: {
|
||||||
definition: definition ?? { id: node.id },
|
definition: definition ?? { id: node.id },
|
||||||
flow: flowName,
|
flow: flowName,
|
||||||
@@ -484,9 +484,17 @@ function FlowEditorInner({
|
|||||||
[key, flowName, detail.endpoints],
|
[key, flowName, detail.endpoints],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isMobile = useIsMobile()
|
||||||
// Which way the graph runs. A phone has height to spare and no width, so it
|
// Which way the graph runs. A phone has height to spare and no width, so it
|
||||||
// reads top to bottom; everything else reads left to right.
|
// reads top to bottom; everything else reads left to right.
|
||||||
const direction: Direction = useIsMobile() ? "TB" : "LR"
|
const direction: Direction = isMobile ? "TB" : "LR"
|
||||||
|
|
||||||
|
// Expanding is a desktop affordance, so a window narrowed past `md` gives the
|
||||||
|
// room back: the sheet it becomes has no second column to hold, and its body
|
||||||
|
// only scrolls while the editor is its normal size.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) setEditorExpanded(false)
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nobody places a node here — the graph lays itself out, endpoints included,
|
* Nobody places a node here — the graph lays itself out, endpoints included,
|
||||||
@@ -554,21 +562,53 @@ function FlowEditorInner({
|
|||||||
])
|
])
|
||||||
}, [key, direction, external, updateNodeInternals])
|
}, [key, direction, external, updateNodeInternals])
|
||||||
|
|
||||||
// A relayout can put a new node outside the viewport, and turning the graph
|
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
||||||
// on its side moves everything. Both want the whole flow back in view.
|
// A panel is the view you are working in, so it takes the room — but never
|
||||||
|
// the lanes the bars sit in: publishing is most wanted right after editing.
|
||||||
|
const panelOpen = Boolean(selected) || flowPanelOpen
|
||||||
|
|
||||||
|
// One effect owns the viewport, so nothing fights over it. Selecting a node
|
||||||
|
// brings that node into the lane the panel leaves; every other change to the
|
||||||
|
// graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow
|
||||||
|
// into the same lane. Which of the two runs is decided by what changed, not by
|
||||||
|
// what is true: a selection centres once, and the port edits that follow it
|
||||||
|
// re-fit around it, because a new edge's far end is what wants to be seen.
|
||||||
// The first fit is instant: an animated one travels from React Flow's
|
// The first fit is instant: an animated one travels from React Flow's
|
||||||
// default viewport to the content, which is the whole flow visibly sliding
|
// default viewport to the content, which is the whole flow visibly sliding
|
||||||
// in from the corner every time one is opened. Later fits move from
|
// in from the corner every time one is opened. Later fits move from
|
||||||
// somewhere the user was already looking, so those stay animated.
|
// somewhere the user was already looking, so those stay animated.
|
||||||
const fitted = useRef(false)
|
const fitted = useRef(false)
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render.
|
const centred = useRef<string | null>(null)
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const focus =
|
||||||
|
selectedId && selectedId !== centred.current ? selectedId : null
|
||||||
|
centred.current = selectedId
|
||||||
|
// A phone's sheet covers the canvas outright, and so does the expanded
|
||||||
|
// editor: there is no viewport to aim.
|
||||||
|
if (editorExpanded) return
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
fitView(fitted.current ? { ...FIT_VIEW, duration: 300 } : FIT_VIEW)
|
const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW
|
||||||
|
const duration = fitted.current ? 300 : 0
|
||||||
|
fitView(
|
||||||
|
focus
|
||||||
|
? { ...view, nodes: [{ id: focus }], duration }
|
||||||
|
: { ...view, duration },
|
||||||
|
)
|
||||||
fitted.current = true
|
fitted.current = true
|
||||||
})
|
})
|
||||||
return () => cancelAnimationFrame(frame)
|
return () => cancelAnimationFrame(frame)
|
||||||
}, [direction, definitions.length, external.nodes.length, fitView])
|
}, [
|
||||||
|
direction,
|
||||||
|
definitions.length,
|
||||||
|
external.nodes.length,
|
||||||
|
edges.length,
|
||||||
|
selectedId,
|
||||||
|
panelOpen,
|
||||||
|
editorExpanded,
|
||||||
|
isMobile,
|
||||||
|
fitView,
|
||||||
|
])
|
||||||
|
|
||||||
const runMutation = useMutation({
|
const runMutation = useMutation({
|
||||||
mutationFn: (inputs: Record<string, unknown> = {}) =>
|
mutationFn: (inputs: Record<string, unknown> = {}) =>
|
||||||
@@ -832,10 +872,9 @@ function FlowEditorInner({
|
|||||||
const id = qualifiedId.startsWith(`${flowName}.`)
|
const id = qualifiedId.startsWith(`${flowName}.`)
|
||||||
? qualifiedId.slice(flowName.length + 1)
|
? qualifiedId.slice(flowName.length + 1)
|
||||||
: qualifiedId
|
: qualifiedId
|
||||||
fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 })
|
|
||||||
setSelectedId(id)
|
setSelectedId(id)
|
||||||
},
|
},
|
||||||
[fitView, flowName],
|
[flowName],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Put the stored draft live. Publishing what is queued means saving first. */
|
/** Put the stored draft live. Publishing what is queued means saving first. */
|
||||||
@@ -941,11 +980,6 @@ function FlowEditorInner({
|
|||||||
["mod+s", "mod+k"],
|
["mod+s", "mod+k"],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selected = definitions.find((node) => node.id === selectedId) ?? null
|
|
||||||
// A panel is the view you are working in, so it takes the room — but never
|
|
||||||
// the lanes the bars sit in: publishing is most wanted right after editing.
|
|
||||||
const panelOpen = Boolean(selected) || flowPanelOpen
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/*
|
{/*
|
||||||
@@ -1045,13 +1079,22 @@ function FlowEditorInner({
|
|||||||
panelOpen && !editorExpanded && "md:right-[27rem]",
|
panelOpen && !editorExpanded && "md:right-[27rem]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CanvasTitle>
|
<CanvasTitle
|
||||||
|
className={cn(
|
||||||
|
"transition-transform duration-200",
|
||||||
|
editorExpanded && "-translate-y-[calc(100%+1rem)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
||||||
{flowDoc.title || flowName}
|
{flowDoc.title || flowName}
|
||||||
</span>
|
</span>
|
||||||
</CanvasTitle>
|
</CanvasTitle>
|
||||||
|
|
||||||
<FlowDock
|
<FlowDock
|
||||||
|
className={cn(
|
||||||
|
"transition-transform duration-200",
|
||||||
|
editorExpanded && "translate-y-[calc(100%+1rem)]",
|
||||||
|
)}
|
||||||
flow={flowName}
|
flow={flowName}
|
||||||
issues={issues}
|
issues={issues}
|
||||||
running={runMutation.isPending}
|
running={runMutation.isPending}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { Terminal, X } from "lucide-react"
|
import { Terminal, X } from "lucide-react"
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
import { slideUp, transitions } from "@/lib/motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { liveStore, useLiveLogs } from "./liveStore"
|
import { liveStore, useLiveLogs } from "./liveStore"
|
||||||
|
|
||||||
@@ -65,93 +62,125 @@ export function LogsPanel({
|
|||||||
bottom.current?.scrollIntoView({ block: "end" })
|
bottom.current?.scrollIntoView({ block: "end" })
|
||||||
}, [lines.length])
|
}, [lines.length])
|
||||||
|
|
||||||
|
// Escape still closes it, like everything else floating over this canvas.
|
||||||
|
// The listener is on the window because the trigger keeps focus after the
|
||||||
|
// click that opened the panel.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onOpenChange(false)
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown)
|
||||||
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
className={cn(
|
||||||
className="size-11 text-muted-foreground md:size-8"
|
"size-11 text-muted-foreground md:size-8",
|
||||||
aria-label="Logs"
|
open && "text-primary",
|
||||||
data-testid="flow-logs"
|
)}
|
||||||
>
|
onClick={() => onOpenChange(!open)}
|
||||||
<Terminal />
|
aria-pressed={open}
|
||||||
</Button>
|
aria-label="Logs"
|
||||||
</PopoverTrigger>
|
data-testid="flow-logs"
|
||||||
|
>
|
||||||
|
<Terminal />
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>What this flow printed</TooltipContent>
|
<TooltipContent>What this flow printed</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<PopoverContent align="center" className="w-[28rem] p-0">
|
<AnimatePresence>
|
||||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
{open ? (
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
// A sibling of the button but positioned against the dock, so it
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
// sits centred above the whole bar and clears it by `mb-3` however
|
||||||
Logs
|
// many rows the bar wrapped into. Deliberately not a popover: a
|
||||||
</p>
|
// click on the canvas is what you do *while* reading the logs, so
|
||||||
{node ? (
|
// only the button or Escape puts them away.
|
||||||
|
<motion.div
|
||||||
|
variants={slideUp}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
exit="exit"
|
||||||
|
transition={transitions.emphasized}
|
||||||
|
className="absolute bottom-full left-1/2 mb-3 w-[28rem] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md"
|
||||||
|
data-testid="logs-panel"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
||||||
|
Logs
|
||||||
|
</p>
|
||||||
|
{node ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
|
||||||
|
onClick={onClearNode}
|
||||||
|
data-testid="clear-logs-filter"
|
||||||
|
>
|
||||||
|
<span className="truncate">{node}</span>
|
||||||
|
<X className="size-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 min-w-0 gap-1 px-2 font-mono text-xs"
|
className="h-6 px-2 text-xs text-muted-foreground"
|
||||||
onClick={onClearNode}
|
onClick={() => liveStore.clearLogs()}
|
||||||
data-testid="clear-logs-filter"
|
disabled={lines.length === 0}
|
||||||
>
|
>
|
||||||
<span className="truncate">{node}</span>
|
Clear
|
||||||
<X className="size-3 shrink-0" />
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs text-muted-foreground"
|
|
||||||
onClick={() => liveStore.clearLogs()}
|
|
||||||
disabled={lines.length === 0}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lines.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
{node
|
{node
|
||||||
? `Nothing from ${node} yet.`
|
? `Nothing from ${node} yet.`
|
||||||
: "Nothing yet. Anything a node prints shows up here."}
|
: "Nothing yet. Anything a node prints shows up here."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-72">
|
<ScrollArea className="h-72">
|
||||||
<ul className="grid gap-1.5 p-3 font-mono text-xs">
|
<ul className="grid gap-1.5 p-3 font-mono text-xs">
|
||||||
{lines.map((line, index) => (
|
{lines.map((line, index) => (
|
||||||
<li
|
<li
|
||||||
// Lines are append-only and repeat freely, so position is the
|
// Lines are append-only and repeat freely, so position is the
|
||||||
// only stable identity they have.
|
// only stable identity they have.
|
||||||
key={`${line.ts}-${line.node}-${index}`}
|
key={`${line.ts}-${line.node}-${index}`}
|
||||||
className="grid grid-cols-[auto_1fr] gap-2"
|
className="grid grid-cols-[auto_1fr] gap-2"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{shortTime(line.ts)}{" "}
|
{shortTime(line.ts)}{" "}
|
||||||
<span className="text-foreground/70">
|
<span className="text-foreground/70">
|
||||||
{nodeLabel(line.node, flow)}
|
{nodeLabel(line.node, flow)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"whitespace-pre-wrap break-words",
|
"whitespace-pre-wrap break-words",
|
||||||
line.level === "error" && "text-destructive",
|
line.level === "error" && "text-destructive",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{line.text.replace(/\n+$/, "")}
|
{line.text.replace(/\n+$/, "")}
|
||||||
{line.truncated ? "\n… truncated" : ""}
|
{line.truncated ? "\n… truncated" : ""}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li ref={bottom} aria-hidden />
|
<li ref={bottom} aria-hidden />
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</PopoverContent>
|
</motion.div>
|
||||||
</Popover>
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -988,8 +988,20 @@ function PanelBody({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div className={cn("grid min-w-0 gap-6 p-4", expanded && "max-w-2xl")}>
|
className={cn(
|
||||||
|
expanded
|
||||||
|
? "flex min-h-0 flex-1 flex-col md:flex-row-reverse"
|
||||||
|
: "contents",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-0 gap-6 p-4",
|
||||||
|
expanded &&
|
||||||
|
"md:w-[400px] md:shrink-0 md:overflow-y-auto md:border-l md:border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<PortList
|
<PortList
|
||||||
title="Consumes"
|
title="Consumes"
|
||||||
specs={node.requires ?? []}
|
specs={node.requires ?? []}
|
||||||
@@ -1055,7 +1067,12 @@ function PanelBody({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSource ? (
|
{hasSource ? (
|
||||||
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 px-4 pb-4",
|
||||||
|
expanded && "md:pt-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={SECTION}>
|
<span className={SECTION}>
|
||||||
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
|
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
|
||||||
@@ -1087,7 +1104,7 @@ function PanelBody({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,12 @@ export function SidePanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col",
|
||||||
|
expanded ? "overflow-hidden" : "overflow-y-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,9 +192,9 @@ export function SidePanel({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
|
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
|
||||||
expanded
|
expanded
|
||||||
? // Still a floating surface, only given the room code needs —
|
? // Still a floating surface, given the whole inset: the toolbar
|
||||||
// and the canvas chrome keeps its lanes above and below.
|
// and the flow-name box translate off screen while it is open.
|
||||||
"inset-x-4 bottom-16 top-16"
|
"inset-4"
|
||||||
: "inset-y-4 right-4 w-[400px]",
|
: "inset-y-4 right-4 w-[400px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -92,9 +92,29 @@ test("the logs panel shows what a node printed and why one failed", async ({
|
|||||||
await page.getByTestId("run-flow").click()
|
await page.getByTestId("run-flow").click()
|
||||||
await page.getByTestId("flow-logs").click()
|
await page.getByTestId("flow-logs").click()
|
||||||
|
|
||||||
const panel = page.locator('[data-slot="popover-content"]')
|
const panel = page.getByTestId("logs-panel")
|
||||||
await expect(panel).toContainText("sensor read 21.5 degrees")
|
await expect(panel).toContainText("sensor read 21.5 degrees")
|
||||||
await expect(panel).toContainText("RuntimeError")
|
await expect(panel).toContainText("RuntimeError")
|
||||||
|
|
||||||
|
// Reading the logs is something you do *while* working on the flow, so a
|
||||||
|
// click on the canvas leaves them up — only the button or Escape closes it.
|
||||||
|
await page.locator(".react-flow__pane").click({ position: { x: 8, y: 8 } })
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
|
||||||
|
// It sits above the dock rather than on it, and shares its centre. The dock
|
||||||
|
// has no box of its own to measure, so its span is its first and last
|
||||||
|
// buttons; its padding is symmetric, so their midpoint is its centre.
|
||||||
|
const logs = (await panel.boundingBox())!
|
||||||
|
const dock = (await page.getByTestId("run-flow").boundingBox())!
|
||||||
|
expect(logs.y + logs.height).toBeLessThan(dock.y)
|
||||||
|
|
||||||
|
const first = (await page.getByTestId("add-node").boundingBox())!
|
||||||
|
const last = (await page.getByTestId("publish-flow").boundingBox())!
|
||||||
|
const centre = (first.x + last.x + last.width) / 2
|
||||||
|
expect(Math.abs(logs.x + logs.width / 2 - centre)).toBeLessThan(2)
|
||||||
|
|
||||||
|
await page.getByTestId("flow-logs").click()
|
||||||
|
await expect(panel).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a flow can be paused and let go again", async ({ page }) => {
|
test("a flow can be paused and let go again", async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user