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.
158 lines
5.1 KiB
TypeScript
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>
|
|
)
|
|
}
|