Files
app/frontend/src/components/Flow/BrainEdge.tsx
T
stroblmeandClaude Opus 5 bbff78766e Own the live socket outside React so leaving Home cannot orphan it
The socket belonged to whichever hook instance ran its effect first. Passive
effects run children before parents, so on Home that was the brain graph rather
than the shell: navigating to a sibling route unmounted the graph, which closed
the socket, while the shell kept the reference count above zero. From there the
page was deaf for the rest of its life, with nothing left to reconnect it.

A module-level connection with a real refcount replaces it — connect on the
first subscriber, disconnect on the last — and the hook is a thin subscription
with the same signature, correct under StrictMode's mount/unmount/mount.

A 1008 now reconnects instead of returning silently: the token is read afresh
per attempt, and three consecutive rejections fall through to the caller's auth
handler so a revoked session surfaces rather than spins.

The snapshot's emit counts are read into a store of their own, apart from the
live count, so a graph that connects into a busy engine is drawn as busy without
every neuron claiming it just fired. The neuron and edge pulses now key off a
change seen while they were mounted, so returning to Home no longer replays
every emission of the session.

Home gets a live indicator for the case none of this can fix: quiet while the
socket is up, and named in words when it is down, since HTTP polling keeps the
rest of the page looking current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
2026-08-20 11:58:49 +02:00

118 lines
4.9 KiB
TypeScript

import { BaseEdge, type EdgeProps, useInternalNode } from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { useLatestTs } from "./liveStore"
export type BrainEdgeData = {
/** Every message this connection carries, qualified. */
messages: string[]
/** Circle radii of the two neurons: their centres, and where the line stops. */
sourceRadius: number
targetRadius: number
[key: string]: unknown
}
/**
* Radius of the dot that marks where the connection meets a neuron.
*
* A flow node's port handle is 12px across on a node some four times that; a
* neuron is 32px at its smallest, so the same proportion lands at 8px — small
* enough that several connections into one small neuron stay separate dots.
*/
const DOT = 4
/**
* A connection between neurons, lit by whatever passes along it.
*
* `LiveEdge` without the value chip or the producer check: a neuron can stand
* for several nodes, so "did *this* node publish it" has no answer here, and a
* circle the size of a coin has no room for a payload anyway.
*
* It runs centre to centre, so the line trims itself back to the two rims and
* then further, to clear the dot that sits on each of them: a flow node's port,
* filled where the value leaves and hollow where it arrives. The dots belong to
* the edge rather than to the node because a neuron meets its connections all
* round its rim, not at two fixed points — and being part of the edge is also
* what makes them dim along with it.
*/
function BrainEdgeComponent({ id, source, target, data }: EdgeProps) {
const { messages, sourceRadius, targetRadius } = (data ?? {
messages: [],
sourceRadius: 0,
targetRadius: 0,
}) as BrainEdgeData
const sourceNode = useInternalNode(source)
const targetNode = useInternalNode(target)
const ts = useLatestTs(messages)
const [pulsing, setPulsing] = useState(false)
// Seeded with whatever the store already holds rather than with 0: the store
// outlives this component, so a value that arrived before it mounted is
// history and must not be replayed as a pulse.
const lastTs = useRef(ts)
// The two centres, taken from the layout rather than from the `sourceX`/
// `targetX` React Flow hands an edge. Those come off the handles, which it
// measures out of the DOM exactly once per node — and here that once falls
// inside the graph's entrance scale, so every one of them is stored a few
// percent short of the centre and never measured again. A node's position
// plus its radius is the same point, exactly, with nothing measured at all.
const sourceX = (sourceNode?.internals.positionAbsolute.x ?? 0) + sourceRadius
const sourceY = (sourceNode?.internals.positionAbsolute.y ?? 0) + sourceRadius
const targetX = (targetNode?.internals.positionAbsolute.x ?? 0) + targetRadius
const targetY = (targetNode?.internals.positionAbsolute.y ?? 0) + targetRadius
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, length - from)
const startX = sourceX + unitX * from
const startY = sourceY + unitY * from
const endX = targetX - unitX * to
const endY = targetY - unitY * to
// The line stops at each dot rather than running under it, so a filled dot is
// one flat tone instead of a disc with a darker line showing through half of
// it. Never longer than the gap, or a short connection would draw backwards.
const trim = Math.min(DOT, (length - from - to) / 2)
const path = `M${startX + unitX * trim},${startY + unitY * trim}L${endX - unitX * trim},${endY - unitY * trim}`
useEffect(() => {
if (!ts || ts === lastTs.current) return
lastTs.current = ts
setPulsing(true)
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [ts])
return (
// The tone lives on the group so the line and both dots share one falloff.
<g
className={cn(
"brain-wire",
// Nothing seen yet this session is not the same as seen and gone quiet.
ts === 0 && "brain-idle",
pulsing && "brain-hot",
)}
>
<BaseEdge
id={id}
path={path}
className={cn("brain-edge", pulsing && "edge-live")}
/>
<circle
className="brain-dot brain-dot-out"
cx={startX}
cy={startY}
r={DOT}
/>
<circle className="brain-dot" cx={endX} cy={endY} r={DOT} />
</g>
)
}
export const BrainEdge = memo(BrainEdgeComponent)