Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642import 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<K, V>(map: Map<K, V[]>, key: K, value: V) { let list = map.get(key); if (!list) map.set(key, (list = [])); list.push(value);}
function childrenOf(family: Session[]): Map<string, Session[]> { const children = new Map<string, Session[]>(); 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. */export function descendants(family: Session[], id: string): Session[] { const children = childrenOf(family); const found: Session[] = []; const walk = (parent: string) => { for (const child of children.get(parent) ?? []) { found.push(child); walk(child.id); } }; walk(id); return found;}
/** * 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<string>(); 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<string>; upEdges: Set<string>; downNodeHop: Map<string, number>; downEdgeHop: Map<string, number>;}
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<string>): 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<string> = 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<string, TreeItem[]>(); for (const item of items) { if (item.parentId) pushMap(byParent, item.parentId, item); }
const widthCache = new Map<string, number>(); 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<string, TreeItem[]>, 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<string, Placed>(); 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<string>(); const upEdges = new Set<string>(); const downNodeHop = new Map<string, number>(); const downEdgeHop = new Map<string, number>(); if (!hoveredId) return { ancestors, upEdges, downNodeHop, downEdgeHop };
const parentMap = new Map<string, string>(); const childMap = new Map<string, string[]>(); const subColMap = new Map<string, number>();
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 };}