diff --git a/frontend/src/app/pages/Dashboard/geometry/agentCardHeight.ts b/frontend/src/app/pages/Dashboard/geometry/agentCardHeight.ts new file mode 100644 index 00000000..b9763361 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/geometry/agentCardHeight.ts @@ -0,0 +1,19 @@ +import { renderedAgentCardHeight } from '@/shared/state/dashboardLayoutSlice'; + +/** + * A chat card's real on-screen height: what the DOM measured, else the stored envelope. + * + * The tether layer and the sibling restack each grew their own version of this and disagreed on the + * expanded case (the restack ignored the measured value), so the arrow anchored where the card was + * not and the stack cursor left siblings overlapping. One formula, so they cannot drift (ENG-412). + */ +export function agentCardHeight( + id: string, + storedHeight: number, + expanded: boolean, + measured: Record | null, +): number { + const m = measured?.[id]; + // A zero reading is a card mid-mount, not a flat card. + return m && m > 0 ? m : renderedAgentCardHeight(storedHeight, expanded); +} diff --git a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts index 51004774..c390c816 100644 --- a/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts +++ b/frontend/src/app/pages/Dashboard/geometry/dashboardTethers.ts @@ -1,9 +1,10 @@ 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 { EXPANDED_CARD_MIN_H, GRID_GAP } from '@/shared/state/dashboardLayoutSlice'; +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; @@ -122,44 +123,8 @@ export function useTethers({ sid !== monitorRunSessionId && sessionById.has(sid) && !expandedSet.has(sid); const wfHeight = (wc: WorkflowCardPosition): number => measuredHeightsRef.current![wc.workflow_id] ?? wc.height; - const agentTethers = Object.entries(glowingAgentCards).map(([copyId, { sourceId, fading, label }]) => { - const src = cards[sourceId]; - const dst = cards[copyId]; - if (!src || !dst) return null; - - let srcX = src.x, srcY = src.y; - let dstX = dst.x, dstY = dst.y; - if (liveDragInfo) { - if (liveDragInfo.cardId === sourceId) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } - if (liveDragInfo.cardId === copyId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } - } - - const srcMeasured = measuredHeightsRef.current![sourceId]; - const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, src.height) - : src.height); - const dstMeasured = measuredHeightsRef.current![copyId]; - const dstH = dstMeasured ?? (expandedSessionIds.includes(copyId) - ? Math.max(EXPANDED_CARD_MIN_H, dst.height) - : dst.height); - - const x1 = srcX + src.width; - const y1 = srcY + srcH * 0.54; - const x2 = dstX; - const y2 = dstY + dstH * (expandedSessionIds.includes(copyId) ? 0.54 : 0.79); - const midX = x1 + (x2 - x1) / 2; - const labelX = midX + (x2 - midX) * 0.15; - const labelY = y2; - - return { - key: copyId, - path: elbowPath(x1, y1, x2, y2), - labelX, - labelY, - label: label || '', - fading, - }; - }).filter(Boolean) as Tether[]; + const p_agentPairs = Object.entries(glowingAgentCards) + .filter(([copyId, { sourceId }]) => cards[sourceId] && cards[copyId]); // 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( @@ -185,11 +150,10 @@ export function useTethers({ if (liveDragInfo.cardId === dstId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } - const srcMeasured = measuredHeightsRef.current![sourceId]; - const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, src.height) - : src.height); - const dstH = dst.height; + 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; @@ -260,6 +224,14 @@ export function useTethers({ }; } + // 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]); @@ -323,10 +295,7 @@ export function useTethers({ if (liveDragInfo.cardId === wc.workflow_id) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } - const srcMeasured = measuredHeightsRef.current![sourceId]; - const srcH = srcMeasured ?? (expandedSessionIds.includes(sourceId) - ? Math.max(EXPANDED_CARD_MIN_H, src.height) - : src.height); + const srcH = agentCardHeight(sourceId, src.height, expandedSessionIds.includes(sourceId), measuredHeightsRef.current); const wcH = wfHeight(wc); const srcCx = srcX + src.width / 2; @@ -403,10 +372,7 @@ export function useTethers({ if (liveDragInfo.cardId === wc.workflow_id) { srcX += liveDragInfo.dx; srcY += liveDragInfo.dy; } if (liveDragInfo.cardId === sidecarId) { dstX += liveDragInfo.dx; dstY += liveDragInfo.dy; } } - const dstMeasured = measuredHeightsRef.current![sidecarId]; - const dstH = dstMeasured ?? (expandedSessionIds.includes(sidecarId) - ? Math.max(EXPANDED_CARD_MIN_H, sidecar.height) - : sidecar.height); + 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 }; diff --git a/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts b/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts new file mode 100644 index 00000000..2b748d11 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/geometry/subAgentTether.test.ts @@ -0,0 +1,48 @@ +// ENG-412: the sub-agent arrow was the one tether family with no anchor search. It left the parent's +// RIGHT edge and entered the child's LEFT edge unconditionally, which is only correct while the child +// sits in its spawn column; drag it left or above and the line looped back across both cards and read +// as an orange thread attached to nothing. Screenshot from production 1.7.9, 2026-08-26. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { agentCardHeight } from './agentCardHeight'; +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', () => { + const i = tethers.indexOf('const agentTethers ='); + assert.ok(i > 0, 'agentTethers must still exist'); + const body = tethers.slice(i, i + 400); + 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'); +}); + +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); + 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'); + assert.equal(restack.match(/Math\.max\(EXPANDED_CARD_MIN_H/g), null); +}); + +test('a measured height wins over the stored envelope, in both states', () => { + assert.equal(agentCardHeight('a', 280, true, { a: 940 }), 940); + assert.equal(agentCardHeight('a', 280, false, { a: 96 }), 96); +}); + +test('an unmeasured card falls back to the rendered envelope', () => { + assert.equal(agentCardHeight('a', 280, true, {}), EXPANDED_CARD_MIN_H); + assert.equal(agentCardHeight('a', 280, false, null), 280); + // A browser card is never in the map, so its stored height must come back untouched. + assert.equal(agentCardHeight('browser-1', 512, false, { 'chat-1': 900 }), 512); +}); + +test('a zero reading is a card mid-mount, not a flat card', () => { + assert.equal(agentCardHeight('a', 280, true, { a: 0 }), EXPANDED_CARD_MIN_H); +}); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts index 86328b59..fe080e62 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useSiblingRestack.ts @@ -2,11 +2,11 @@ import { useEffect, type RefObject } from 'react'; import { useAppDispatch } from '@/shared/hooks'; import { moveCards, - EXPANDED_CARD_MIN_H, GRID_GAP, type CardPosition, type BrowserCardPosition, } from '@/shared/state/dashboardLayoutSlice'; +import { agentCardHeight } from '../../geometry/agentCardHeight'; interface GlowingCard { sourceId: string; @@ -64,10 +64,7 @@ export function useSiblingRestack({ if (Math.abs(dy) > 1) { dispatch(moveCards({ items: [{ id, type: 'agent' as const }], dx: 0, dy })); } - const isExpanded = expandedSessionIds.includes(id); - const h = isExpanded - ? Math.max(EXPANDED_CARD_MIN_H, card.height) - : (measuredHeightsRef.current![id] ?? card.height); + const h = agentCardHeight(id, card.height, expandedSessionIds.includes(id), measuredHeightsRef.current); cursor += h + GRID_GAP * 2; } }