diff --git a/frontend/src/components/Common/Dock.tsx b/frontend/src/components/Common/Dock.tsx
index ae2c505..b1b62c0 100644
--- a/frontend/src/components/Common/Dock.tsx
+++ b/frontend/src/components/Common/Dock.tsx
@@ -43,7 +43,7 @@ export function Dock({
exit="exit"
transition={transitions.emphasized}
className={cn(
- "pointer-events-auto flex max-w-[calc(100vw-2rem)] 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))]",
+ "pointer-events-auto flex w-max max-w-[calc(100vw-2rem)] 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,
)}
>
diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx
index c477f67..0d404df 100644
--- a/frontend/src/components/Flow/FlowDock.tsx
+++ b/frontend/src/components/Flow/FlowDock.tsx
@@ -121,7 +121,7 @@ export function FlowDock({
// gives the two panels a row to share, instead of one landing on the other.
diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx
index c5547aa..bee7b12 100644
--- a/frontend/src/components/Flow/FlowEditor.tsx
+++ b/frontend/src/components/Flow/FlowEditor.tsx
@@ -553,6 +553,24 @@ function FlowEditorInner({
[navigate, flowName],
)
+ /**
+ * Read an input where its label sits: what it is declared as and what it
+ * holds now. With no label to anchor to it opens the flow settings, where
+ * inputs are declared.
+ */
+ const inspectInput = useCallback(
+ (id: string) => {
+ const declared = (latest.current.inputs ?? []).find(
+ (one) => `input:${qualify(flowName, one.spec?.name ?? "")}` === id,
+ )
+ const label = document.querySelector(`.react-flow__node[data-id="${id}"]`)
+ if (!declared || !label) return openEndpoint(id)
+ const { left, top, width, height } = label.getBoundingClientRect()
+ setInspectedInput({ declared, rect: { left, top, width, height } })
+ },
+ [flowName, openEndpoint],
+ )
+
// React Flow measures a node once and keeps the size on it. An endpoint is
// not in `canvasNodes`, so the measurement it reports back has nowhere to
// land: without carrying it over by hand the endpoint arrives unmeasured on
@@ -741,6 +759,14 @@ function FlowEditorInner({
])
}, [key, direction, external, updateNodeInternals])
+ // An input the canvas draws is read where its label sits, as clicking it
+ // does, so arriving on one waits for the layout to place the label.
+ const arrival =
+ focus?.startsWith("input:") && external.nodes.some((n) => n.id === focus)
+ ? focus
+ : null
+ const arrived = useRef
(null)
+
// The canvas is not remounted per node — that would throw the session away
// on every click in the brain graph — so arriving at a flow already open
// only changes the address. Seeded above for the first arrival, set here for
@@ -753,10 +779,10 @@ function FlowEditorInner({
if (landsOn(focus)) {
setFlowPanelOpen(false)
setSelectedId(focus)
- } else {
+ } else if (!arrival) {
openEndpoint(focus)
}
- }, [focus, openEndpoint])
+ }, [focus, arrival, openEndpoint])
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
@@ -782,7 +808,10 @@ function FlowEditorInner({
// selection on the early returns is what fitted the whole flow instead.
// Another flow's label is centred once it has been found on this canvas.
const target = selectedId?.startsWith("flow:") ? landing : selectedId
- const focus = target && selectedId !== centred.current ? target : null
+ // A label found by the search is centred, and read once it is there.
+ const label = arrival && arrival !== arrived.current ? arrival : null
+ const focus =
+ label ?? (target && selectedId !== centred.current ? target : null)
// A phone's sheet covers the canvas outright, and so does the expanded
// editor: there is no viewport to aim.
if (editorExpanded) return
@@ -792,16 +821,23 @@ function FlowEditorInner({
const frame = requestAnimationFrame(() => {
const view = panelOpen && !isMobile ? FIT_VIEW_PANEL : FIT_VIEW
const duration = fitted.current ? 300 : 0
- fitView(
+ const fit = fitView(
focus
? { ...view, nodes: [{ id: focus }], duration }
: { ...view, duration },
)
fitted.current = true
centred.current = selectedId
+ if (label) {
+ arrived.current = label
+ // The frame lets the canvas draw the view it was just given.
+ void fit.then(() => requestAnimationFrame(() => inspectInput(label)))
+ }
})
return () => cancelAnimationFrame(frame)
}, [
+ arrival,
+ inspectInput,
direction,
definitions.length,
external.nodes.length,
@@ -1326,26 +1362,11 @@ function FlowEditorInner({
onNodesDelete={(deleted) =>
deleteNodes(deleted.filter(isDocumentNode).map((node) => node.id))
}
- onNodeClick={(event, node) => {
+ onNodeClick={(_, node) => {
// An endpoint is somewhere else's: opening its panel here would
// offer to edit a node this flow does not contain.
// An input is read where it sits; editing it is one press on.
- const declared = node.id.startsWith("input:")
- ? (flowDoc.inputs ?? []).find(
- (one) =>
- `input:${qualify(flowName, one.spec?.name ?? "")}` ===
- node.id,
- )
- : undefined
- if (declared) {
- const { left, top, width, height } =
- event.currentTarget.getBoundingClientRect()
- setInspectedInput({
- declared,
- rect: { left, top, width, height },
- })
- return
- }
+ if (node.id.startsWith("input:")) return inspectInput(node.id)
if (!isDocumentNode(node)) {
const wire = edges.find(
(edge) => edge.source === node.id || edge.target === node.id,
diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx
index 527c1ad..798da4f 100644
--- a/frontend/src/components/Flow/LiveEdge.tsx
+++ b/frontend/src/components/Flow/LiveEdge.tsx
@@ -210,6 +210,7 @@ function LiveEdgeComponent({
return at(source, route.from) && at(target, route.to)
})
const [pulsing, setPulsing] = useState(false)
+ const lit = useRef(null)
const lastTs = useRef(null)
// Owned by the pulse rather than by the effect: a value from another
// producer re-runs the effect too, and must not cancel a reset it does not
@@ -259,18 +260,29 @@ function LiveEdgeComponent({
// No source at all is an older engine; pulse rather than go silent.
if (from && from.id !== producerId) return
setPulsing(true)
+ // A CSS animation starts when its class arrives, and while a pulse is still
+ // running the class is already on, so a second value within it would show
+ // nothing. Start the running one over by hand.
+ for (const animation of lit.current?.getAnimations({ subtree: true }) ??
+ []) {
+ if ((animation as CSSAnimation).animationName === "edge-pulse") {
+ animation.currentTime = 0
+ }
+ }
clearTimeout(timer.current)
timer.current = setTimeout(() => setPulsing(false), duration.pulse * 1000)
}, [live?.ts, live?.source, live?.prior, producerId])
return (
<>
-
+
+
+
{live !== undefined && zoom >= CHIP_MIN_ZOOM ? (
{
`${id} runs off the right edge`,
).toBeLessThanOrEqual(width)
}
+
+ // It is centred on `left-1/2`, where a shrink-wrapped box gets only the half
+ // of the screen to the right of that, and wraps into a pill as tall as it is
+ // wide. Whatever the buttons need, it is never confined to that half.
+ const dock = await page.getByTestId("add-node").locator("..").boundingBox()
+ expect(
+ dock!.width,
+ "the dock is squeezed into half the screen",
+ ).toBeGreaterThan((page.viewportSize()?.width ?? 0) / 2)
})
test("a dashboard stacks instead of shrinking", async ({ page }) => {
diff --git a/frontend/tests/search.spec.ts b/frontend/tests/search.spec.ts
index 6d93d39..731dcf5 100644
--- a/frontend/tests/search.spec.ts
+++ b/frontend/tests/search.spec.ts
@@ -42,7 +42,7 @@ test("finds a node by name and opens its flow on it", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`))
})
-test("finds a flow input and opens the settings declaring it", async ({
+test("finds a flow input and reads it where its label sits", async ({
page,
}) => {
await page.goto("/")
@@ -54,6 +54,10 @@ test("finds a flow input and opens the settings declaring it", async ({
await expect(page).toHaveURL(
new RegExp(`/flows/${flowName}\\?node=input%3A${flowName}\\.findme_input$`),
)
+ // What clicking the label shows, and the settings one press on.
+ await expect(page.getByTestId("input-inspector")).toBeVisible()
+ await expect(page.getByTestId("flow-panel")).toBeHidden()
+ await page.getByTestId("edit-input").click()
await expect(page.getByTestId("flow-panel")).toBeVisible()
})