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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 16:43:41 +02:00
co-authored by Claude Fable 5
parent 929bb56662
commit fa12ffd323
9 changed files with 477 additions and 222 deletions
+45 -8
View File
@@ -1,4 +1,4 @@
import { Terminal } from "lucide-react"
import { Terminal, X } from "lucide-react"
import { useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
@@ -30,12 +30,33 @@ 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 }: { flow: string }) {
const lines = useLiveLogs().filter((line) => line.flow === flow)
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.
@@ -45,7 +66,7 @@ export function LogsPanel({ flow }: { flow: string }) {
}, [lines.length])
return (
<Popover>
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -65,9 +86,23 @@ export function LogsPanel({ flow }: { flow: string }) {
<PopoverContent align="center" className="w-[28rem] p-0">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Logs
</p>
<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"
@@ -81,7 +116,9 @@ export function LogsPanel({ flow }: { flow: string }) {
{lines.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Nothing yet. Anything a node prints shows up here.
{node
? `Nothing from ${node} yet.`
: "Nothing yet. Anything a node prints shows up here."}
</p>
) : (
<ScrollArea className="h-72">