Files
app/frontend/src/components/Flow/LogsPanel.tsx
T
stroblmeandClaude Opus 5 fdf3c86eae 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
2026-08-21 10:12:10 +02:00

187 lines
6.6 KiB
TypeScript

import { Terminal, X } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { liveStore, useLiveLogs } from "./liveStore"
function shortTime(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString(undefined, {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
/** Drop the flow prefix: every line in here belongs to the open flow. */
function nodeLabel(nodeId: string, flow: string): string {
return nodeId.startsWith(`${flow}.`) ? nodeId.slice(flow.length + 1) : nodeId
}
/** What one node printed, or the whole flow when nothing is singled out. */
export type LogsFilter = {
open: boolean
/** A node id within this flow, as the canvas asked for it. */
node: string | null
onOpenChange: (open: boolean) => void
onClearNode: () => void
}
/**
* What the nodes of this flow printed, and the tracebacks of the ones that
* failed — the detail the one-line error bubble on a node has no room for.
*
* Opening it is not only the dock's to do: a failing node points straight at
* its own traceback, which is why the open state lives in the editor.
*/
export function LogsPanel({
flow,
open,
node,
onOpenChange,
onClearNode,
}: { flow: string } & LogsFilter) {
const lines = useLiveLogs().filter(
(line) =>
line.flow === flow && (!node || nodeLabel(line.node, flow) === node),
)
const bottom = useRef<HTMLLIElement | null>(null)
// Follow the tail, which is where a running flow puts what just happened.
// biome-ignore lint/correctness/useExhaustiveDependencies: a new line is what scrolls.
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" })
}, [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 (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
open && "text-primary",
)}
onClick={() => onOpenChange(!open)}
aria-pressed={open}
aria-label="Logs"
data-testid="flow-logs"
>
<Terminal />
</Button>
</TooltipTrigger>
<TooltipContent>What this flow printed</TooltipContent>
</Tooltip>
<AnimatePresence>
{open ? (
// A sibling of the button but positioned against the dock, so it
// sits centred above the whole bar and clears it by `mb-3` however
// many rows the bar wrapped into. Deliberately not a popover: a
// click on the canvas is what you do *while* reading the logs, so
// 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
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 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
{node
? `Nothing from ${node} yet.`
: "Nothing yet. Anything a node prints shows up here."}
</p>
) : (
<ScrollArea className="h-72">
<ul className="grid gap-1.5 p-3 font-mono text-xs">
{lines.map((line, index) => (
<li
// Lines are append-only and repeat freely, so position is the
// only stable identity they have.
key={`${line.ts}-${line.node}-${index}`}
className="grid grid-cols-[auto_1fr] gap-2"
>
<span className="text-muted-foreground">
{shortTime(line.ts)}{" "}
<span className="text-foreground/70">
{nodeLabel(line.node, flow)}
</span>
</span>
<span
className={cn(
"whitespace-pre-wrap break-words",
line.level === "error" && "text-destructive",
)}
>
{line.text.replace(/\n+$/, "")}
{line.truncated ? "\n… truncated" : ""}
</span>
</li>
))}
<li ref={bottom} aria-hidden />
</ul>
</ScrollArea>
)}
</motion.div>
) : null}
</AnimatePresence>
</>
)
}