import type { Session } from './api'; // The agents of a family as a list below one session and as a tree, with // nothing but the sessions themselves to go on, and the two things that go // with a tree in a window: how often to read it and how big its window may grow. const oldestFirst = (a: Session, b: Session) => (a.created_at ?? '').localeCompare(b.created_at ?? '') || a.id.localeCompare(b.id); function pushMap(map: Map, key: K, value: V) { let list = map.get(key); if (!list) map.set(key, (list = [])); list.push(value); } function childrenOf(family: Session[]): Map { const children = new Map(); for (const session of [...family].sort(oldestFirst)) { if (session.parent_id) pushMap(children, session.parent_id, session); } return children; } /** * Every agent below a session, depth first, oldest first; with `rank`, * siblings go lowest rank first (ranks compare item by item), a branch ranked * by the lowest in it, so a working agent's parents come up with it. */ export function descendants(family: Session[], id: string, rank?: (session: Session) => number[]): Session[] { const children = childrenOf(family); if (rank) { const best = new Map(); const branch = (session: Session): number[] => { let low = best.get(session.id); if (!low) best.set(session.id, (low = [rank(session), ...(children.get(session.id) ?? []).map(branch)].reduce((a, b) => (lower(b, a) < 0 ? b : a)))); return low; }; for (const list of children.values()) list.sort((a, b) => lower(branch(a), branch(b))); } const found: Session[] = []; const walk = (parent: string) => { for (const child of children.get(parent) ?? []) { found.push(child); walk(child.id); } }; walk(id); return found; } function lower(a: number[], b: number[]): number { for (let at = 0; at < a.length; at++) if (a[at] !== b[at]) return a[at] - b[at]; return 0; } /** * How soon a family is read again, in ms: closely while an agent works or its * root has a turn in hand (that is when agents are started), and rarely at * rest. The events socket follows only roots, so a family is polled. */ export const FAMILY_MS = { working: 2000, turn: 3000, agents: 6000, none: 30_000, retry: 6000 }; export function familyDelay(family: Session[], rootId: string, working: (session: Session) => boolean, rootWorking: boolean): number { // The family holds its root, whose turn is the `turn` pace and not an agent working. const agents = family.filter((session) => session.id !== rootId); if (agents.some(working)) return FAMILY_MS.working; if (rootWorking) return FAMILY_MS.turn; return agents.length ? FAMILY_MS.agents : FAMILY_MS.none; } /** * The family without the agents that are not working: the ones with no turn * running and none working below them. The root stays, and an agent that is * not working but has one working below it stays too, so each shown agent * still hangs from its parent. */ export function withoutInactive(family: Session[], rootId: string, working: (session: Session) => boolean): Session[] { const children = childrenOf(family); const kept = new Set(); const visit = (session: Session): boolean => { let keep = session.id === rootId || working(session); for (const child of children.get(session.id) ?? []) if (visit(child)) keep = true; if (keep) kept.add(session.id); return keep; }; const root = family.find((session) => session.id === rootId); if (root) visit(root); return family.filter((session) => kept.has(session.id)); } /** Card geometry and spacing for full, folded, and compact closed pills. */ export const CARD = { minWidth: 176, maxWidth: 340, height: 54, closedHeight: 34, gapX: 48, gapY: 12, staggerGapY: 18, subColGapX: 38, }; export interface FoldInfo { parentId: string; count: number; expanded: boolean; model: string | null; } export type Placed = { id: string; parentId: string | null; session: Session; x: number; y: number; width: number; height: number; compact: boolean; subCol: number; fold?: FoldInfo; isLead?: boolean; }; export type Link = { id: string; from: string; to: string; d: string; subCol: number; }; export interface TreeLayout { nodes: Placed[]; links: Link[]; width: number; height: number; fitBox: Box; } export interface HoverPaths { ancestors: Set; upEdges: Set; downNodeHop: Map; downEdgeHop: Map; } const isCore = (node: Placed) => !node.session.closed || !!node.fold; /** * Which core cards a tree has and where they sit, as text: it changes when an * active agent or fold handle starts, goes or moves, and not when a closed fold * is opened or an agent only does something else, so auto-fit never zooms out * when the reader unfolds a closed batch. */ export const shapeOf = (nodes: Placed[]): string => { const core = nodes.filter(isCore); const source = core.length ? core : nodes; if (!source.length) return ''; const minX = Math.min(...source.map((node) => node.x)); const minY = Math.min(...source.map((node) => node.y)); return source.map((node) => `${node.id}@${node.x - minX},${node.y - minY},${node.width}`).join(' '); }; function curveLink(from: Placed, to: Placed): Link { const left = to.x + to.width <= from.x; const sx = left ? from.x : from.x + from.width; const sy = from.y + from.height / 2; const ex = left ? to.x + to.width : to.x; const ey = to.y + to.height / 2; const dist = Math.abs(ex - sx); const sign = left ? -1 : 1; const far = dist > CARD.gapX + 20; const c1x = sx + sign * (far ? CARD.gapX * 0.45 : dist * 0.5); const c2x = sx + sign * (far ? CARD.gapX * 0.85 : dist * 0.5); return { id: `${from.id}->${to.id}`, from: from.id, to: to.id, d: `M${sx},${sy} C${c1x},${sy} ${c2x},${ey} ${ex},${ey}`, subCol: to.subCol }; } interface TreeItem { id: string; parentId: string | null; session: Session; fold?: FoldInfo; isLead: boolean; } function buildHierarchy(family: Session[], root: Session, expanded: ReadonlySet): TreeItem[] { const children = childrenOf(family); const items: TreeItem[] = [ { id: root.id, parentId: null, session: root, isLead: (children.get(root.id)?.length ?? 0) > 0, }, ]; const visit = (parent: Session) => { const kids = children.get(parent.id) ?? []; const openOrBranch: Session[] = []; const closedLeaves: Session[] = []; for (const child of kids) { const hasSub = (children.get(child.id)?.length ?? 0) > 0; if (child.closed && !hasSub) closedLeaves.push(child); else openOrBranch.push(child); } for (const child of openOrBranch) { const hasSub = (children.get(child.id)?.length ?? 0) > 0; items.push({ id: child.id, parentId: parent.id, session: child, isLead: hasSub }); visit(child); } if (closedLeaves.length === 1) { const only = closedLeaves[0]; items.push({ id: only.id, parentId: parent.id, session: only, isLead: false }); } else if (closedLeaves.length > 1) { const isExpanded = expanded.has(parent.id); const foldId = `fold:${parent.id}`; const first = closedLeaves[0]; items.push({ id: foldId, parentId: parent.id, session: { ...first, id: foldId, parent_id: parent.id, name: `${isExpanded ? '▾' : '▸'} ${closedLeaves.length} closed agents`, closed: false, }, fold: { parentId: parent.id, count: closedLeaves.length, expanded: isExpanded, model: first.model ?? null, }, isLead: false, }); if (isExpanded) { for (const closed of closedLeaves) { items.push({ id: closed.id, parentId: foldId, session: closed, isLead: false }); } } } }; visit(root); return items; } interface Span { x: number; width: number; bottom: number; } function contourOffsetY(spans: Span[], incoming: Placed[], minGapY: number): number { if (!spans.length) return 0; let required = 0; for (const node of incoming) { const left = node.x - 14; const right = node.x + node.width + 14; for (const span of spans) { if (span.x < right && span.x + span.width > left) { const needed = span.bottom + minGapY - node.y; if (needed > required) required = needed; } } } return required; } function recordSpans(spans: Span[], nodes: Placed[]) { for (const node of nodes) { const bottom = node.y + node.height; const existing = spans.find((span) => span.x === node.x && span.width === node.width); if (existing) existing.bottom = Math.max(existing.bottom, bottom); else spans.push({ x: node.x, width: node.width, bottom }); } } /** * The family as a calm, high-info tree: * - Working and active branches grow to the right of the root; closed folds and * finished branches balance to the left when active work is also present. * - Large leaf sibling sets (> 5) stagger across sub-columns with a half-row * vertical offset so bezier curves glide cleanly through the gaps between rows. * - Closed leaf sets (>= 2) fold per parent into a `▸ N closed agents` node that * sprouts its closed pills when unfolded. * - `fitBox` frames the active/core tree and fold handles (ignoring unfolded * closed pills), so unfolding a closed batch never shrinks the zoom. */ export function layoutTree( family: Session[], rootId: string, wanted: (session: Session) => number = () => CARD.minWidth, expanded: ReadonlySet = new Set(), ): TreeLayout { const root = family.find((session) => session.id === rootId); if (!root) return { nodes: [], links: [], width: 0, height: 0, fitBox: { x: 0, y: 0, width: 0, height: 0 } }; const items = buildHierarchy(family, root, expanded); const byParent = new Map(); for (const item of items) { if (item.parentId) pushMap(byParent, item.parentId, item); } const widthCache = new Map(); const clampWidth = (session: Session) => { let w = widthCache.get(session.id); if (w === undefined) { w = Math.min(CARD.maxWidth, Math.max(CARD.minWidth, Math.ceil(wanted(session)))); widthCache.set(session.id, w); } return w; }; const itemSize = (item: TreeItem, minColWidth = 0) => { const base = Math.max(minColWidth, clampWidth(item.session)); if (item.fold) return { width: Math.max(184, base), height: CARD.height, compact: false }; if (item.session.closed) return { width: Math.max(164, base), height: CARD.closedHeight, compact: true }; return { width: base, height: CARD.height, compact: false }; }; const placeItem = (item: TreeItem, x: number, y: number, sz: { width: number; height: number; compact: boolean }, subCol = 0): Placed => ({ id: item.id, parentId: item.parentId, session: item.session, x, y, width: sz.width, height: sz.height, compact: sz.compact, subCol, fold: item.fold, isLead: item.isLead, }); const fanStepped = (kids: TreeItem[], minColWidth = 0) => { const count = kids.length; const cols = count <= 5 ? 1 : count <= 16 ? 2 : Math.min(5, Math.ceil(count / 10)); const perCol = Math.ceil(count / cols); const gapY = cols > 1 ? CARD.staggerGapY : CARD.gapY; const colWidths: number[] = []; const colX: number[] = []; let nextX = 0; for (let c = 0; c < cols; c++) { const slice = kids.slice(c * perCol, (c + 1) * perCol); const w = Math.max(...slice.map((item) => itemSize(item, c === 0 ? minColWidth : 0).width)); colWidths[c] = w; colX[c] = nextX; nextX += w + CARD.subColGapX; } const nodes: Placed[] = []; let maxH = 0; for (let c = 0; c < cols; c++) { const slice = kids.slice(c * perCol, (c + 1) * perCol); const sampleH = slice.length ? itemSize(slice[0]).height : CARD.height; const rowStep = sampleH + gapY; let curY = c * (rowStep * 0.5); for (const item of slice) { const sz = itemSize(item, colWidths[c]); nodes.push(placeItem(item, colX[c], curY, sz, c)); curY += sz.height + gapY; } maxH = Math.max(maxH, curY - gapY); } return { nodes, entryY: maxH / 2, width: Math.max(...nodes.map((node) => node.x + node.width)), height: maxH, }; }; interface Subtree { rootId: string; nodes: Placed[]; entryY: number; rootY: number; width: number; height: number; } const buildRightSubtree = (item: TreeItem, map: Map, forcedRootWidth?: number): Subtree => { const sz = itemSize(item, forcedRootWidth ?? 0); const kids = map.get(item.id) ?? []; if (!kids.length) { const node = placeItem(item, 0, 0, sz); return { rootId: item.id, nodes: [node], entryY: sz.height / 2, rootY: 0, width: sz.width, height: sz.height }; } const directColWidth = Math.max(...kids.map((child) => itemSize(child).width)); const foldLeaves = kids.filter((child) => !!child.fold); const nonFoldKids = kids.filter((child) => !child.fold); const allOpenLeaves = nonFoldKids.length > 0 && nonFoldKids.every((child) => !(map.get(child.id)?.length ?? 0)); const childBlocks: { nodes: Placed[]; targetY: number; isFoldBranch: boolean }[] = []; const spans: Span[] = []; const childStartX = sz.width + CARD.gapX; const blockGapY = CARD.gapY; let nextCoreY = 0; const addBlock = (block: { nodes: Placed[]; entryY: number }, isFoldBranch: boolean) => { for (const node of block.nodes) node.x += childStartX; const dy = Math.max(nextCoreY, contourOffsetY(spans, block.nodes, blockGapY)); for (const node of block.nodes) node.y += dy; recordSpans(spans, block.nodes); const coreBottom = Math.max(...block.nodes.filter(isCore).map((n) => n.y + n.height), dy); nextCoreY = coreBottom + blockGapY; childBlocks.push({ nodes: block.nodes, targetY: dy + block.entryY, isFoldBranch }); }; if (allOpenLeaves) { addBlock(fanStepped(nonFoldKids, directColWidth), false); } else { for (const child of nonFoldKids) { addBlock(buildRightSubtree(child, map, directColWidth), false); } } for (const fl of foldLeaves) { const foldSz = itemSize(fl, directColWidth); const foldNode = placeItem(fl, 0, 0, foldSz); const closedKids = map.get(fl.id) ?? []; const subNodes: Placed[] = [foldNode]; if (closedKids.length > 0) { const cFan = fanStepped(closedKids); const fanX = foldSz.width + CARD.gapX; const fanY = foldSz.height / 2 - cFan.entryY; for (const node of cFan.nodes) { node.x += fanX; node.y += fanY; subNodes.push(node); } } const minSubY = Math.min(...subNodes.map((node) => node.y)); if (minSubY < 0) { for (const node of subNodes) node.y -= minSubY; } addBlock({ nodes: subNodes, entryY: foldNode.y + foldSz.height / 2 }, true); } const primaryBlocks = childBlocks.filter((block) => !block.isFoldBranch); const anchorBlocks = primaryBlocks.length ? primaryBlocks : childBlocks; const firstY = anchorBlocks[0].targetY; const lastY = anchorBlocks[anchorBlocks.length - 1].targetY; const rootY = Math.max(0, (firstY + lastY) / 2 - sz.height / 2); const allNodes: Placed[] = [placeItem(item, 0, rootY, sz)]; for (const block of childBlocks) { for (const node of block.nodes) allNodes.push(node); } return { rootId: item.id, nodes: allNodes, entryY: rootY + sz.height / 2, rootY, width: Math.max(...allNodes.map((node) => node.x + node.width)), height: Math.max(...allNodes.map((node) => node.y + node.height)), }; }; const rootItem = items[0]; const rootKids = byParent.get(rootItem.id) ?? []; // Active branches grow right; closed folds and finished branches grow left when active branches exist too. const isFinishedBranch = (candidate: TreeItem): boolean => { if (candidate.fold || candidate.session.closed) return true; const sub = byParent.get(candidate.id) ?? []; return sub.length > 0 && sub.every(isFinishedBranch); }; const activeKids: TreeItem[] = []; const finishedKids: TreeItem[] = []; for (const kid of rootKids) { if (isFinishedBranch(kid)) finishedKids.push(kid); else activeKids.push(kid); } const split = activeKids.length > 0 && finishedKids.length > 0; const rightKids = split ? activeKids : rootKids; const leftKids = split ? finishedKids : []; const rightMap = new Map(byParent); rightMap.set(rootItem.id, rightKids); const rightTree = buildRightSubtree(rootItem, rightMap); const combinedNodes = [...rightTree.nodes]; if (leftKids.length > 0) { const leftMap = new Map(byParent); leftMap.set(rootItem.id, leftKids); const rootW = itemSize(rootItem).width; const leftTree = buildRightSubtree(rootItem, leftMap); const dy = rightTree.rootY - leftTree.rootY; for (const node of leftTree.nodes) { if (node.id === rootItem.id) continue; node.x = -node.x - node.width + rootW; node.y += dy; combinedNodes.push(node); } } const minX = Math.min(...combinedNodes.map((node) => node.x)); const minY = Math.min(...combinedNodes.map((node) => node.y)); const byId = new Map(); for (const node of combinedNodes) { node.x -= minX; node.y -= minY; byId.set(node.id, node); } // Emit each SVG curve once from final placed coordinates. const combinedLinks: Link[] = []; for (const node of combinedNodes) { if (!node.parentId) continue; const parent = byId.get(node.parentId); if (parent) combinedLinks.push(curveLink(parent, node)); } // `fitBox` frames the active/core tree and fold handles, ignoring unfolded closed pills. const coreNodes = combinedNodes.filter(isCore); const fitSource = coreNodes.length ? coreNodes : combinedNodes; const fitMinX = Math.min(...fitSource.map((node) => node.x)); const fitMinY = Math.min(...fitSource.map((node) => node.y)); const fitMaxX = Math.max(...fitSource.map((node) => node.x + node.width)); const fitMaxY = Math.max(...fitSource.map((node) => node.y + node.height)); return { nodes: combinedNodes, links: combinedLinks, width: Math.max(...combinedNodes.map((node) => node.x + node.width)), height: Math.max(...combinedNodes.map((node) => node.y + node.height)), fitBox: { x: fitMinX, y: fitMinY, width: fitMaxX - fitMinX, height: fitMaxY - fitMinY, }, }; } /** * Monotonic lineage paths for a hovered node: * - Every ancestor and edge up to the root (`ancestors`, `upEdges`). * - Every descendant and edge below `hoveredId`, with its hop distance (`1`, `2`, `3`), * where each staggered sub-column also adds a hop so outer sub-columns step down * gently in opacity while staying brighter than resting and unrelated lines. */ export function hoverLineage(nodes: Placed[], hoveredId: string | null): HoverPaths { const ancestors = new Set(); const upEdges = new Set(); const downNodeHop = new Map(); const downEdgeHop = new Map(); if (!hoveredId) return { ancestors, upEdges, downNodeHop, downEdgeHop }; const parentMap = new Map(); const childMap = new Map(); const subColMap = new Map(); for (const node of nodes) { subColMap.set(node.id, node.subCol); if (!node.parentId) continue; parentMap.set(node.id, node.parentId); pushMap(childMap, node.parentId, node.id); } let cur = hoveredId; while (parentMap.has(cur)) { const parent = parentMap.get(cur)!; ancestors.add(parent); upEdges.add(`${parent}->${cur}`); cur = parent; } const walkDown = (id: string, baseDepth: number) => { for (const childId of childMap.get(id) ?? []) { const colStep = subColMap.get(childId) ?? 0; const hop = Math.min(3, baseDepth + colStep); downNodeHop.set(childId, hop); downEdgeHop.set(`${id}->${childId}`, hop); walkDown(childId, baseDepth + 1); } }; walkDown(hoveredId, 1); return { ancestors, upEdges, downNodeHop, downEdgeHop }; } export interface Size { width: number; height: number; } export interface Box extends Size { x: number; y: number; } /** What the controls along a tree window's bottom edge take: 12 px from the edge and about 36 px tall. */ export const BAR = 48; export interface View { x: number; y: number; k: number; } /** * Where a tree (or its active `fitBox`) goes in a window of size `box` and how * far it is scaled (between `min` and `max`): centred, with `pad` around it and * the controls' `bar` kept clear along the bottom, so a card in the last row * does not meet them. */ export function fitView(box: Size, tree: Size | Box, pad: number, bar: number, min: number, max: number): View { const room = { width: box.width - pad * 2, height: box.height - pad * 2 - bar }; const k = Math.max(min, Math.min(max, room.width / tree.width, room.height / tree.height)); const offsetX = 'x' in tree ? tree.x : 0; const offsetY = 'y' in tree ? tree.y : 0; return { k, x: (box.width - tree.width * k) / 2 - offsetX * k, y: pad + (room.height - tree.height * k) / 2 - offsetY * k, }; } /** * The size an agents window should be fitted to, or null to leave it. A window * is fitted only while it still has the size it was last fitted to (`fit`, kept * with the window); any other size is the person's, through a reload as much as * in the same sitting. */ export function refit(size: Size, fit: Size | undefined, want: Size): Size | null { if (!fit || size.width !== fit.width || size.height !== fit.height) return null; return want.width === fit.width && want.height === fit.height ? null : want; } /** * How far a window may grow from `box` toward `want`: into free room, stopping * `gap` short of a window beside it or below it, so a tree that grows does not * cover the chats opened from it. It never shrinks for a neighbour. */ export function growInto(box: Box, others: Box[], want: Size, gap: number): Size { let width = want.width; let height = want.height; for (const other of others) { const beside = other.x >= box.x + box.width && other.y < box.y + want.height && other.y + other.height > box.y; const below = other.y >= box.y + box.height && other.x < box.x + want.width && other.x + other.width > box.x; if (beside) width = Math.min(width, Math.max(box.width, other.x - gap - box.x)); if (below) height = Math.min(height, Math.max(box.height, other.y - gap - box.y)); } return { width, height }; }