diff --git a/frontend/src/components/Flow/CanvasTitle.tsx b/frontend/src/components/Flow/CanvasTitle.tsx index 06322a1..d888ebc 100644 --- a/frontend/src/components/Flow/CanvasTitle.tsx +++ b/frontend/src/components/Flow/CanvasTitle.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react" import { SidebarTrigger } from "@/components/ui/sidebar" import { slideUp, transitions } from "@/lib/motion" +import { cn } from "@/lib/utils" /** * What you are looking at, floating top-centre over a full-bleed canvas. @@ -16,7 +17,14 @@ import { slideUp, transitions } from "@/lib/motion" * a panel alongside others is edited with that panel's rail on screen, because * the wall has it too and it takes room off the canvas. */ -export function CanvasTitle({ children }: { children: ReactNode }) { +export function CanvasTitle({ + children, + className, +}: { + children: ReactNode + /** Lets the shell move the bar out of the way. */ + className?: string +}) { return ( {/* The sidebar carries its own collapse control; a phone has no sidebar on screen to carry it. */} diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index f352dad..c8751bd 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -12,8 +12,6 @@ import { StepForward, WifiOff, X, - ZoomIn, - ZoomOut, } from "lucide-react" import { motion } from "motion/react" @@ -43,6 +41,20 @@ import { useLiveConnection } from "./liveStore" */ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 } +/** + * The same fit with the settings panel's lane held clear, so an opening panel + * never lands on the node you are looking at. 432px is the lane the chrome is + * pushed out of by `md:right-[27rem]`; xyflow's padding parser takes px and %, + * so it cannot be spelled in rem. 10% per side is what `padding: 0.25` above + * resolves to, so the two fits are equally generous. + */ +export const FIT_VIEW_PANEL = { + maxZoom: FIT_VIEW.maxZoom, + // `as const` because xyflow types each side as `${number}px | ${number}%`, + // which a widened `string` does not satisfy. + padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const, +} + /** * The action bar, floating bottom-centre. Run is the one brand-secondary * affordance on this view; everything else stays quiet. @@ -51,7 +63,7 @@ export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 } * saved, the flow's own settings, and putting it live or throwing it away — * so the top of the canvas is left to say which flow this is. * - * It wraps rather than overflows, and drops the zoom controls on a phone; see + * It wraps rather than overflows, and drops the fit control on a phone; see * DESIGN-GUIDELINES.md → Responsive. */ export function FlowDock({ @@ -73,6 +85,7 @@ export function FlowDock({ onEditFlow, onPublish, onDiscard, + className, }: { flow: string issues: ValidationIssue[] @@ -93,8 +106,9 @@ export function FlowDock({ onEditFlow: () => void onPublish: () => void onDiscard: () => void + className?: string }) { - const { zoomIn, zoomOut, fitView } = useReactFlow() + const { fitView } = useReactFlow() const connected = useLiveConnection() return ( @@ -107,7 +121,10 @@ export function FlowDock({ // Capped and wrapping: the canvas shell clips, so an uncapped row would // put the buttons at its ends out of reach on a phone rather than merely // look wrong. See DESIGN-GUIDELINES.md → Responsive. - className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]" + className={cn( + "pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]", + className, + )} > @@ -130,17 +147,8 @@ export function FlowDock({ className="mx-0.5 !h-5 hidden md:block" /> - {/* A phone pinches to zoom and the graph fits itself, so these three - would only be taking room the rest of the bar needs. */} - + {/* A phone pinches to zoom and the graph fits itself, so this would only + be taking room the rest of the bar needs. */} {issues.length > 0 ? ( <> diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index f2c8e2d..be3c236 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -57,7 +57,7 @@ import { import { EdgeInspector, type InspectedEdge } from "./EdgeInspector" import { EndpointNode } from "./EndpointNode" import { deriveEndpoints, ENDPOINT_TYPE, isEndpointNode } from "./endpoints" -import { FIT_VIEW, FlowDock } from "./FlowDock" +import { FIT_VIEW, FIT_VIEW_PANEL, FlowDock } from "./FlowDock" import { FlowNode, type FlowNodeData } from "./FlowNode" import { FlowPanel } from "./FlowPanel" import { LiveEdge } from "./LiveEdge" @@ -352,7 +352,7 @@ function FlowEditorInner({ const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? [] return { ...node, - selected: node.id === selectedId, + selected: node.selected || node.id === selectedId, data: { definition: definition ?? { id: node.id }, flow: flowName, @@ -484,9 +484,17 @@ function FlowEditorInner({ [key, flowName, detail.endpoints], ) + const isMobile = useIsMobile() // Which way the graph runs. A phone has height to spare and no width, so it // reads top to bottom; everything else reads left to right. - const direction: Direction = useIsMobile() ? "TB" : "LR" + const direction: Direction = isMobile ? "TB" : "LR" + + // Expanding is a desktop affordance, so a window narrowed past `md` gives the + // room back: the sheet it becomes has no second column to hold, and its body + // only scrolls while the editor is its normal size. + useEffect(() => { + if (isMobile) setEditorExpanded(false) + }, [isMobile]) /** * Nobody places a node here — the graph lays itself out, endpoints included, @@ -554,21 +562,53 @@ function FlowEditorInner({ ]) }, [key, direction, external, updateNodeInternals]) - // A relayout can put a new node outside the viewport, and turning the graph - // on its side moves everything. Both want the whole flow back in view. + const selected = definitions.find((node) => node.id === selectedId) ?? null + // 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 + + // One effect owns the viewport, so nothing fights over it. Selecting a node + // brings that node into the lane the panel leaves; every other change to the + // graph — new wiring, a new endpoint, a panel opening — re-fits the whole flow + // into the same lane. Which of the two runs is decided by what changed, not by + // what is true: a selection centres once, and the port edits that follow it + // re-fit around it, because a new edge's far end is what wants to be seen. // The first fit is instant: an animated one travels from React Flow's // default viewport to the content, which is the whole flow visibly sliding // in from the corner every time one is opened. Later fits move from // somewhere the user was already looking, so those stay animated. const fitted = useRef(false) - // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape changes, not on every render. + const centred = useRef(null) + // biome-ignore lint/correctness/useExhaustiveDependencies: refit when the shape or the panel changes, not on every render. useEffect(() => { + const focus = + selectedId && selectedId !== centred.current ? selectedId : null + centred.current = selectedId + // A phone's sheet covers the canvas outright, and so does the expanded + // editor: there is no viewport to aim. + if (editorExpanded) return const frame = requestAnimationFrame(() => { - fitView(fitted.current ? { ...FIT_VIEW, duration: 300 } : FIT_VIEW) + const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW + const duration = fitted.current ? 300 : 0 + fitView( + focus + ? { ...view, nodes: [{ id: focus }], duration } + : { ...view, duration }, + ) fitted.current = true }) return () => cancelAnimationFrame(frame) - }, [direction, definitions.length, external.nodes.length, fitView]) + }, [ + direction, + definitions.length, + external.nodes.length, + edges.length, + selectedId, + panelOpen, + editorExpanded, + isMobile, + fitView, + ]) const runMutation = useMutation({ mutationFn: (inputs: Record = {}) => @@ -832,10 +872,9 @@ function FlowEditorInner({ const id = qualifiedId.startsWith(`${flowName}.`) ? qualifiedId.slice(flowName.length + 1) : qualifiedId - fitView({ nodes: [{ id }], duration: 300, maxZoom: 1.2 }) setSelectedId(id) }, - [fitView, flowName], + [flowName], ) /** Put the stored draft live. Publishing what is queued means saving first. */ @@ -941,11 +980,6 @@ function FlowEditorInner({ ["mod+s", "mod+k"], ) - const selected = definitions.find((node) => node.id === selectedId) ?? null - // 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 ( <> {/* @@ -1045,13 +1079,22 @@ function FlowEditorInner({ panelOpen && !editorExpanded && "md:right-[27rem]", )} > - + {flowDoc.title || flowName} { + if (!open) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onOpenChange(false) + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [open, onOpenChange]) + return ( - + <> - - - + What this flow printed - -
-
-

- Logs -

- {node ? ( + + {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} +
- ) : 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)} - - - - {line.text.replace(/\n+$/, "")} - {line.truncated ? "\n… truncated" : ""} - -
  • - ))} -
  • -
-
- )} -
-
+ {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)} + + + + {line.text.replace(/\n+$/, "")} + {line.truncated ? "\n… truncated" : ""} + +
  • + ))} +
  • +
+
+ )} +
+ ) : null} + + ) } diff --git a/frontend/src/components/Flow/NodePanel.tsx b/frontend/src/components/Flow/NodePanel.tsx index 1a95349..36566c9 100644 --- a/frontend/src/components/Flow/NodePanel.tsx +++ b/frontend/src/components/Flow/NodePanel.tsx @@ -988,8 +988,20 @@ function PanelBody({ }, []) return ( - <> -
+
+
{hasSource ? ( -
+
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"} @@ -1087,7 +1104,7 @@ function PanelBody({
) : null} - +
) } diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index 1ec68ac..914b080 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -137,7 +137,12 @@ export function SidePanel({
-
+
{children}
@@ -187,9 +192,9 @@ export function SidePanel({ className={cn( "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 - ? // 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" + ? // Still a floating surface, given the whole inset: the toolbar + // and the flow-name box translate off screen while it is open. + "inset-4" : "inset-y-4 right-4 w-[400px]", )} > diff --git a/frontend/tests/runtime.spec.ts b/frontend/tests/runtime.spec.ts index 811f320..66ebbba 100644 --- a/frontend/tests/runtime.spec.ts +++ b/frontend/tests/runtime.spec.ts @@ -92,9 +92,29 @@ test("the logs panel shows what a node printed and why one failed", async ({ await page.getByTestId("run-flow").click() await page.getByTestId("flow-logs").click() - const panel = page.locator('[data-slot="popover-content"]') + const panel = page.getByTestId("logs-panel") await expect(panel).toContainText("sensor read 21.5 degrees") await expect(panel).toContainText("RuntimeError") + + // Reading the logs is something you do *while* working on the flow, so a + // click on the canvas leaves them up — only the button or Escape closes it. + await page.locator(".react-flow__pane").click({ position: { x: 8, y: 8 } }) + await expect(panel).toBeVisible() + + // It sits above the dock rather than on it, and shares its centre. The dock + // has no box of its own to measure, so its span is its first and last + // buttons; its padding is symmetric, so their midpoint is its centre. + const logs = (await panel.boundingBox())! + const dock = (await page.getByTestId("run-flow").boundingBox())! + expect(logs.y + logs.height).toBeLessThan(dock.y) + + const first = (await page.getByTestId("add-node").boundingBox())! + const last = (await page.getByTestId("publish-flow").boundingBox())! + const centre = (first.x + last.x + last.width) / 2 + expect(Math.abs(logs.x + logs.width / 2 - centre)).toBeLessThan(2) + + await page.getByTestId("flow-logs").click() + await expect(panel).toBeHidden() }) test("a flow can be paused and let go again", async ({ page }) => {