- {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)
+ }, [])
+}