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(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 ( <> What this flow printed {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.

Logs

{node ? ( ) : null}
{lines.length === 0 ? (

{node ? `Nothing from ${node} yet.` : "Nothing yet. Anything a node prints shows up here."}

) : (
    {lines.map((line, index) => (
  • {shortTime(line.ts)}{" "} {nodeLabel(line.node, flow)} {line.text.replace(/\n+$/, "")} {line.truncated ? "\n… truncated" : ""}
  • ))}
)}
) : null}
) }