diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index d0d06dd..df6003f 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -13,15 +13,11 @@ import { WifiOff, X, } from "lucide-react" -import { motion } from "motion/react" +import { AnimatePresence, motion } from "motion/react" +import { useEffect, useState } from "react" import type { ValidationIssue } from "@/client" import { Button } from "@/components/ui/button" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" import { Separator } from "@/components/ui/separator" import { Tooltip, @@ -30,7 +26,7 @@ import { } from "@/components/ui/tooltip" import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" -import { type LogsFilter, LogsPanel } from "./LogsPanel" +import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel" import { useLiveConnection } from "./liveStore" /** @@ -110,271 +106,336 @@ export function FlowDock({ }) { const { fitView } = useReactFlow() const connected = useLiveConnection() + const [issuesOpen, setIssuesOpen] = useState(false) // The engine marks the issues it does not count against a flow. They are // still worth saying, so they stay in the list — but in the muted tone, and // without turning the summary red, since nothing here is actually broken. const faults = issues.filter((issue) => !issue.advisory) return ( - - - - - - Add a node (⌘K) - +
+ setIssuesOpen(false)} + onFocusNode={onFocusNode} + /> + +
- + + + + + + Add a node (⌘K) + - {/* A phone pinches to zoom and the graph fits itself, so this would only + + + {/* A phone pinches to zoom and the graph fits itself, so this would only be taking room the rest of the bar needs. */} - - - - - Fit to screen - + + + + + Fit to screen + - {issues.length > 0 ? ( - <> - - - + {issues.length > 0 ? ( + <> + + + + ) : null} + + + + + + + + + + + {!enabled + ? "This flow is stopped" + : paused + ? "Let the flow carry on" + : "Hold the nodes; values still arrive"} + + + + {paused && enabled ? ( + + - - -

- Needs attention -

-
    - {issues.map((issue) => ( -
  • - -
  • - ))} -
-
-
- - ) : null} + + Let one held-back item through + + ) : null} - + + + + + + + + {enabled ? "Run every node once" : "Start the flow to run it"} + + - + - - - - - - {!enabled - ? "This flow is stopped" - : paused - ? "Let the flow carry on" - : "Hold the nodes; values still arrive"} - - - - {paused && enabled ? ( - Let one held-back item through + Flow settings - ) : null} - - - - - - - - {enabled ? "Run every node once" : "Start the flow to run it"} - - - - - - - - - - Flow settings - - - {/* Throwing the edit away sits next to putting it live, and only exists + {/* Throwing the edit away sits next to putting it live, and only exists while there is something to throw away. */} - {hasDraft ? ( + {hasDraft ? ( + + + + + Discard the unpublished changes + + ) : null} + + {/* + * Saved state and publish are one control: the glyph never moves, it + * simply stops being something you can press once there is nothing left + * to put live. A button that appears and disappears moved everything + * beside it just as the work was finished. + */} - + + + - Discard the unpublished changes + + {!connected + ? "Reconnecting to the engine" + : publishing + ? "Publishing" + : saving + ? "Saving" + : hasDraft + ? "Saved — publish to put it live" + : "All changes saved"} + +
+ + ) +} + +/** + * What stops this flow running, or is merely worth knowing — the same chrome as + * the logs, so a canvas with both open shows one pair rather than a panel and a + * popover disagreeing about what a panel looks like. + * + * Not a popover for the same reason the logs are not: clicking a node is what + * you do while reading this, since each line points at one. + */ +function IssuesPanel({ + open, + issues, + onClose, + onFocusNode, +}: { + open: boolean + issues: ValidationIssue[] + onClose: () => void + onFocusNode: (nodeId: string) => void +}) { + useEffect(() => { + if (!open) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose() + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [open, onClose]) + + return ( + + {open && issues.length > 0 ? ( + +
+

+ Needs attention +

+
+
    + {issues.map((issue) => ( +
  • + +
  • + ))} +
+
) : null} - - {/* - * Saved state and publish are one control: the glyph never moves, it - * simply stops being something you can press once there is nothing left - * to put live. A button that appears and disappears moved everything - * beside it just as the work was finished. - */} - - - - - - - - {!connected - ? "Reconnecting to the engine" - : publishing - ? "Publishing" - : saving - ? "Saving" - : hasDraft - ? "Saved — publish to put it live" - : "All changes saved"} - - -
+ ) } diff --git a/frontend/src/components/Flow/LogsPanel.tsx b/frontend/src/components/Flow/LogsPanel.tsx index ff51558..849e80d 100644 --- a/frontend/src/components/Flow/LogsPanel.tsx +++ b/frontend/src/components/Flow/LogsPanel.tsx @@ -13,6 +13,13 @@ import { slideUp, transitions } from "@/lib/motion" import { cn } from "@/lib/utils" import { liveStore, useLiveLogs } from "./liveStore" +/** + * The chrome a panel over this canvas wears: frosted, bordered, and the same + * width whichever of them is open, so two side by side read as one pair. + */ +export const PANEL = + "pointer-events-auto w-[30rem] max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md" + function shortTime(ts: number): string { return new Date(ts * 1000).toLocaleTimeString(undefined, { hour12: false, @@ -36,6 +43,38 @@ export type LogsFilter = { onClearNode: () => void } +/** + * The dock button that opens the logs. Separate from the panel because the two + * sit in different rows: the trigger belongs in the bar, the panel above it, + * beside whatever else is open. + */ +export function LogsTrigger({ + open, + onOpenChange, +}: Pick) { + return ( + + + + + What this flow printed + + ) +} + /** * 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. @@ -75,112 +114,92 @@ export function LogsPanel({ }, [open, onOpenChange]) return ( - <> - - - - - What this flow printed - - - - {open ? ( - // A sibling of the button but positioned against the dock, so it - // sits centred above the whole bar and clears it by `mb-3` however - // many rows the bar wrapped into. Deliberately not a popover: a - // click on the canvas is what you do *while* reading the logs, so - // only the button or Escape puts them away. - -
-
-

- Logs -

- {node ? ( - - ) : null} -
- -
- - {lines.length === 0 ? ( -

- {node - ? `Nothing from ${node} yet.` - : "Nothing yet. Anything a node prints shows up here."} + + {open ? ( + // Laid out by the dock's panel row rather than positioned against the + // bar, so it can sit beside the issues list instead of over it — and + // so neither this nor the bar nests a half-pixel translate or a + // backdrop filter inside the other, which is what Firefox was + // rasterising the text through. Deliberately not a popover: a click on + // the canvas is what you do *while* reading the logs, so only the + // button or Escape puts them away. + +

+
+

+ Logs

- ) : ( - -
    - {lines.map((line, index) => ( -
  • + {node} + + + ) : null} +
+ +
+ + {lines.length === 0 ? ( +

+ {node + ? `Nothing from ${node} yet.` + : "Nothing yet. Anything a node prints shows up here."} +

+ ) : ( + +
    + {lines.map((line, index) => ( +
  • + + {shortTime(line.ts)}{" "} + + {nodeLabel(line.node, flow)} + + + - - {shortTime(line.ts)}{" "} - - {nodeLabel(line.node, flow)} - - - - {line.text.replace(/\n+$/, "")} - {line.truncated ? "\n… truncated" : ""} - -
  • - ))} -
  • -
-
- )} -
- ) : null} -
- + {line.text.replace(/\n+$/, "")} + {line.truncated ? "\n… truncated" : ""} + + + ))} +
  • + + + )} + + ) : null} + ) }