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:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<LogsPanel flow={flow} />
|
||||
<LogsPanel flow={flow} {...logs} />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
// 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<NodeDef_Input[]>(
|
||||
() => 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<FlowDef_Input>(() => 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<Rebind | null>(null)
|
||||
const [renamed, setRenamed] = useState<MessageRename | null>(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<string | null>(null)
|
||||
|
||||
const issues = detail.issues ?? []
|
||||
const paused = useFlowPaused(flowName)
|
||||
@@ -247,33 +256,35 @@ function FlowEditorInner({
|
||||
const latest = useRef<FlowDef_Input>(detail.definition)
|
||||
const history = useRef<History>({ 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<FlowDetail>(
|
||||
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<string, string> = {}
|
||||
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({
|
||||
<CanvasBackground />
|
||||
</ReactFlow>
|
||||
|
||||
<AnimatePresence>
|
||||
{panelOpen ? null : (
|
||||
<FlowTabs
|
||||
key="flow-tabs"
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
hasDraft={detail.has_draft ?? false}
|
||||
publishing={publish.isPending || saving.isPending}
|
||||
onPublish={async () => {
|
||||
// Publish what was actually stored: the version only advances
|
||||
// once the queued save has landed.
|
||||
await flush()
|
||||
const current = queryClient.getQueryData<FlowDetail>(
|
||||
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.
|
||||
*/}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 transition-[right] duration-200",
|
||||
panelOpen && !editorExpanded && "md:right-[27rem]",
|
||||
)}
|
||||
>
|
||||
<FlowTabs
|
||||
flows={flows.data}
|
||||
active={flowName}
|
||||
saving={saving.isPending}
|
||||
hasDraft={detail.has_draft ?? false}
|
||||
publishing={publish.isPending || saving.isPending}
|
||||
onPublish={() => void publishFlow()}
|
||||
onEditFlow={() => {
|
||||
setSelectedId(null)
|
||||
setFlowPanelOpen(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
{panelOpen ? null : (
|
||||
<FlowDock
|
||||
key="flow-dock"
|
||||
flow={flowName}
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
enabled={detail.enabled ?? true}
|
||||
paused={paused}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<FlowDock
|
||||
flow={flowName}
|
||||
issues={issues}
|
||||
running={runMutation.isPending}
|
||||
enabled={detail.enabled ?? true}
|
||||
paused={paused}
|
||||
logs={{
|
||||
open: logsOpen,
|
||||
node: logsNode,
|
||||
onOpenChange: (open) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{definitions.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
@@ -875,17 +990,10 @@ function FlowEditorInner({
|
||||
|
||||
<FlowPanel
|
||||
open={flowPanelOpen && !selected}
|
||||
definition={{ ...detail.definition, nodes: definitions }}
|
||||
definition={flowDoc}
|
||||
nodeCount={definitions.length}
|
||||
renaming={renameMutation.isPending}
|
||||
onChange={(next) => {
|
||||
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}
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
</span>
|
||||
{status === "error" && onShowLogs ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// `nodrag` keeps xyflow from reading the press as a drag; the
|
||||
// click itself is stopped so the node panel stays closed.
|
||||
className="nodrag nopan -my-1 size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Show what this node printed"
|
||||
data-testid="node-traceback"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onShowLogs(definition.id)
|
||||
}}
|
||||
>
|
||||
<Bug />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Show the traceback</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{style ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SidePanel
|
||||
@@ -69,14 +56,11 @@ export function FlowPanel({
|
||||
bodyKey={definition.name}
|
||||
onClose={onClose}
|
||||
header={
|
||||
<Input
|
||||
<PanelTitle
|
||||
value={definition.title ?? ""}
|
||||
placeholder={definition.name}
|
||||
aria-label="Flow title"
|
||||
className="h-8 flex-1 text-sm font-medium"
|
||||
onChange={(event) =>
|
||||
onChange({ ...definition, title: event.target.value })
|
||||
}
|
||||
label="Flow title"
|
||||
onConfirm={(title) => onChange({ ...definition, title })}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
@@ -110,40 +94,11 @@ export function FlowPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Name</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={name}
|
||||
aria-label="Flow name"
|
||||
autoComplete="off"
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && valid && changed) {
|
||||
onRename(name)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!valid || !changed || renaming}
|
||||
onClick={() => onRename(name)}
|
||||
>
|
||||
{renaming ? "Renaming…" : "Rename"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{definition.name}</span> namespaces
|
||||
every message in here.{" "}
|
||||
{nodeCount === 0
|
||||
? "No nodes yet."
|
||||
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MessageSparkline } from "./MessageSparkline"
|
||||
import {
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
nodeSourceQueryOptions,
|
||||
secretsQueryOptions,
|
||||
} from "./queries"
|
||||
import { PANEL_SECTION, SidePanel } from "./SidePanel"
|
||||
import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
|
||||
|
||||
const NodeEditor = lazy(() => import("./NodeEditor"))
|
||||
|
||||
@@ -679,6 +680,15 @@ function PanelBody({
|
||||
const save = useRef(onSaveSource)
|
||||
save.current = onSaveSource
|
||||
|
||||
/** Send what is typed now rather than a second from now. */
|
||||
const saveNow = () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
if (pending.current !== null) save.current(pending.current)
|
||||
}
|
||||
|
||||
const editCode = (next: string) => {
|
||||
setCode(next)
|
||||
pending.current = next
|
||||
@@ -689,6 +699,17 @@ function PanelBody({
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// ⌘S in the editor means "apply this code"; the editor's own state is here,
|
||||
// so the binding is too. Anywhere else on the canvas it publishes the flow.
|
||||
useShortcuts(
|
||||
{
|
||||
"mod+s": (event) => {
|
||||
if (inCodeEditor(event.target)) saveNow()
|
||||
},
|
||||
},
|
||||
["mod+s"],
|
||||
)
|
||||
|
||||
// Closing the panel must not lose the last keystrokes.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -827,13 +848,10 @@ export function NodePanel({
|
||||
<span className="min-w-0 truncate font-mono text-sm text-muted-foreground">
|
||||
/{flow}/
|
||||
</span>
|
||||
<Input
|
||||
<PanelTitle
|
||||
value={node.title || node.id}
|
||||
aria-label="Node name"
|
||||
className="h-8 flex-1 text-sm font-medium"
|
||||
onChange={(event) =>
|
||||
onChange({ ...node, title: event.target.value })
|
||||
}
|
||||
label="Node name"
|
||||
onConfirm={(title) => onChange({ ...node, title })}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Input
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
autoComplete="off"
|
||||
className="h-8 min-w-0 flex-1 text-sm font-medium"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && changed) onConfirm(next)
|
||||
}}
|
||||
/>
|
||||
{changed ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => onConfirm(next)}
|
||||
data-testid="confirm-rename"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
) : 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({
|
||||
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
data-testid={testId}
|
||||
// The panel header carries its own close button, and opening should
|
||||
// not drop the caret into the first field.
|
||||
className="flex h-dvh w-full max-w-none flex-col gap-0 rounded-none p-0 [&>button:last-of-type]:hidden"
|
||||
@@ -131,11 +185,12 @@ export function SidePanel({
|
||||
data-testid={testId}
|
||||
data-expanded={expanded || undefined}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden border border-border bg-card/80 shadow-e2 backdrop-blur-md",
|
||||
"pointer-events-auto absolute z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md",
|
||||
expanded
|
||||
? // Fills the content region, which already starts after the sidebar.
|
||||
"inset-0 rounded-none"
|
||||
: "inset-y-4 right-4 w-[400px] rounded-lg",
|
||||
? // Still a floating surface, only given the room code needs —
|
||||
// and the canvas chrome keeps its lanes above and below.
|
||||
"inset-x-4 bottom-16 top-16"
|
||||
: "inset-y-4 right-4 w-[400px]",
|
||||
)}
|
||||
>
|
||||
{contents}
|
||||
|
||||
@@ -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<string, (event: KeyboardEvent) => 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)
|
||||
}, [])
|
||||
}
|
||||
Reference in New Issue
Block a user