Files
app/frontend/src/components/Flow/LogsPanel.tsx
T
stroblme bfa8d46bd9 Copy nodes, bind the keys, and keep publish within reach
The floating bars used to make way for a side panel, which hid Publish
exactly when a node had just been edited. They keep their lane now and
the panel is inset out of it; the enlarged code editor stays a floating
surface between the two bars rather than covering them.

Shortcuts are one small registry (`lib/shortcuts.ts`): undo, redo, ⌘K,
copy, paste, and ⌘S — which publishes the flow, or applies the code when
the editor has focus. Copying carries a node's own source through
localStorage, so a paste works in another flow too.

A flow is now named where a node is, at the top of its panel and only
there, and both take effect on Confirm. Flow-level edits go through the
undo stack with everything else, clicking the canvas puts the flow panel
away, a failing node opens the logs filtered to its own traceback, and
the panel carries its test id on a phone as well.
2026-08-16 16:43:41 +02:00

158 lines
5.1 KiB
TypeScript

import { Terminal, X } from "lucide-react"
import { useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
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])
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
aria-label="Logs"
data-testid="flow-logs"
>
<Terminal />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>What this flow printed</TooltipContent>
</Tooltip>
<PopoverContent align="center" className="w-[28rem] p-0">
<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>
)}
</PopoverContent>
</Popover>
)
}