diff --git a/NOTEPAD.md b/NOTEPAD.md
index e49a884..bc993af 100644
--- a/NOTEPAD.md
+++ b/NOTEPAD.md
@@ -25,8 +25,8 @@ should reopen it.
- FEAT/UI introduce a graph panel which renders at the top right next to the graph view (to make more use of the horizontal space) and which allows (de-) selecting flows to be excluded from the graph view or search for individual nodes where only the flows containing this node should be shown (like slicing the brain)
- FEAT/UI durations are written as a shortened number beside a fixed unit, so a slow run reads "1.2k ms" rather than "1.2 s". A duration formatter that steps the unit itself (µs/ms/s/min) would read better wherever `si` is followed by "ms"
- CHORE/UI `biome check ./src` reports an ineffective suppression at `FlowEditor.tsx:473` (`useExhaustiveDependencies` no longer fires there)
-- FEAT/UI text labels in the brain graph view should only show upon hovering. Edges/nodes which fire seldomly should dim out over time (up to a lower limit). Get inspiration on how "Obsidian" visualizes the graph view. Also make sure nodes shapes are visible on both light and dark themes (consider using fill color instead of shadows) -> web-search / research about visualization of large graphs and ensure high quality visual design
-- BUG/UI make sure the edges point towards the center of the nodes in the brain graph view (currently some of them seem a bit off-center)
+- FEAT/UI the brain's activity falloff is session-observed: a page just opened shows every neuron and connection at the same neutral base, and only sorts itself out as values arrive. A "last published" timestamp per node from the backend would let it open already sorted.
+- CHORE/UI the brain's hover labels have no touch equivalent — a tap navigates to the flow, and there is no hover to reveal a name first. The native `title` carries it on desktop only.
- FEAT/UI labels in flows (indicating dashboard widget connections) naturally can't pulse. Instead add an animation (enlightning fade) from either ltr or rtl depending if the label is in- or outbound
- FEAT/UI (deferred until MCP lands): add a "bot" icon button to the home view (graph panel) which opens a chat window (reuse general concept of a side panel like in flows/nodes to make it a chat panel which can open on any screen (stacks below any other existing panel -> introduce stacking) to give support on errors/write code, generate dashboards etc) to explain the error(s)
@@ -42,8 +42,6 @@ is what M4 still waits on, together with porting the flows.
### Bugs found while building the screens
-- BUG/API: `POST /alerts/test/{channel}` always answers 200. `AlertManager.send()` catches and logs every delivery failure, so the alerts screen's Test button cannot tell a working channel from a broken one — the one thing it exists for. Let `send()` raise or return a result on the test path.
-- BUG/FLOW: deleting a flow leaves its `pipeline:{flow}.*` Redis keys behind, and renaming one does not migrate them — the live instance carries `pipeline:__history__:dashboar.test` beside the correct `dashboard.test`. One cleanup on the delete/rename path covers both.
- CHORE/API: revoking an OAuth client does not invalidate access tokens already issued; they are stateless JWTs valid up to `MCP_TOKEN_EXPIRE_MINUTES`. Immediate revocation means `app/mcp/http.py` checking the client row still exists.
- CHORE/FLOW: `Pipeline.trigger`'s docstring says a paused flow still publishes so the value shows on the canvas. True only without a queue; with one the item parks before `apply_outputs` and nothing shows. Docstring and behaviour disagree.
- CHORE/FLOW: `_to_messages` keeps its `if not retval: return None` guard ahead of the new type check, so a falsy non-dict return (`0`, `""`, `[]`) is still silently "no output" rather than the named error. Deliberate for now; worth a decision.
@@ -52,7 +50,6 @@ is what M4 still waits on, together with porting the flows.
### Out-of-process nodes and modules
-- BUG/API: `POST /flows/{name}/nodes/{node_id}/trigger` answers 500 when the node's code raises, because the inline trigger path runs `Node.__call__` rather than `Pipeline._execute_node` and nothing catches it. Predates the worker pool, which only made it easier to hit; the person waiting on the response should get the node's error, not a stack trace in the server log.
- CHORE/FLOW: `PythonWorkerPool._running` is keyed by node id and last-wins, so two concurrent runs of one node mean cancel kills the newest. Key by run id once M5's run records exist.
- CHORE/FLOW: `compile_check` sends the *draft* source under the running node's cache key, so the worker recompiles the published source on its next call. Correct, but one wasted compile per save on a busy node.
- FEAT/API: `POST /modules/apply` rebuilds the whole pipeline so a node that could not import its package stops being red. That resubscribes every MQTT node in the deployment; a targeted rebuild of the flows that actually failed to load would be gentler.
@@ -69,9 +66,9 @@ is what M4 still waits on, together with porting the flows.
- FEAT/UI: the health screen's window is fixed at 24 hours and the charts fold minute buckets in Python. A range picker (and `date_bin()` behind it) is the next step if anyone wants a week.
- CHORE/FLOW: run records for a deleted flow stay until the retention window passes, so a flow that no longer exists keeps appearing in the history. Deliberate — it is a record of what ran — but `forget_flow` could offer to clear it.
- CHORE/API: nothing can ask the collector to flush now, so anything needing the tables to be current has to wait out `FLUSH_INTERVAL_S` — which is what the soak harness does before clearing its own rows.
-- BUG/UI: `MemoryWorkQueue.stats()` hard-codes `pending: 0`, so the health tile always reads zero on a stack without Redis.
- CHORE/API: `MetricsCollector._start_run`'s `existing is not None` branch is unreachable: a redelivery only arrives after the record it would update has been dropped.
-- CHORE/API: the collector's `_tracebacks` stash is never pruned for nodes that stop failing — one entry per node that ever errored, kept for the life of the process.
+- CHORE/FLOW: `RedisWorkQueue.clear_flow` deletes only `pipeline:__parked__:{flow}`, so a deleted or renamed flow's `__queue__` stream entries, `__delayed__` zset members and `__done__:*` markers stay behind. The stream is capped and the entries are dropped when they reach a node that no longer exists, so it costs work rather than correctness.
+- CHORE/FLOW: `MemoryWorkQueue`'s in-flight count is a counter around claim/ack, and claiming already removed the item — so an item a handler leaves unacknowledged (no pipeline bound) counts as in flight until the process ends. Nothing can hand it back either way, which is what the memory queue is.
- CHORE/API: audit rows ride the same drop-oldest bus as telemetry, so a storm can lose one. Writing a node's source is not audited either; publishing is.
- PERF/API: two unmarked ceilings — `/observability/flows` scans every `metric_minute` row in the window on each 30 s poll, and `queue.stats()` does a keyspace `scan_iter` on every call while two endpoints poll it.
- CHORE/INFRA: dev only — memory-queue ids (`mem-{seq}`) restart at 0 each boot and `FlowRun.id` is the primary key, so a restart without Redis upserts over the previous boot's run rows.
@@ -182,8 +179,7 @@ as an em dash.
- CHORE/UX: free-form params (python nodes) get no suggestions, since there is no schema to key them off.
- PERF/UI: `BrainView` runs 300 force-layout ticks synchronously inside a `useMemo`, so the graph is laid out on the render thread.
- FEAT/UI: the brain is a band on a scrolling page now, so it neither pans nor zooms — the fit keeps the whole graph in view instead. An installation with enough flows to make the labels unreadable at that fit needs a way to open the graph larger.
-- FEAT/UI: the brain graph draws connections as plain lines, so which way a value travels is not shown. An arrowhead needs the path shortened to the target circle's rim, which needs the node radius on the edge.
-- CHORE/UI: fixing the yellow message pulse gave `edge-pulse` an explicit `to`, so a *selected* edge now decays to `--muted-foreground` and snaps back to its blue at the end instead of decaying into it. Barely visible, and only while an edge is selected.
+- CHORE/UI: `flow.css` drops the focus outline on every `.react-flow__node`. Brain neurons now answer `:focus-visible` with the same border colour as hover, but the flow editor's own nodes still have no visible keyboard focus.
### Infrastructure
diff --git a/frontend/src/components/Flow/BrainEdge.tsx b/frontend/src/components/Flow/BrainEdge.tsx
index 73a92eb..4b2c1bb 100644
--- a/frontend/src/components/Flow/BrainEdge.tsx
+++ b/frontend/src/components/Flow/BrainEdge.tsx
@@ -1,4 +1,4 @@
-import { BaseEdge, type EdgeProps, getStraightPath } from "@xyflow/react"
+import { BaseEdge, type EdgeProps } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
@@ -8,9 +8,55 @@ import { useLatestTs } from "./liveStore"
export type BrainEdgeData = {
/** Every message this connection carries, qualified. */
messages: string[]
+ /** Circle radii of the two neurons, so the line can stop at their rims. */
+ sourceRadius: number
+ targetRadius: number
[key: string]: unknown
}
+/** Arrowhead barb length, and how far its tip stops short of the target rim. */
+const HEAD = 7
+const RIM_GAP = 3
+
+/**
+ * A line from rim to rim, with an arrowhead at the end it flows into.
+ *
+ * Both handles sit at a neuron's centre, so the raw path runs under both
+ * circles: it looks off-centre wherever the circles differ in size, and it says
+ * nothing about which way the value travels. The barbs are two more segments of
+ * the same stroked path rather than an SVG marker, so they dim with the line
+ * they belong to instead of needing a fill of their own.
+ */
+function rimPath(
+ sourceX: number,
+ sourceY: number,
+ targetX: number,
+ targetY: number,
+ sourceRadius: number,
+ targetRadius: number,
+): string {
+ const length = Math.hypot(targetX - sourceX, targetY - sourceY) || 1
+ const unitX = (targetX - sourceX) / length
+ const unitY = (targetY - sourceY) / length
+ // Neither trim reaches past the midpoint, so two neurons closer together than
+ // their radii still get a line pointing the right way rather than a backwards
+ // one. The force layout keeps them apart; this only stops it looking broken.
+ const from = Math.min(sourceRadius, length / 2)
+ const to = Math.min(targetRadius + RIM_GAP, length - from)
+ const startX = sourceX + unitX * from
+ const startY = sourceY + unitY * from
+ const tipX = targetX - unitX * to
+ const tipY = targetY - unitY * to
+ const line = `M${startX},${startY}L${tipX},${tipY}`
+ if (length - from - to < HEAD) return line
+
+ const backX = tipX - unitX * HEAD
+ const backY = tipY - unitY * HEAD
+ const spreadX = (-unitY * HEAD) / 2
+ const spreadY = (unitX * HEAD) / 2
+ return `${line}M${backX + spreadX},${backY + spreadY}L${tipX},${tipY}L${backX - spreadX},${backY - spreadY}`
+}
+
/**
* A connection between neurons, lit by whatever passes along it.
*
@@ -26,12 +72,23 @@ function BrainEdgeComponent({
targetY,
data,
}: EdgeProps) {
- const { messages } = (data ?? { messages: [] }) as BrainEdgeData
+ const { messages, sourceRadius, targetRadius } = (data ?? {
+ messages: [],
+ sourceRadius: 0,
+ targetRadius: 0,
+ }) as BrainEdgeData
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef(0)
- const [path] = getStraightPath({ sourceX, sourceY, targetX, targetY })
+ const path = rimPath(
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourceRadius,
+ targetRadius,
+ )
useEffect(() => {
if (!ts || ts === lastTs.current) return
@@ -41,7 +98,18 @@ function BrainEdgeComponent({
return () => clearTimeout(timer)
}, [ts])
- return