[eric] canvas: sub-agent arrows pick their anchors like every other tether, and one height formula (ENG-412)

This commit is contained in:
ciregenz
2026-08-26 12:56:07 -07:00
parent 5617e8b19e
commit 52d764f155
4 changed files with 87 additions and 57 deletions
@@ -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<string, number> | 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);
}
@@ -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<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]);
@@ -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 };
@@ -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);
});
@@ -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;
}
}