|
|
|
@@ -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]);
|
|
|
|
|
}
|
|
|
|
|