From fa12ffd323068f764d3d07071926e820a6e62bcd Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 16:43:41 +0200 Subject: [PATCH] Copy nodes, bind the keys, and keep publish within reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A --- .../src/components/Flow/CommandPalette.tsx | 12 - frontend/src/components/Flow/FlowDock.tsx | 7 +- frontend/src/components/Flow/FlowEditor.tsx | 374 +++++++++++------- frontend/src/components/Flow/FlowNode.tsx | 29 +- frontend/src/components/Flow/FlowPanel.tsx | 61 +-- frontend/src/components/Flow/LogsPanel.tsx | 53 ++- frontend/src/components/Flow/NodePanel.tsx | 32 +- frontend/src/components/Flow/SidePanel.tsx | 67 +++- frontend/src/lib/shortcuts.ts | 64 +++ 9 files changed, 477 insertions(+), 222 deletions(-) create mode 100644 frontend/src/lib/shortcuts.ts diff --git a/frontend/src/components/Flow/CommandPalette.tsx b/frontend/src/components/Flow/CommandPalette.tsx index 91f3edf..57b95cd 100644 --- a/frontend/src/components/Flow/CommandPalette.tsx +++ b/frontend/src/components/Flow/CommandPalette.tsx @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" -import { useEffect } from "react" import type { FlowSummary, NodeTypeInfo } from "@/client" import { @@ -40,17 +39,6 @@ export function CommandPalette({ enabled: open, }) - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === "k" && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - onOpenChange(!open) - } - } - window.addEventListener("keydown", onKey) - return () => window.removeEventListener("keydown", onKey) - }, [open, onOpenChange]) - // Picking an item re-renders the canvas, which can interrupt the dialog's // exit animation and leave its overlay swallowing clicks. Unmounting the // dialog outright is deterministic; the palette does not need to fade out. diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index e95799a..24bcf2c 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -27,7 +27,7 @@ import { } from "@/components/ui/tooltip" import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" -import { LogsPanel } from "./LogsPanel" +import { type LogsFilter, LogsPanel } from "./LogsPanel" /** * What "fit" means on this canvas: the view a flow opens with, and the one the @@ -47,6 +47,7 @@ export function FlowDock({ running, enabled, paused, + logs, onAddNode, onRun, onTogglePause, @@ -57,6 +58,8 @@ export function FlowDock({ running: boolean enabled: boolean paused: boolean + /** Owned by the editor, because a failing node can open it too. */ + logs: LogsFilter onAddNode: () => void onRun: () => void onTogglePause: () => void @@ -164,7 +167,7 @@ export function FlowDock({ - + diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index 896d8b7..d7af5a9 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -19,7 +19,6 @@ import { } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Workflow } from "lucide-react" -import { AnimatePresence } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { @@ -39,6 +38,8 @@ import { DialogTitle, } from "@/components/ui/dialog" import useCustomToast from "@/hooks/useCustomToast" +import { inCodeEditor, useShortcuts } from "@/lib/shortcuts" +import { cn } from "@/lib/utils" import { CommandPalette } from "./CommandPalette" import { bindingsKey, deriveEdges, portOf, qualify } from "./deriveEdges" import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" @@ -94,8 +95,8 @@ type MessageRename = { /** Documents to step back and forward through, newest last. */ type History = { - past: NodeDef_Input[][] - future: NodeDef_Input[][] + past: FlowDef_Input[] + future: FlowDef_Input[] /** When the last entry was recorded, so a burst of typing stays one edit. */ at: number } @@ -103,6 +104,16 @@ type History = { const HISTORY_LIMIT = 50 const TYPING_WINDOW = 500 +/** Nodes copied here, and the code of the ones carrying their own. */ +type NodeClipboard = { + nodes: NodeDef_Input[] + sources: Record +} + +// ponytail: localStorage, so a copy survives a flow switch or a second tab but +// not a second browser. The system clipboard if that ever matters. +const CLIPBOARD_KEY = "fluksio.nodeClipboard" + /** * Remember the document as it was before a change. * @@ -112,13 +123,15 @@ const TYPING_WINDOW = 500 */ function record( history: History, - previous: NodeDef_Input[], - next: NodeDef_Input[], + previous: FlowDef_Input, + next: FlowDef_Input, settled: boolean, ) { + const before = previous.nodes ?? [] + const after = next.nodes ?? [] const sameNodes = - previous.length === next.length && - previous.every((node, index) => node.id === next[index].id) + before.length === after.length && + before.every((node, index) => node.id === after[index].id) const stillTyping = !settled && sameNodes && Date.now() - history.at < TYPING_WINDOW @@ -131,15 +144,6 @@ function record( history.future = [] } -/** Monaco and every text field keep their own undo, so leave theirs alone. */ -function isTextEntry(target: EventTarget | null): boolean { - return Boolean( - (target as Element | null)?.closest?.( - "input, textarea, [contenteditable='true'], .monaco-editor", - ), - ) -} - function toCanvasNodes(definitions: NodeDef_Input[]): FlowCanvasNode[] { return definitions.map((node) => ({ id: node.id, @@ -225,9 +229,10 @@ function FlowEditorInner({ const publish = usePublish(flowName) const discard = useDiscardDraft(flowName) - const [definitions, setDefinitions] = useState( - () => detail.definition.nodes ?? [], - ) + // The whole working document, so a flow-level edit is an edit like any + // other — undoable, and on screen before the server has seen it. + const [flowDoc, setFlowDoc] = useState(() => detail.definition) + const definitions = useMemo(() => flowDoc.nodes ?? [], [flowDoc]) const [canvasNodes, setCanvasNodes, onNodesChange] = useUnpositionedNodes( detail.definition.nodes ?? [], ) @@ -237,8 +242,12 @@ function FlowEditorInner({ const [rebind, setRebind] = useState(null) const [renamed, setRenamed] = useState(null) const [flowPanelOpen, setFlowPanelOpen] = useState(false) - // The editor at full size covers the canvas, so its chrome steps aside. + // The editor at full size takes the width the canvas chrome does not need. const [editorExpanded, setEditorExpanded] = useState(false) + // The dock hosts the logs, but a failing node opens them too, at its own + // lines — so which node and whether it is open belong here. + const [logsOpen, setLogsOpen] = useState(false) + const [logsNode, setLogsNode] = useState(null) const issues = detail.issues ?? [] const paused = useFlowPaused(flowName) @@ -247,33 +256,35 @@ function FlowEditorInner({ const latest = useRef(detail.definition) const history = useRef({ past: [], future: [], at: 0 }) - /** Put a set of nodes on the canvas and on their way to the server. */ - const apply = useCallback( + /** Put a document on the canvas and on its way to the server. */ + const applyDoc = useCallback( + (next: FlowDef_Input) => { + setFlowDoc(next) + latest.current = next + save(next) + }, + [save], + ) + + /** Apply a change to the whole document and make it undoable. */ + const commitDoc = useCallback( + (next: FlowDef_Input, settled = false) => { + record(history.current, latest.current, next, settled) + applyDoc(next) + }, + [applyDoc], + ) + + /** The same, for the changes that only touch the nodes. */ + const commit = useCallback( (nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => { const placed = nodes.map((node) => { const canvas = (positions ?? canvasNodes).find((n) => n.id === node.id) return canvas ? { ...node, position: canvas.position } : node }) - setDefinitions(placed) - const next: FlowDef_Input = { ...detail.definition, nodes: placed } - latest.current = next - save(next) + commitDoc({ ...latest.current, nodes: placed }, Boolean(positions)) }, - [canvasNodes, detail.definition, save], - ) - - /** Apply a change and make it undoable. */ - const commit = useCallback( - (nodes: NodeDef_Input[], positions?: FlowCanvasNode[]) => { - record( - history.current, - latest.current.nodes ?? [], - nodes, - Boolean(positions), - ) - apply(nodes, positions) - }, - [apply], + [canvasNodes, commitDoc], ) /** @@ -285,35 +296,32 @@ function FlowEditorInner({ const { past, future } = history.current const remembered = (back ? past : future).pop() if (!remembered) return - ;(back ? future : past).push(latest.current.nodes ?? []) + ;(back ? future : past).push(latest.current) history.current.at = 0 // xyflow owns the positions, so hand it the remembered ones too. - const canvas = toCanvasNodes(remembered) - setCanvasNodes(canvas) - apply(remembered, canvas) + setCanvasNodes(toCanvasNodes(remembered.nodes ?? [])) + applyDoc(remembered) setInspected(null) }, - [apply, setCanvasNodes], + [applyDoc, setCanvasNodes], ) - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - // Shift makes it a capital Z, so compare on the letter alone. - const undoKey = - (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" - if (!undoKey || isTextEntry(event.target)) return - event.preventDefault() - step(!event.shiftKey) - } - window.addEventListener("keydown", onKeyDown) - return () => window.removeEventListener("keydown", onKeyDown) - }, [step]) - const typeLabels = useMemo( () => new Map((nodeTypeInfo ?? []).map((info) => [info.type, info.title])), [nodeTypeInfo], ) + // Which types bring code of their own, so a copy has something to carry. + const sourceTypes = useMemo( + () => + new Set( + (nodeTypeInfo ?? []) + .filter((info) => info.has_source) + .map((info) => info.type), + ), + [nodeTypeInfo], + ) + // Which types came from an installed connector rather than the engine. They // cannot be in the icon map, so they share one. const pluginTypes = useMemo( @@ -337,6 +345,11 @@ function FlowEditorInner({ return map }, [issues]) + const showLogs = useCallback((nodeId: string) => { + setLogsNode(nodeId) + setLogsOpen(true) + }, []) + // Canvas nodes carry the definition so the node component can render it. const renderedNodes = useMemo( () => @@ -353,6 +366,7 @@ function FlowEditorInner({ typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", isPlugin: pluginTypes.has(definition?.type ?? ""), issueText: nodeIssues.join("\n"), + onShowLogs: showLogs, } satisfies FlowNodeData, } }), @@ -363,6 +377,7 @@ function FlowEditorInner({ issuesByNode, pluginTypes, selectedId, + showLogs, typeLabels, ], ) @@ -492,25 +507,6 @@ function FlowEditorInner({ onError: () => showErrorToast("The flow could not be paused."), }) - const renameMutation = useMutation({ - mutationFn: (newName: string) => - FlowsService.renameFlow({ - name: flowName, - requestBody: { new_name: newName }, - }), - onSuccess: (detail) => { - queryClient.invalidateQueries({ queryKey: flowKeys.all }) - setFlowPanelOpen(false) - navigate({ - to: "/flows/$flowName", - params: { flowName: detail.definition.name }, - replace: true, - }) - }, - onError: () => - showErrorToast("That name is taken, or is not a valid flow name."), - }) - const deleteMutation = useMutation({ mutationFn: () => FlowsService.deleteFlow({ name: flowName }), onSuccess: () => { @@ -731,10 +727,121 @@ function FlowEditorInner({ [fitView, flowName], ) + /** Put the stored draft live. Publishing what is queued means saving first. */ + const publishFlow = useCallback(async () => { + await flush() + // Publish what was actually stored: the version only advances once the + // queued save has landed. + const current = queryClient.getQueryData( + flowKeys.detail(flowName), + ) + publish.mutate(current?.definition.version ?? 1) + }, [flowName, flush, publish.mutate, queryClient]) + + /** Copy the selected nodes, with the code of the ones carrying their own. */ + const copyNodes = useCallback(async () => { + const ids = new Set( + canvasNodes.filter((node) => node.selected).map((node) => node.id), + ) + if (selectedId) ids.add(selectedId) + const picked = definitions.filter((node) => ids.has(node.id)) + if (!picked.length) return + + const sources: Record = {} + await Promise.all( + picked + // A shared node already points at the library copy; only a private + // one has code that has to travel with it. + .filter((node) => !node.source_ref && sourceTypes.has(node.type ?? "")) + .map(async (node) => { + const { code } = await FlowsService.readNodeSource({ + name: flowName, + nodeId: node.id, + }) + sources[node.id] = code + }), + ).catch(() => undefined) + + localStorage.setItem( + CLIPBOARD_KEY, + JSON.stringify({ nodes: picked, sources } satisfies NodeClipboard), + ) + }, [canvasNodes, definitions, flowName, selectedId, sourceTypes]) + + /** + * Paste them here, renamed around whatever this flow already holds. The + * document only names a node's code, so the copy is written per node once + * the definition is on its way. + */ + const pasteNodes = useCallback(() => { + const stored = localStorage.getItem(CLIPBOARD_KEY) + if (!stored) return + let clipboard: NodeClipboard + try { + clipboard = JSON.parse(stored) + } catch { + return + } + + let pool = definitions + const pasted: NodeDef_Input[] = [] + for (const node of clipboard.nodes ?? []) { + const position = freePosition(pool, { + // Offset, so a copy of a node in this flow is visibly its own. + x: (node.position?.x ?? 0) + 48, + y: (node.position?.y ?? 0) + 48, + }) + const copy = { ...node, id: uniqueNodeId(pool, node.id), position } + pool = [...pool, copy] + pasted.push(copy) + } + if (!pasted.length) return + + const nextCanvas = [ + ...canvasNodes, + ...pasted.map( + (node) => + ({ + id: node.id, + type: "flow", + position: node.position, + data: {}, + }) as FlowCanvasNode, + ), + ] + setCanvasNodes(nextCanvas) + commit(pool, nextCanvas) + setSelectedId(pasted[pasted.length - 1].id) + + clipboard.nodes.forEach((node, index) => { + const code = clipboard.sources?.[node.id] + if (code !== undefined) { + sourceMutation.mutate({ nodeId: pasted[index].id, code }) + } + }) + }, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate]) + + useShortcuts( + { + "mod+z": () => step(true), + "mod+shift+z": () => step(false), + "mod+c": () => void copyNodes(), + "mod+v": pasteNodes, + "mod+k": () => setPaletteOpen((open) => !open), + // Inside the code editor ⌘S applies that code, which the node panel + // owns; anywhere else on the canvas it puts the flow live. + "mod+s": (event) => { + if (!inCodeEditor(event.target)) void publishFlow() + }, + }, + // Both stay reachable while typing: one is the editor's own save, the + // other is how you reach anything at all. + ["mod+s", "mod+k"], + ) + const selected = definitions.find((node) => node.id === selectedId) ?? null - // A panel is the view you are working in: the bars would only compete with - // it, so they step aside until it closes. On a phone the panel covers them - // anyway, and its own close button is the way back. + // A panel is the view you are working in, so it takes the room — but never + // the lanes the bars sit in: publishing is most wanted right after editing. const panelOpen = Boolean(selected) || flowPanelOpen return ( @@ -776,7 +883,10 @@ function FlowEditorInner({ setSelectedId(node.id) }} onPaneClick={() => { + // Clicking the canvas is how you put a panel away, whichever one it + // is: the graph is what you went back to look at. setSelectedId(null) + setFlowPanelOpen(false) setEditorExpanded(false) setInspected(null) }} @@ -813,50 +923,55 @@ function FlowEditorInner({ - - {panelOpen ? null : ( - { - // Publish what was actually stored: the version only advances - // once the queued save has landed. - await flush() - const current = queryClient.getQueryData( - flowKeys.detail(flowName), - ) - publish.mutate(current?.definition.version ?? 1) - }} - onEditFlow={() => { - setSelectedId(null) - setFlowPanelOpen(true) - }} - /> + {/* + * The bars keep their own lane rather than making way for a panel: they + * carry Publish, which is what you reach for the moment a node is done. + * The floating panel is inset out of that lane instead. + */} +
+ void publishFlow()} + onEditFlow={() => { + setSelectedId(null) + setFlowPanelOpen(true) + }} + /> - {panelOpen ? null : ( - setPaletteOpen(true)} - onRun={async () => { - // Running executes what is stored, so the queued edit goes first. - await flush() - runMutation.mutate() - }} - onTogglePause={() => pauseMutation.mutate(!paused)} - onFocusNode={focusNode} - /> - )} - + { + setLogsOpen(open) + if (!open) setLogsNode(null) + }, + onClearNode: () => setLogsNode(null), + }} + onAddNode={() => setPaletteOpen(true)} + onRun={async () => { + // Running executes what is stored, so the queued edit goes first. + await flush() + runMutation.mutate() + }} + onTogglePause={() => pauseMutation.mutate(!paused)} + onFocusNode={focusNode} + /> +
{definitions.length === 0 ? (
@@ -875,17 +990,10 @@ function FlowEditorInner({ { - flush() - save({ ...next, nodes: definitions }) - }} - onRename={async (newName) => { - await flush() - renameMutation.mutate(newName) - }} + // Confirmed, so it is a finished edit rather than a run of keystrokes. + onChange={(next) => commitDoc({ ...next, nodes: definitions }, true)} onDelete={() => deleteMutation.mutate()} enabled={detail.enabled ?? true} toggling={enableMutation.isPending} diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index 01079b5..06effe4 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -2,6 +2,7 @@ import { Handle, type NodeProps, Position } from "@xyflow/react" import { Bell, Braces, + Bug, Clock, Code2, Database, @@ -20,6 +21,7 @@ import { import { memo } from "react" import type { MessageSpec, NodeDef_Input } from "@/client" +import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, @@ -61,6 +63,8 @@ export type FlowNodeData = { typeLabel: string isPlugin?: boolean issueText: string + /** Open the logs at this node's own lines. */ + onShowLogs?: (nodeId: string) => void [key: string]: unknown } @@ -103,7 +107,7 @@ function PortHandles({ } function FlowNodeComponent({ data, selected }: NodeProps) { - const { definition, flow, typeLabel, isPlugin, issueText } = + const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } = data as FlowNodeData const live = useNodeStatus(`${flow}.${definition.id}`) const emits = useNodeEmits(`${flow}.${definition.id}`) @@ -153,6 +157,29 @@ function FlowNodeComponent({ data, selected }: NodeProps) { {typeLabel} + {status === "error" && onShowLogs ? ( + + + + + Show the traceback + + ) : null} + {style ? ( diff --git a/frontend/src/components/Flow/FlowPanel.tsx b/frontend/src/components/Flow/FlowPanel.tsx index 3823ad2..df3fa2f 100644 --- a/frontend/src/components/Flow/FlowPanel.tsx +++ b/frontend/src/components/Flow/FlowPanel.tsx @@ -10,25 +10,18 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" -import { PANEL_SECTION, SidePanel } from "./SidePanel" - -const NAME_PATTERN = /^[a-z][a-z0-9_]*$/ +import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel" /** - * The flow's own settings, in the same panel its nodes use. - * - * The name is also the namespace of every message in the flow, which is why - * renaming goes through the server rather than being another autosaved field. + * The flow's own settings, in the same panel its nodes use — down to where the + * name sits and how confirming a new one works. */ export function FlowPanel({ open, definition, nodeCount, - renaming, onChange, - onRename, onDelete, hasDraft, discarding, @@ -41,9 +34,7 @@ export function FlowPanel({ open: boolean definition: FlowDef_Input nodeCount: number - renaming: boolean onChange: (next: FlowDef_Input) => void - onRename: (newName: string) => void onDelete: () => void hasDraft: boolean discarding: boolean @@ -53,13 +44,9 @@ export function FlowPanel({ onToggleEnabled: (next: boolean) => void onClose: () => void }) { - const [name, setName] = useState(definition.name) const [confirmOpen, setConfirmOpen] = useState(false) const [discardOpen, setDiscardOpen] = useState(false) - const valid = NAME_PATTERN.test(name) - const changed = name !== definition.name - return ( <> - onChange({ ...definition, title: event.target.value }) - } + label="Flow title" + onConfirm={(title) => onChange({ ...definition, title })} /> } footer={ @@ -110,40 +94,11 @@ export function FlowPanel({
-
- Name -
- setName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && valid && changed) { - onRename(name) - } - }} - /> - -
-

- {valid || !name - ? "Messages in this flow are named after it, so other flows reading them follow the rename." - : "Lowercase letters, digits and underscores, starting with a letter."} -

-
-
Contents

+ {definition.name} namespaces + every message in here.{" "} {nodeCount === 0 ? "No nodes yet." : `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`} diff --git a/frontend/src/components/Flow/LogsPanel.tsx b/frontend/src/components/Flow/LogsPanel.tsx index 457bdef..6eb488d 100644 --- a/frontend/src/components/Flow/LogsPanel.tsx +++ b/frontend/src/components/Flow/LogsPanel.tsx @@ -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(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 ( - + @@ -65,9 +86,23 @@ export function LogsPanel({ flow }: { flow: string }) {

-

- Logs -

+
+

+ Logs +

+ {node ? ( + + ) : null} +
) : null diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index 27fc9e4..1ec68ac 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -1,9 +1,10 @@ import { X } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import type { ReactNode } from "react" -import { useEffect } from "react" +import { useEffect, useState } from "react" import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" import { useIsMobile } from "@/hooks/useMobile" import { duration, easeEmphasized, easeStandard } from "@/lib/motion" @@ -27,6 +28,58 @@ const panelSlide = { export const PANEL_SECTION = "text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground" +/** + * What the thing in this panel is called, and the one place to rename it. + * + * Renaming is an explicit act: the field keeps what is typed until it is + * confirmed, so a half-typed name never reaches the canvas. + */ +export function PanelTitle({ + value, + placeholder, + label, + onConfirm, +}: { + value: string + placeholder?: string + /** Names the field for screen readers. */ + label: string + onConfirm: (next: string) => void +}) { + const [draft, setDraft] = useState(value) + // Another thing to name is another field. + useEffect(() => setDraft(value), [value]) + + const next = draft.trim() + const changed = next !== value + + return ( + <> + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && changed) onConfirm(next) + }} + /> + {changed ? ( + + ) : null} + + ) +} + /** * The editor's settings panel: floating over the canvas so the graph stays * visible and running behind it, a full-screen sheet where there is no room @@ -51,7 +104,7 @@ export function SidePanel({ testId: string /** Remounts the contents when the thing being edited changes. */ bodyKey: string - /** Fill the content area instead of floating beside the canvas. */ + /** Take the width a code editor needs, still floating over the canvas. */ expanded?: boolean header: ReactNode footer?: ReactNode @@ -101,6 +154,7 @@ export function SidePanel({ !next && onClose()}> {contents} diff --git a/frontend/src/lib/shortcuts.ts b/frontend/src/lib/shortcuts.ts new file mode 100644 index 0000000..8a6004f --- /dev/null +++ b/frontend/src/lib/shortcuts.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef } from "react" + +/** + * Window-level keyboard shortcuts. + * + * A chord reads as `mod+shift+z`, where `mod` is ⌘ on a Mac and Ctrl + * everywhere else. Bindings are plain data, so a view declares the whole set + * it answers to in one place. + */ +export type Shortcuts = Record void> + +/** Text fields and the code editor keep their own bindings and their own undo. */ +export function isTextEntry(target: EventTarget | null): boolean { + return Boolean( + (target as Element | null)?.closest?.( + "input, textarea, [contenteditable='true'], .monaco-editor", + ), + ) +} + +/** Focus is inside the embedded code editor. */ +export function inCodeEditor(target: EventTarget | null): boolean { + return Boolean((target as Element | null)?.closest?.(".monaco-editor")) +} + +function chordOf(event: KeyboardEvent): string { + const parts: string[] = [] + if (event.metaKey || event.ctrlKey) parts.push("mod") + if (event.altKey) parts.push("alt") + if (event.shiftKey) parts.push("shift") + // Shift makes it a capital Z, so compare on the letter alone. + parts.push(event.key.toLowerCase()) + return parts.join("+") +} + +/** + * Bind chords for as long as the component is mounted. + * + * A chord listed in `inTextEntry` fires even while a field or the code editor + * has focus; every other one steps aside, because typing there means typing. + */ +export function useShortcuts(bindings: Shortcuts, inTextEntry: string[] = []) { + // Read through a ref, so a fresh handler on every render never re-binds. + const latest = useRef({ bindings, inTextEntry }) + latest.current = { bindings, inTextEntry } + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const chord = chordOf(event) + const run = latest.current.bindings[chord] + if (!run) return + if ( + isTextEntry(event.target) && + !latest.current.inTextEntry.includes(chord) + ) { + return + } + event.preventDefault() + run(event) + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, []) +}