From ddd2e3c0f00d86643f6d4b4cc574447987581b14 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 2 Sep 2026 17:28:59 -0700 Subject: [PATCH] [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 Claude-Session: https://claude.ai/code/session_015xkMseod9vJ3hCrE43ctDo (cherry picked from commit aff630a730c4d7fd12364956fb680a2e311f9788) --- .../pages/Dashboard/canvas/TetherLayer.tsx | 72 +-- .../Dashboard/canvas/TetherLayerHost.tsx | 11 +- .../geometry/dashboardTethers.test.ts | 58 ++ .../Dashboard/geometry/dashboardTethers.ts | 586 ++++++++---------- .../Dashboard/geometry/subAgentTether.test.ts | 10 +- .../hooks/state/useDashboardController.ts | 15 +- 6 files changed, 354 insertions(+), 398 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/geometry/dashboardTethers.test.ts diff --git a/frontend/src/app/pages/Dashboard/canvas/TetherLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/TetherLayer.tsx index 47306ce0..64548029 100644 --- a/frontend/src/app/pages/Dashboard/canvas/TetherLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/TetherLayer.tsx @@ -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 = ({ tethers, c }) => { +const TetherLayer: React.FC = ({ tethers, zoom, c }) => { if (tethers.length === 0) return null; + const showLabels = zoom >= LABEL_MIN_ZOOM; return ( = ({ 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, }} > - - - - - {tethers.map((t) => ( - {/* 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. */} - - - {t.label && ( - // Same glass capsule language as the narrator pills; the old white accent-bordered box read as a stray form element. - + {/* 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 && ( + + )} + + + {t.label && showLabels && ( + // Same glass capsule as the narrator pills, scaled against the camera so it reads at LABEL_FONT_PX whatever the zoom. + - + {t.label} diff --git a/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx b/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx index f56793bc..dcc53b4a 100644 --- a/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/TetherLayerHost.tsx @@ -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(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 ; + return ; }; export default React.memo(TetherLayerHost); diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.test.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.test.ts new file mode 100644 index 00000000..78b54639 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.test.ts @@ -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); +}); diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index c390c816..31ce1f35 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -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; + +// ---------- 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(links.length).fill(SINGLE_AT); + const groups = new Map(); + 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; glowingBrowserCards: Record; @@ -87,370 +229,150 @@ export interface TetherInputs { expandedSessionIds: string[]; measuredHeightsRef: RefObject>; 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(); + 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>(); // An "app:" 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(); 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(); 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]); } diff --git a/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts b/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts index 2b748d11..9352ba66 100644 --- a/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts +++ b/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts @@ -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'); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index d503a8cb..f5979437 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -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( + () => 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(() => ({ @@ -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,