[eric] canvas: tethers leave facing edges and fan out, hold 1.5px and a 9px head at any zoom, link a running child, and ignore streaming

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xkMseod9vJ3hCrE43ctDo
(cherry picked from commit aff630a730c4d7fd12364956fb680a2e311f9788)
This commit is contained in:
ciregenz
2026-09-06 20:22:01 -07:00
parent 96bcd37cd8
commit ddd2e3c0f0
6 changed files with 354 additions and 398 deletions
@@ -1,16 +1,18 @@
import React from 'react';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import type { Tether } from '../geometry/dashboardTethers';
import { LABEL_FONT_PX, LABEL_MIN_ZOOM, type Tether } from '../geometry/dashboardTethers';
const TETHER_FADE_MS = 500;
interface TetherLayerProps {
tethers: Tether[];
zoom: number;
c: ClaudeTokens;
}
const TetherLayer: React.FC<TetherLayerProps> = ({ tethers, c }) => {
const TetherLayer: React.FC<TetherLayerProps> = ({ tethers, zoom, c }) => {
if (tethers.length === 0) return null;
const showLabels = zoom >= LABEL_MIN_ZOOM;
return (
<svg
data-canvas-overlay="1"
@@ -22,75 +24,37 @@ const TetherLayer: React.FC<TetherLayerProps> = ({ tethers, c }) => {
height: 1,
overflow: 'visible',
pointerEvents: 'none',
// Behind every card (cards use zOrder 1..N as their z-index): connector lines tuck UNDER the cards like a node graph, visible only in the gaps between them. At zIndex 10 the line drew OVER any card with zOrder < 10, so it cut through the chat and the browsers.
// Behind every card (cards use zOrder 1..N as their z-index): connector lines tuck UNDER the cards like a node graph, visible only in the gaps between them.
zIndex: 0,
}}
>
<defs>
<marker
id="tether-arrow"
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="10"
markerHeight="10"
orient="auto"
>
<path d="M 0 1 L 10 5 L 0 9 z" fill={c.accent.primary} opacity={0.8} />
</marker>
</defs>
{tethers.map((t) => (
<g
key={t.key}
data-tether={t.key}
style={{
opacity: t.fading ? 0 : 1,
transition: `opacity ${TETHER_FADE_MS}ms ease-out`,
}}
>
{/* Soft halo from a plain wide translucent stroke, no SVG blur filter:
the filter re-rasterized every frame and the marching-ants + pulse
that justified it are gone, so a static double-stroke is the cheap,
calm version of the same glow. */}
<path
d={t.path}
fill="none"
stroke={c.accent.primary}
strokeWidth={6}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.12}
/>
<path
d={t.path}
fill="none"
stroke={c.accent.primary}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
opacity={0.75}
markerEnd="url(#tether-arrow)"
/>
{t.label && (
// Same glass capsule language as the narrator pills; the old white accent-bordered box read as a stray form element.
<g transform={`translate(${t.labelX},${t.labelY})`}>
{/* The stroke is a non-scaling one: 1.5px on screen at every camera, where it used to shrink to a hairline zoomed out and fatten zoomed in. The halo is the spawn cue only; a steady link is a plain line. */}
{t.glow && (
<path d={t.path} fill="none" stroke={c.accent.primary} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" opacity={0.14} vectorEffect="non-scaling-stroke" />
)}
<path d={t.path} fill="none" stroke={c.accent.primary} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" opacity={t.glow ? 0.85 : 0.6} vectorEffect="non-scaling-stroke" />
<path d={t.head} fill={c.accent.primary} opacity={t.glow ? 0.9 : 0.7} />
{t.label && showLabels && (
// Same glass capsule as the narrator pills, scaled against the camera so it reads at LABEL_FONT_PX whatever the zoom.
<g transform={`translate(${t.labelX},${t.labelY}) scale(${t.labelScale})`}>
<rect
x={-6}
y={-15}
x={-(t.label.length * 7.5) / 2 - 6}
y={-11}
width={t.label.length * 7.5 + 12}
height={22}
rx={11}
fill="rgba(24,14,32,0.85)"
opacity={0.98}
/>
<text
x={t.label.length * 7.5 / 2}
y={1}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fontFamily="inherit"
fill="rgba(255,255,255,0.92)"
>
<text x={0} y={4} textAnchor="middle" fontSize={LABEL_FONT_PX} fontWeight={600} fontFamily="inherit" fill="rgba(255,255,255,0.92)">
{t.label}
</text>
</g>
@@ -9,16 +9,15 @@ import TetherLayer from './TetherLayer';
// whole dashboard tree (the ENG-88 input delay).
const TetherLayerHost: React.FC<{ inputs: TetherInputs; c: ClaudeTokens }> = ({ inputs, c }) => {
const [liveDrag, setLiveDrag] = useState<LiveDragInfo | null>(null);
// Tethers only exist while something glows AND that something is on the canvas; a DOCKED browser's glow draws no tether (geometry skips docked cards), so dragging its chat must not buy per-frame React either.
const hasTethers =
Object.keys(inputs.glowingAgentCards).length > 0 ||
Object.keys(inputs.glowingBrowserCards).some((bid) => !inputs.browserCards[bid]?.docked_to);
const tethers = useTethers(inputs, liveDrag);
// Per-frame React only while a line is actually drawn (a docked browser's glow draws none, and a
// board with nothing linked must not pay for a drag at all).
const hasTethers = tethers.length > 0;
useEffect(() => {
if (!hasTethers) { setLiveDrag(null); return undefined; }
return subscribeLiveDrag(setLiveDrag);
}, [hasTethers]);
const tethers = useTethers(inputs, liveDrag);
return <TetherLayer tethers={tethers} c={c} />;
return <TetherLayer tethers={tethers} zoom={inputs.zoom} c={c} />;
};
export default React.memo(TetherLayerHost);
@@ -0,0 +1,58 @@
// Run: npm test (frontend/scripts/run-tests.mjs)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pickSides, fanFractions, headPath, route, layoutLinks, HEAD_PX, type Rect } from './dashboardTethers.ts';
const r = (x: number, y: number, w = 200, h = 100): Rect => ({ x, y, width: w, height: h });
test('the facing edges are chosen by the wider gap, so a line never loops back across its own card', () => {
assert.deepEqual(pickSides(r(0, 0), r(400, 0)), { src: 'right', dst: 'left' });
assert.deepEqual(pickSides(r(400, 0), r(0, 0)), { src: 'left', dst: 'right' });
assert.deepEqual(pickSides(r(0, 0), r(0, 400)), { src: 'bottom', dst: 'top' });
assert.deepEqual(pickSides(r(0, 400), r(0, 0)), { src: 'top', dst: 'bottom' });
// Above-right but far more above than right: vertical pair, not a sideways entry into a top edge.
assert.deepEqual(pickSides(r(0, 800), r(60, 0)), { src: 'top', dst: 'bottom' });
});
test('one line leaves the middle spot; several fan out across the band in order of their targets', () => {
assert.deepEqual(fanFractions(1), [0.54]);
const three = fanFractions(3);
assert.equal(three.length, 3);
assert.ok(three[0] < three[1] && three[1] < three[2]);
assert.ok(three[0] >= 0.4 && three[2] <= 0.72);
});
test('the head points the way the line enters and the line stops short of the tip', () => {
const head = headPath({ x: 400, y: 50, side: 'left' }, 9);
assert.ok(head.startsWith('M 400,50 L 391,'), head);
const routed = route({ x: 200, y: 50, side: 'right' }, { x: 400, y: 50, side: 'left' }, 9);
assert.ok(routed.path.endsWith(`H ${400 - 9 * 0.7}`), routed.path);
const vertical = route({ x: 100, y: 100, side: 'bottom' }, { x: 100, y: 500, side: 'top' }, 9);
assert.ok(vertical.path.startsWith('M 100,100 V'), vertical.path);
assert.ok(vertical.path.endsWith(`V ${500 - 9 * 0.7}`), vertical.path);
});
test('three children of one chat leave its right edge at three different heights, sorted by where they sit', () => {
const parent = r(0, 0, 300, 600);
const links = [
{ key: 'c', srcId: 'p', src: parent, dstId: 'c', dst: r(600, 900), label: '', fading: false, glow: false },
{ key: 'a', srcId: 'p', src: parent, dstId: 'a', dst: r(600, -300), label: '', fading: false, glow: false },
{ key: 'b', srcId: 'p', src: parent, dstId: 'b', dst: r(600, 300), label: '', fading: false, glow: false },
];
const out = layoutLinks(links, 1);
const startY = (path: string): number => Number(path.match(/^M [\d.-]+,([\d.-]+)/)![1]);
const byKey = Object.fromEntries(out.map((t) => [t.key, startY(t.path)]));
assert.ok(byKey.a < byKey.b && byKey.b < byKey.c, JSON.stringify(byKey));
assert.equal(new Set(Object.values(byKey)).size, 3);
});
test('head and label are sized against the committed zoom so they hold on screen', () => {
const link = { key: 'k', srcId: 'p', src: r(0, 0), dstId: 'c', dst: r(400, 0), label: 'Sub-agent', fading: false, glow: true };
const [zoomed] = layoutLinks([link], 0.5);
const [flat] = layoutLinks([link], 1);
assert.equal(zoomed.labelScale, 2);
assert.equal(flat.labelScale, 1);
// The head's base sits HEAD_PX / zoom behind the tip in canvas units.
assert.ok(zoomed.head.includes(`L ${400 - HEAD_PX / 0.5},`), zoomed.head);
assert.ok(flat.head.includes(`L ${400 - HEAD_PX},`), flat.head);
});
@@ -1,41 +1,89 @@
import { useMemo, type RefObject } from 'react';
import type { CardPosition, BrowserCardPosition, ViewCardPosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Workflow, OpenCard } from '@/shared/state/workflowsSlice';
import { GRID_GAP } from '@/shared/state/dashboardLayoutSlice';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type { Output } from '@/shared/state/outputsSlice';
import { agentCardHeight } from './agentCardHeight';
const ELBOW_RADIUS = 16;
// Screen pixels, divided by the committed zoom so the head and the label hold their size at any
// camera; the line itself uses a non-scaling stroke in the layer.
export const HEAD_PX = 9;
export const LABEL_FONT_PX = 11;
// Below this a label would be under 5px tall; the line still shows where the link goes.
export const LABEL_MIN_ZOOM = 0.4;
// A lone line leaves an edge at the historical 54% spot; several share the band below so they
// never stack on top of each other for their first segment.
const SINGLE_AT = 0.54;
const FAN_FROM = 0.4;
const FAN_TO = 0.72;
export type Side = 'left' | 'right' | 'top' | 'bottom';
export interface Anchor { x: number; y: number; side: Side }
export interface Rect { x: number; y: number; width: number; height: number }
export interface Tether {
key: string;
path: string;
/** The filled arrowhead, drawn as geometry (a marker would scale with the camera). */
head: string;
labelX: number;
labelY: number;
label: string;
/** 1 / zoom: the label group scales by this so its text stays LABEL_FONT_PX on screen. */
labelScale: number;
fading: boolean;
/** A spawn glow (the "just happened" cue) wears a halo; a steady link stays a plain line. */
glow: boolean;
}
interface GlowingAgentCard {
sourceId: string;
fading: boolean;
sourceYRatio?: number;
label?: string;
interface GlowingAgentCard { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }
interface GlowingBrowserCard { sourceId: string; fading: boolean; label?: string }
export interface LiveDragInfo { cardId: string; dx: number; dy: number }
/** The few session facts the tethers read. A projection, so streamed messages never re-run the geometry. */
export type TetherSession = Pick<AgentSession, 'id' | 'mode' | 'status' | 'browser_id' | 'parent_session_id' | 'workflow_edit_id'>;
// ---------- pure geometry ----------
function center(r: Rect): { x: number; y: number } {
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}
interface GlowingBrowserCard {
sourceId: string;
fading: boolean;
label?: string;
/**
* The pair of FACING edges. The axis with the wider gap between the two cards decides, so a line
* always leaves perpendicular to one card and enters perpendicular to the other, and can never
* loop back across the card it started from (the nearest-anchor search could pair a right edge
* with a top edge and then drive a horizontal segment sideways into that top edge).
*/
export function pickSides(src: Rect, dst: Rect): { src: Side; dst: Side } {
const sc = center(src);
const dc = center(dst);
const dx = dc.x - sc.x;
const dy = dc.y - sc.y;
const gapX = dx >= 0 ? dst.x - (src.x + src.width) : src.x - (dst.x + dst.width);
const gapY = dy >= 0 ? dst.y - (src.y + src.height) : src.y - (dst.y + dst.height);
if (gapX >= gapY) return dx >= 0 ? { src: 'right', dst: 'left' } : { src: 'left', dst: 'right' };
return dy >= 0 ? { src: 'bottom', dst: 'top' } : { src: 'top', dst: 'bottom' };
}
export interface LiveDragInfo {
cardId: string;
dx: number;
dy: number;
function anchorAt(r: Rect, side: Side, fraction: number): Anchor {
switch (side) {
case 'left': return { x: r.x, y: r.y + r.height * fraction, side };
case 'right': return { x: r.x + r.width, y: r.y + r.height * fraction, side };
case 'top': return { x: r.x + r.width * fraction, y: r.y, side };
default: return { x: r.x + r.width * fraction, y: r.y + r.height, side };
}
}
/** Where along an edge each of n lines leaves it: one line at the middle spot, several spread across the band. */
export function fanFractions(n: number): number[] {
if (n <= 1) return [SINGLE_AT];
return Array.from({ length: n }, (_, i) => FAN_FROM + ((FAN_TO - FAN_FROM) * i) / (n - 1));
}
/** Left/right pairs: out, across, in. Rounded at both bends. */
export function elbowPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
const dy = y2 - y1;
@@ -45,7 +93,6 @@ export function elbowPath(x1: number, y1: number, x2: number, y2: number): strin
: Math.min(ELBOW_RADIUS, Math.abs(dy) / 2, Math.abs(dx) / 4);
const sy = dy >= 0 ? 1 : -1;
const sx = dx >= 0 ? 1 : -1;
return [
`M ${x1},${y1}`,
`H ${midX - sx * r}`,
@@ -56,24 +103,119 @@ export function elbowPath(x1: number, y1: number, x2: number, y2: number): strin
].join(' ');
}
type Anchor = { x: number; y: number; side: 'left' | 'right' | 'top' | 'bottom' };
type CanvasRect = { x: number; y: number; width: number; height: number };
// Where the ray from a rect's center toward (tx,ty) crosses the rect border. Pins a tether endpoint to the card edge facing the other card, so it can never float in empty space the way nearest-corner anchoring could.
function borderPoint(x: number, y: number, w: number, h: number, tx: number, ty: number): { x: number; y: number } {
const cx = x + w / 2;
const cy = y + h / 2;
const dx = tx - cx;
const dy = ty - cy;
if (dx === 0 && dy === 0) return { x: cx, y: cy };
const scale = 1 / Math.max(Math.abs(dx) / (w / 2), Math.abs(dy) / (h / 2));
return { x: cx + dx * scale, y: cy + dy * scale };
/** Top/bottom pairs: the same elbow turned on its side. */
export function verticalElbowPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = x2 - x1;
const dy = y2 - y1;
const midY = y1 + dy / 2;
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
? 0
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
return [
`M ${x1},${y1}`,
`V ${midY - sy * r}`,
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
`H ${x2 - sx * r}`,
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
`V ${y2}`,
].join(' ');
}
function rectCenter(r: CanvasRect): { x: number; y: number } {
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
/** Unit direction the line travels as it enters a card through `side`. */
function entryDirection(side: Side): { x: number; y: number } {
switch (side) {
case 'left': return { x: 1, y: 0 };
case 'right': return { x: -1, y: 0 };
case 'top': return { x: 0, y: 1 };
default: return { x: 0, y: -1 };
}
}
/** A filled triangle whose tip sits on the card edge, pointing the way the line travels. */
export function headPath(tip: Anchor, size: number): string {
const d = entryDirection(tip.side);
const bx = tip.x - d.x * size;
const by = tip.y - d.y * size;
const px = -d.y * size * 0.5;
const py = d.x * size * 0.5;
return `M ${tip.x},${tip.y} L ${bx + px},${by + py} L ${bx - px},${by - py} Z`;
}
export interface Routed { path: string; head: string; labelX: number; labelY: number }
/** The line stops short of the tip so the stroke never pokes through the head. */
export function route(a: Anchor, b: Anchor, headSize: number): Routed {
const d = entryDirection(b.side);
const shorten = headSize * 0.7;
const ex = b.x - d.x * shorten;
const ey = b.y - d.y * shorten;
const horizontal = a.side === 'left' || a.side === 'right';
const path = horizontal ? elbowPath(a.x, a.y, ex, ey) : verticalElbowPath(a.x, a.y, ex, ey);
return {
path,
head: headPath(b, headSize),
labelX: a.x + (b.x - a.x) / 2,
labelY: a.y + (b.y - a.y) / 2,
};
}
// ---------- links ----------
interface Link {
key: string;
srcId: string;
src: Rect;
dstId: string;
dst: Rect;
label: string;
fading: boolean;
glow: boolean;
}
function shifted(r: Rect, id: string, drag: LiveDragInfo | null): Rect {
return drag && drag.cardId === id ? { ...r, x: r.x + drag.dx, y: r.y + drag.dy } : r;
}
/**
* Sides first, then anchors: every link leaving the same edge of the same card gets its own spot
* along that edge, ordered by where its other end sits, so lines fan out instead of overprinting.
*/
export function layoutLinks(links: Link[], zoom: number): Tether[] {
const sides = links.map((l) => pickSides(l.src, l.dst));
const along = (side: Side, r: Rect): number => (side === 'left' || side === 'right' ? center(r).y : center(r).x);
const fractionFor = (which: 'src' | 'dst'): number[] => {
const out = new Array<number>(links.length).fill(SINGLE_AT);
const groups = new Map<string, number[]>();
links.forEach((l, i) => {
const id = which === 'src' ? l.srcId : l.dstId;
const key = `${id}|${sides[i][which]}`;
const g = groups.get(key);
if (g) g.push(i); else groups.set(key, [i]);
});
for (const [, idx] of groups) {
if (idx.length === 1) continue;
const side = sides[idx[0]][which];
idx.sort((p, q) => along(side, which === 'src' ? links[p].dst : links[p].src) - along(side, which === 'src' ? links[q].dst : links[q].src));
const fr = fanFractions(idx.length);
idx.forEach((i, k) => { out[i] = fr[k]; });
}
return out;
};
const srcFr = fractionFor('src');
const dstFr = fractionFor('dst');
const headSize = HEAD_PX / Math.max(zoom, 0.05);
return links.map((l, i) => {
const a = anchorAt(l.src, sides[i].src, srcFr[i]);
const b = anchorAt(l.dst, sides[i].dst, dstFr[i]);
const r = route(a, b, headSize);
return { key: l.key, path: r.path, head: r.head, labelX: r.labelX, labelY: r.labelY, label: l.label, labelScale: 1 / Math.max(zoom, 0.05), fading: l.fading, glow: l.glow };
});
}
// ---------- the hook ----------
export interface TetherInputs {
glowingAgentCards: Record<string, GlowingAgentCard>;
glowingBrowserCards: Record<string, GlowingBrowserCard>;
@@ -87,370 +229,150 @@ export interface TetherInputs {
expandedSessionIds: string[];
measuredHeightsRef: RefObject<Record<string, number>>;
measuredHeightsTick: number;
sessionList: AgentSession[];
sessions: TetherSession[];
workflowsHub: WorkflowsHubPosition | null;
workflowsMonitorCard: WorkflowsHubPosition | null;
workflowsMonitorLabel: string;
/** Session id of the run the monitor is showing; its browser tethers to the monitor card, not a (suppressed) standalone agent card. */
monitorRunSessionId: string | null;
/** The committed camera zoom; heads and labels are sized against it. */
zoom: number;
}
export function useTethers({
glowingAgentCards,
glowingBrowserCards,
cards,
browserCards,
workflowCards,
workflowItems,
workflowOpenCards,
viewCards,
outputs,
expandedSessionIds,
measuredHeightsRef,
measuredHeightsTick,
sessionList,
workflowsHub,
workflowsMonitorCard,
workflowsMonitorLabel,
monitorRunSessionId,
}: TetherInputs, liveDragInfo: LiveDragInfo | null): Tether[] {
export function useTethers(inputs: TetherInputs, liveDragInfo: LiveDragInfo | null): Tether[] {
const {
glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards,
viewCards, outputs, expandedSessionIds, measuredHeightsRef, measuredHeightsTick, sessions,
workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId, zoom,
} = inputs;
return useMemo(() => {
const sessionById = new Map(sessionList.map((s) => [s.id, s]));
const sessionById = new Map(sessions.map((s) => [s.id, s]));
const expandedSet = new Set(expandedSessionIds);
const measured = measuredHeightsRef.current;
// A collapsed chat renders as a pill that already previews its browser, so an arrow from the
// pill duplicates the link and reads as clutter; arrows only make sense from an OPEN chat.
const sourceIsCollapsedChat = (sid: string): boolean =>
sid !== monitorRunSessionId && sessionById.has(sid) && !expandedSet.has(sid);
const wfHeight = (wc: WorkflowCardPosition): number =>
measuredHeightsRef.current![wc.workflow_id] ?? wc.height;
const p_agentPairs = Object.entries(glowingAgentCards)
.filter(([copyId, { sourceId }]) => cards[sourceId] && cards[copyId]);
const agentRect = (id: string, c: CardPosition): Rect => ({ x: c.x, y: c.y, width: c.width, height: agentCardHeight(id, c.height, expandedSet.has(id), measured) });
const wfRect = (wc: WorkflowCardPosition): Rect => ({ x: wc.x, y: wc.y, width: wc.width, height: measured?.[wc.workflow_id] ?? wc.height });
const plainRect = (r: { x: number; y: number; width: number; height: number }): Rect => ({ x: r.x, y: r.y, width: r.width, height: r.height });
// One tether builder for both browser and view cards: the anchor-pairing and elbow/vertical path are identical; only the destination card map and the key prefix differ, so the resolved dst card is passed in.
function cardTether(
dst: { x: number; y: number; width: number; height: number } | undefined,
dstId: string,
sourceId: string,
key: string,
label: string,
fading: boolean,
): Tether | null {
// Workflow chats have no standalone agent card: a run anchors to the monitor card, an edit/compose chat to the hub window, so the browser tether lands on the workflow surface instead of nothing.
// Workflow chats have no standalone agent card: a run anchors to the monitor card, an edit/compose
// chat to the hub window, so a browser tether lands on the workflow surface instead of nothing.
const sourceRect = (sourceId: string): { id: string; rect: Rect } | null => {
const srcSession = sessionById.get(sourceId);
const srcIsMonitor = !!workflowsMonitorCard && sourceId === monitorRunSessionId;
const srcIsHub = !srcIsMonitor && !!workflowsHub && !!srcSession?.workflow_edit_id;
const src = srcIsMonitor ? workflowsMonitorCard : srcIsHub ? workflowsHub : cards[sourceId];
if (!src || !dst) return null;
if (workflowsMonitorCard && sourceId === monitorRunSessionId) return { id: 'workflows-monitor', rect: plainRect(workflowsMonitorCard) };
if (workflowsHub && srcSession?.workflow_edit_id) return { id: 'workflows-hub', rect: plainRect(workflowsHub) };
const c = cards[sourceId];
return c ? { id: sourceId, rect: agentRect(sourceId, c) } : null;
};
const srcDragId = srcIsMonitor ? 'workflows-monitor' : srcIsHub ? 'workflows-hub' : sourceId;
let srcX = src.x, srcY = src.y;
let dstX = dst.x, dstY = dst.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === srcDragId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
}
const links: Link[] = [];
const cardTether = (dst: Rect | undefined, dstId: string, sourceId: string, key: string, label: string, fading: boolean, glow: boolean): void => {
const src = sourceRect(sourceId);
if (!src || !dst) return;
links.push({ key, srcId: src.id, src: shifted(src.rect, src.id, liveDragInfo), dstId, dst: shifted(dst, dstId, liveDragInfo), label, fading, glow });
};
const srcH = agentCardHeight(sourceId, src.height, expandedSessionIds.includes(sourceId), measuredHeightsRef.current);
// Browser and view cards have no measured entry, so this reads exactly as dst.height for them
// and only changes the agent-to-agent case.
const dstH = agentCardHeight(dstId, dst.height, expandedSessionIds.includes(dstId), measuredHeightsRef.current);
const srcCx = srcX + src.width / 2;
const dstCx = dstX + dst.width / 2;
const srcAnchors: Anchor[] = [
{ x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' },
{ x: srcX, y: srcY + srcH * 0.54, side: 'left' },
{ x: srcCx, y: srcY, side: 'top' },
{ x: srcCx, y: srcY + srcH, side: 'bottom' },
];
const dstAnchors: Anchor[] = [
{ x: dstX, y: dstY + dstH * 0.54, side: 'left' },
{ x: dstX + dst.width, y: dstY + dstH * 0.54, side: 'right' },
{ x: dstCx, y: dstY, side: 'top' },
{ x: dstCx, y: dstY + dstH, side: 'bottom' },
];
let bestSrc = srcAnchors[0], bestDst = dstAnchors[0];
let bestDist = Infinity;
for (const sa of srcAnchors) {
for (const da of dstAnchors) {
const d = Math.hypot(sa.x - da.x, sa.y - da.y);
if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; }
}
}
const x1 = bestSrc.x, y1 = bestSrc.y;
const x2 = bestDst.x, y2 = bestDst.y;
const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom')
&& (bestDst.side === 'top' || bestDst.side === 'bottom');
let pathD: string;
if (isVertical) {
const dx = x2 - x1;
const dy = y2 - y1;
const midY = y1 + dy / 2;
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
? 0
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
pathD = [
`M ${x1},${y1}`,
`V ${midY - sy * r}`,
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
`H ${x2 - sx * r}`,
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
`V ${y2}`,
].join(' ');
} else {
pathD = elbowPath(x1, y1, x2, y2);
}
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
// Center the pill on the line midpoint: the box is left-anchored at labelX, so back off half its text width (same trick as the monitor "Watching" label).
const labelX = midX - (label.length * 7.5) / 2;
const labelY = midY;
return {
key,
path: pathD,
labelX,
labelY,
label,
fading,
};
// Spawn glow: the parent-to-child cue, halo and label, fading after the card has settled.
const agentTethers = new Set<string>();
for (const [childId, { sourceId, fading, label }] of Object.entries(glowingAgentCards)) {
if (!cards[sourceId] || !cards[childId]) continue;
agentTethers.add(childId);
cardTether(agentRect(childId, cards[childId]), childId, sourceId, childId, label || '', fading, true);
}
// A steady link while a child WORKS: after the glow has faded, the only other way to tell which
// pill belongs to which chat was position. Quiet (no halo, no label) and gone when the child rests.
for (const s of sessions) {
if (!s.parent_session_id || agentTethers.has(s.id)) continue;
if (s.status !== 'running' && s.status !== 'waiting_approval') continue;
if (s.mode === 'browser-agent') continue;
if (!cards[s.id] || !cards[s.parent_session_id] || sourceIsCollapsedChat(s.parent_session_id)) continue;
cardTether(agentRect(s.id, cards[s.id]), s.id, s.parent_session_id, `child-${s.id}`, '', false, false);
}
// Sub-agent arrows go through the same anchor search as everything else. They used to leave the
// parent's RIGHT edge and enter the child's LEFT edge unconditionally, which is only right while
// the child sits in its spawn column; drag it anywhere else and the line looped back across both
// cards, reading as an orange thread attached to nothing (ENG-412).
const agentTethers = p_agentPairs.map(([copyId, { sourceId, fading, label }]) => cardTether(
cards[copyId], copyId, sourceId, copyId, label || '', fading,
)).filter(Boolean) as Tether[];
const glowTethers = new Map<string, ReturnType<typeof cardTether>>();
// An "app:<output_id>" glow key targets a VIEW card (AppAgent driving an app); everything else is a browser card.
const glowTarget = (id: string) => (id.startsWith('app:') ? viewCards[id.slice(4)] : browserCards[id]);
const browserLinked = new Set<string>();
for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) {
const target = glowTarget(browserId);
// A docked card (browser OR app) renders INSIDE its chat; an arrow to it points at nothing.
if (glowTarget(browserId)?.docked_to) continue;
if (!target || target.docked_to) continue;
if (sourceIsCollapsedChat(sourceId)) continue;
const t = cardTether(
glowTarget(browserId),
browserId,
sourceId,
`browser-${browserId}`,
label || '',
fading,
);
if (t) glowTethers.set(browserId, t);
browserLinked.add(browserId);
cardTether(plainRect(target), browserId, sourceId, `browser-${browserId}`, label || '', fading, true);
}
for (const s of sessionList) {
for (const s of sessions) {
if (s.mode !== 'browser-agent') continue;
if (s.status !== 'running' && s.status !== 'waiting_approval') continue;
if (!s.browser_id || !s.parent_session_id) continue;
if (glowTethers.has(s.browser_id)) continue;
// Docked browsers live INSIDE the chat; an arrow to them points at nothing.
if (browserCards[s.browser_id]?.docked_to) continue;
if (!s.browser_id || !s.parent_session_id || browserLinked.has(s.browser_id)) continue;
const target = browserCards[s.browser_id];
if (!target || target.docked_to) continue;
if (sourceIsCollapsedChat(s.parent_session_id)) continue;
// A browser docked below the hub keeps a "Browser" pointer so the link reads at a glance; the right-docked agent/run cases stay label-free (their glow already said it on spawn).
// A browser docked below the hub keeps a "Browser" pointer so the link reads at a glance.
const parent = sessionById.get(s.parent_session_id);
const t = cardTether(
glowTarget(s.browser_id),
s.browser_id,
s.parent_session_id,
`browser-${s.browser_id}`,
parent?.workflow_edit_id ? 'Browser' : '',
false,
);
if (t) glowTethers.set(s.browser_id, t);
browserLinked.add(s.browser_id);
cardTether(plainRect(target), s.browser_id, s.parent_session_id, `browser-${s.browser_id}`, parent?.workflow_edit_id ? 'Browser' : '', false, false);
}
const browserTethers = Array.from(glowTethers.values()).filter(Boolean) as Tether[];
// Workflow tethers reuse the browser-tether anchor/elbow math; skip deleted workflows to avoid dangling arrows.
const workflowTethers: Tether[] = [];
// "Make workflow" is a draft-time affordance: the chat that authored a workflow card, until it is saved.
for (const wc of Object.values(workflowCards)) {
const sourceId = wc.source_session_id;
if (!sourceId) continue;
const src = cards[sourceId];
if (!src) continue;
// Layout entry can outlive its workflow when deleted from the hub.
const hasReal = wc.workflow_id in workflowItems;
const hasDraft = wc.workflow_id in workflowOpenCards;
if (!hasReal && !hasDraft) continue;
// "Make workflow" is a draft-time affordance; once saved (openCard leaves 'preview') the link retires.
if (!sourceId || !cards[sourceId]) continue;
if (!(wc.workflow_id in workflowItems) && !(wc.workflow_id in workflowOpenCards)) continue;
const openCard = workflowOpenCards[wc.workflow_id];
if (openCard && openCard.view !== 'preview') continue;
let srcX = src.x, srcY = src.y;
let dstX = wc.x, dstY = wc.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
}
const srcH = agentCardHeight(sourceId, src.height, expandedSessionIds.includes(sourceId), measuredHeightsRef.current);
const wcH = wfHeight(wc);
const srcCx = srcX + src.width / 2;
const dstCx = dstX + wc.width / 2;
const srcAnchors: Anchor[] = [
{ x: srcX + src.width, y: srcY + srcH * 0.54, side: 'right' },
{ x: srcX, y: srcY + srcH * 0.54, side: 'left' },
{ x: srcCx, y: srcY, side: 'top' },
{ x: srcCx, y: srcY + srcH, side: 'bottom' },
];
const dstAnchors: Anchor[] = [
{ x: dstX, y: dstY + wcH * 0.54, side: 'left' },
{ x: dstX + wc.width, y: dstY + wcH * 0.54, side: 'right' },
{ x: dstCx, y: dstY, side: 'top' },
{ x: dstCx, y: dstY + wcH, side: 'bottom' },
];
let bestSrc = srcAnchors[0], bestDst = dstAnchors[0];
let bestDist = Infinity;
for (const sa of srcAnchors) {
for (const da of dstAnchors) {
const d = Math.hypot(sa.x - da.x, sa.y - da.y);
if (d < bestDist) { bestDist = d; bestSrc = sa; bestDst = da; }
}
}
const x1 = bestSrc.x, y1 = bestSrc.y;
const x2 = bestDst.x, y2 = bestDst.y;
const isVertical = (bestSrc.side === 'top' || bestSrc.side === 'bottom')
&& (bestDst.side === 'top' || bestDst.side === 'bottom');
let pathD: string;
if (isVertical) {
const dx = x2 - x1;
const dy = y2 - y1;
const midY = y1 + dy / 2;
const r = (Math.abs(dx) < 1 || Math.abs(dy) < ELBOW_RADIUS * 2)
? 0
: Math.min(ELBOW_RADIUS, Math.abs(dx) / 2, Math.abs(dy) / 4);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
pathD = [
`M ${x1},${y1}`,
`V ${midY - sy * r}`,
`Q ${x1},${midY} ${x1 + sx * r},${midY}`,
`H ${x2 - sx * r}`,
`Q ${x2},${midY} ${x2},${midY + sy * r}`,
`V ${y2}`,
].join(' ');
} else {
pathD = elbowPath(x1, y1, x2, y2);
}
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
const labelX = isVertical ? midX : midX + (x2 - midX) * 0.15;
const labelY = isVertical ? midY + (y2 - midY) * 0.15 : y2;
workflowTethers.push({
key: `workflow-${wc.workflow_id}`,
path: pathD,
labelX,
labelY,
label: 'Make workflow',
fading: false,
});
cardTether(wfRect(wc), wc.workflow_id, sourceId, `workflow-${wc.workflow_id}`, 'Make workflow', false, false);
}
// Sidecar tethers: workflow card to its sibling agent session (View Agent / Watch Live / Test Agent).
// Sidecar: a workflow card to the sibling chat that watches or tests it.
for (const wc of Object.values(workflowCards)) {
const openCard = workflowOpenCards[wc.workflow_id];
if (!openCard?.sidecarSessionId || !openCard.sidecarKind) continue;
const sidecarId = openCard.sidecarSessionId;
const sidecar = cards[sidecarId];
const sidecar = cards[openCard.sidecarSessionId];
if (!sidecar) continue;
let srcX = wc.x, srcY = wc.y;
let dstX = sidecar.x, dstY = sidecar.y;
if (liveDragInfo) {
if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; }
if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; }
}
const dstH = agentCardHeight(sidecarId, sidecar.height, expandedSessionIds.includes(sidecarId), measuredHeightsRef.current);
const wcH = wfHeight(wc);
const workflowRect = { x: srcX, y: srcY, width: wc.width, height: wcH };
const sidecarRect = { x: dstX, y: dstY, width: sidecar.width, height: dstH };
const srcCenter = rectCenter(workflowRect);
const dstCenter = rectCenter(sidecarRect);
const a = borderPoint(workflowRect.x, workflowRect.y, workflowRect.width, workflowRect.height, dstCenter.x, dstCenter.y);
const b = borderPoint(sidecarRect.x, sidecarRect.y, sidecarRect.width, sidecarRect.height, srcCenter.x, srcCenter.y);
const x1 = a.x, y1 = a.y;
const x2 = b.x, y2 = b.y;
const pathD = elbowPath(x1, y1, x2, y2);
const midX = x1 + (x2 - x1) / 2;
const midY = y1 + (y2 - y1) / 2;
const sidecarLabel = openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching';
workflowTethers.push({
links.push({
key: `sidecar-${wc.workflow_id}`,
path: pathD,
labelX: midX,
labelY: midY,
label: sidecarLabel,
srcId: wc.workflow_id,
src: shifted(wfRect(wc), wc.workflow_id, liveDragInfo),
dstId: openCard.sidecarSessionId,
dst: shifted(agentRect(openCard.sidecarSessionId, sidecar), openCard.sidecarSessionId, liveDragInfo),
label: openCard.sidecarKind === 'testing' ? 'Testing' : 'Watching',
fading: false,
glow: false,
});
}
// Run Monitor tether: the Workflows window to its spawned live-run card.
const monitorTethers: Tether[] = [];
// The Workflows window to its live-run monitor card.
if (workflowsHub && workflowsMonitorCard) {
let hubX = workflowsHub.x, hubY = workflowsHub.y;
let monX = workflowsMonitorCard.x, monY = workflowsMonitorCard.y;
// Track live drag so the line follows the card in real time instead of snapping into place on drop (same mechanism as the agent->browser tether).
if (liveDragInfo) {
if (liveDragInfo.cardId === 'workflows-hub') { hubX += liveDragInfo.dx; hubY += liveDragInfo.dy; }
if (liveDragInfo.cardId === 'workflows-monitor') { monX += liveDragInfo.dx; monY += liveDragInfo.dy; }
}
// The monitor always spawns directly right of the hub, so anchor at the hub's right edge and the monitor's left edge at the same 0.54 height the browser/agent tethers use. Keeps the window->monitor line at the identical vertical spot as the monitor->browser line.
const a = { x: hubX + workflowsHub.width, y: hubY + workflowsHub.height * 0.54 };
const b = { x: monX, y: monY + workflowsMonitorCard.height * 0.54 };
const midX = a.x + (b.x - a.x) / 2;
const midY = a.y + (b.y - a.y) / 2;
// The label box is left-anchored at labelX (rect starts there and grows right), so shift left by half the text width to truly center it on the line.
monitorTethers.push({
links.push({
key: 'workflows-monitor',
path: elbowPath(a.x, a.y, b.x, b.y),
labelX: midX - (workflowsMonitorLabel.length * 7.5) / 2,
labelY: midY,
srcId: 'workflows-hub',
src: shifted(plainRect(workflowsHub), 'workflows-hub', liveDragInfo),
dstId: 'workflows-monitor',
dst: shifted(plainRect(workflowsMonitorCard), 'workflows-monitor', liveDragInfo),
label: workflowsMonitorLabel,
fading: false,
glow: false,
});
}
// Index outputs by their owning session so the per-session lookup below doesn't scan the whole outputs map for every view-builder chat.
// An app builder chat to the app it is editing, while it works.
const outputsBySession = new Map<string, string[]>();
for (const o of Object.values(outputs)) {
if (!o.session_id) continue;
const arr = outputsBySession.get(o.session_id);
if (arr) arr.push(o.id); else outputsBySession.set(o.session_id, [o.id]);
}
const viewTethers: Tether[] = [];
for (const s of sessionList) {
for (const s of sessions) {
if (s.mode !== 'view-builder') continue;
if (s.status !== 'running' && s.status !== 'waiting_approval') continue;
const outIds = outputsBySession.get(s.id);
if (!outIds) continue;
for (const outputId of outIds) {
if (!viewCards[outputId]) continue;
const t = cardTether(
viewCards[outputId],
outputId,
s.id,
`view-${outputId}`,
'Editing',
false,
);
if (t) viewTethers.push(t);
for (const outputId of outputsBySession.get(s.id) ?? []) {
const vc = viewCards[outputId];
if (!vc || vc.docked_to) continue;
cardTether(plainRect(vc), outputId, s.id, `view-${outputId}`, 'Editing', false, false);
}
}
return [...agentTethers, ...browserTethers, ...workflowTethers, ...viewTethers, ...monitorTethers];
return layoutLinks(links, zoom);
// measuredHeightsTick re-runs the memo once ResizeObserver reports a new height after a collapse (the ref read is invisible to the dep checker). eslint-disable-next-line react-hooks/exhaustive-deps
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId]);
}, [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, liveDragInfo, measuredHeightsTick, sessions, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId, zoom]);
}
@@ -12,19 +12,23 @@ import { EXPANDED_CARD_MIN_H } from '@/shared/state/dashboardLayoutSlice';
const here = path.join(process.cwd(), 'src/app/pages/Dashboard/geometry');
const tethers = fs.readFileSync(path.join(here, 'dashboardTethers.ts'), 'utf8');
test('the sub-agent arrow goes through the shared anchor search', () => {
test('the sub-agent arrow goes through the shared builder, and every family routes through one layout pass', () => {
const i = tethers.indexOf('const agentTethers =');
assert.ok(i > 0, 'agentTethers must still exist');
const body = tethers.slice(i, i + 400);
const body = tethers.slice(i, i + 600);
assert.ok(body.includes('cardTether('), 'it must reuse the builder that picks anchors');
assert.ok(!body.includes('srcX + src.width'), 'no hardcoded right-edge exit survives');
// One router: the workflow copy of the anchor search is gone for good.
assert.equal(tethers.match(/bestDist = Infinity/g), null);
assert.equal(tethers.match(/return layoutLinks\(links, zoom\)/g)!.length, 1);
});
test('every tether family reads one height formula, so none can drift', () => {
// Four hand-rolled copies disagreed on the expanded case; that is why the arrow anchored where
// the card was not and the sibling stack cursor left cards overlapping.
assert.equal(tethers.match(/Math\.max\(EXPANDED_CARD_MIN_H/g), null);
assert.ok(tethers.match(/agentCardHeight\(/g)!.length >= 4);
assert.equal(tethers.match(/agentCardHeight\(/g)!.length, 1, 'one height read, in agentRect, shared by every family');
assert.equal(tethers.match(/renderedAgentCardHeight\(/g), null);
const restack = fs.readFileSync(
path.join(process.cwd(), 'src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts'), 'utf8');
assert.ok(restack.includes('agentCardHeight('), 'the restack must agree with the tether by construction');
@@ -14,7 +14,7 @@ import { getCardRect } from '../../geometry/getCardRect';
import { computeContentBounds } from '../../geometry/contentBounds';
import { useDashboardUiState } from './useDashboardUiState';
import { useLayoutSave } from './useLayoutSave';
import type { TetherInputs } from '../../geometry/dashboardTethers';
import type { TetherInputs, TetherSession } from '../../geometry/dashboardTethers';
import { useArrowNav } from '../interaction/useArrowNav';
import { useDashboardShortcuts } from '../interaction/useDashboardShortcuts';
import { useDashboardClipboard } from '../interaction/useDashboardClipboard';
@@ -344,6 +344,14 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
measuredHeightsTick,
});
// The tethers read six facts per session. Keyed on a signature of just those, so a streamed
// message (which replaces the sessions dict) does not re-run the tether geometry every chunk.
const tetherSessionsSig = sessionList.map((s) => `${s.id}|${s.mode}|${s.status}|${s.browser_id ?? ''}|${s.parent_session_id ?? ''}|${s.workflow_edit_id ?? ''}`).join('\n');
const tetherSessions = useMemo<TetherSession[]>(
() => sessionList.map((s) => ({ id: s.id, mode: s.mode, status: s.status, browser_id: s.browser_id, parent_session_id: s.parent_session_id, workflow_edit_id: s.workflow_edit_id })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tetherSessionsSig],
);
// Bundled for the canvas's TetherLayerHost, which re-renders ALONE on drag frames; holding drag
// state here re-rendered the whole page per pointer move (the ENG-88 input delay).
const tetherInputs = useMemo<TetherInputs>(() => ({
@@ -359,12 +367,13 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
expandedSessionIds,
measuredHeightsRef,
measuredHeightsTick,
sessionList,
sessions: tetherSessions,
workflowsHub,
workflowsMonitorCard,
workflowsMonitorLabel,
monitorRunSessionId,
}), [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, measuredHeightsRef, measuredHeightsTick, sessionList, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId]);
zoom: canvas.zoom,
}), [glowingAgentCards, glowingBrowserCards, cards, browserCards, workflowCards, workflowItems, workflowOpenCards, viewCards, outputs, expandedSessionIds, measuredHeightsRef, measuredHeightsTick, tetherSessions, workflowsHub, workflowsMonitorCard, workflowsMonitorLabel, monitorRunSessionId, canvas.zoom]);
return {
c, dashboardId, dashboardName, canvas, selection, sessions, sessionList,