import type { OrchestrationV2ThreadProjection, OrchestrationV2ThreadShell, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; export type ThreadRelationshipKind = "parent" | "fork" | "subagent" | "transfer"; export interface ThreadRelationshipNode { readonly threadId: ThreadId; readonly thread: OrchestrationV2ThreadShell | null; readonly missing: boolean; } export interface ThreadRelationshipEdge { readonly sourceThreadId: ThreadId; readonly targetThreadId: ThreadId; readonly kind: ThreadRelationshipKind; readonly status: string | null; } export interface ThreadRelationshipGraph { readonly nodes: ReadonlyMap; readonly edges: ReadonlyArray; } export interface ThreadRelationshipWalkRow { readonly threadId: ThreadId; readonly fromThreadId: ThreadId; readonly depth: number; readonly edge: ThreadRelationshipEdge; } export function resolveMergeBackTargetThreadId( projection: Pick | null, ): ThreadId | null { if (projection?.thread.lineage.relationshipToParent !== "fork") return null; return projection.thread.forkedFrom?.type === "run" ? projection.thread.forkedFrom.threadId : projection.thread.lineage.parentThreadId; } function edgeKey(edge: ThreadRelationshipEdge): string { return `${edge.sourceThreadId}\u001f${edge.targetThreadId}\u001f${edge.kind}`; } export function deriveThreadRelationshipGraph(input: { readonly threads: ReadonlyArray; readonly projection: OrchestrationV2ThreadProjection | null; }): ThreadRelationshipGraph { const threadsById = new Map(); for (const thread of input.threads) { // Callers order shells from most to least authoritative. In particular, // live shells precede archived snapshots, which may still contain a stale // copy during archive refresh. if (!threadsById.has(thread.id)) { threadsById.set(thread.id, thread); } } const threads = [...threadsById.values()]; const nodes = new Map( threads.map((thread) => [thread.id, { threadId: thread.id, thread, missing: false }]), ); const edgesByKey = new Map(); const ensureNode = (threadId: ThreadId) => { if (!nodes.has(threadId)) { nodes.set(threadId, { threadId, thread: null, missing: true }); } }; const addEdge = (edge: ThreadRelationshipEdge) => { ensureNode(edge.sourceThreadId); ensureNode(edge.targetThreadId); edgesByKey.set(edgeKey(edge), edge); }; for (const thread of threads) { const parentThreadId = thread.forkedFrom?.type === "run" ? thread.forkedFrom.threadId : thread.lineage.parentThreadId; if (parentThreadId === null) continue; addEdge({ sourceThreadId: parentThreadId, targetThreadId: thread.id, kind: thread.lineage.relationshipToParent === "subagent" ? "subagent" : "fork", status: thread.activityRunStatus ?? thread.status, }); } if (input.projection !== null) { const ownerThreadId = input.projection.thread.id; for (const subagent of input.projection.subagents) { if (subagent.childThreadId === null) continue; addEdge({ sourceThreadId: ownerThreadId, targetThreadId: subagent.childThreadId, kind: "subagent", status: subagent.status, }); } for (const transfer of input.projection.contextTransfers) { if (transfer.sourceThreadId === transfer.targetThreadId) continue; addEdge({ sourceThreadId: transfer.sourceThreadId, targetThreadId: transfer.targetThreadId, kind: "transfer", status: transfer.status, }); } } return { nodes, edges: [...edgesByKey.values()] }; } export function relatedThreadIds( graph: ThreadRelationshipGraph, threadId: ThreadId, ): ReadonlyArray { const ids = new Set(); for (const edge of graph.edges) { if (edge.sourceThreadId === threadId) ids.add(edge.targetThreadId); if (edge.targetThreadId === threadId) ids.add(edge.sourceThreadId); } return [...ids]; } export function walkThreadRelationships( graph: ThreadRelationshipGraph, threadId: ThreadId, ): ReadonlyArray { const visited = new Set([threadId]); const pending: Array<{ readonly threadId: ThreadId; readonly depth: number }> = [ { threadId, depth: 0 }, ]; const rows: ThreadRelationshipWalkRow[] = []; for (let index = 0; index < pending.length; index += 1) { const current = pending[index]; if (current === undefined) continue; for (const edge of graph.edges) { const relatedId = edge.sourceThreadId === current.threadId ? edge.targetThreadId : edge.targetThreadId === current.threadId ? edge.sourceThreadId : null; if (relatedId === null || visited.has(relatedId)) continue; visited.add(relatedId); const depth = current.depth + 1; rows.push({ threadId: relatedId, fromThreadId: current.threadId, depth, edge }); pending.push({ threadId: relatedId, depth }); } } return rows; } export function immediateThreadRelationships( graph: ThreadRelationshipGraph, threadId: ThreadId, ): ReadonlyArray { const visited = new Set(); const rows: ThreadRelationshipWalkRow[] = []; for (const edge of graph.edges) { const relatedId = edge.sourceThreadId === threadId ? edge.targetThreadId : edge.targetThreadId === threadId ? edge.sourceThreadId : null; if (relatedId === null || visited.has(relatedId)) continue; visited.add(relatedId); rows.push({ threadId: relatedId, fromThreadId: threadId, depth: 1, edge }); } return rows; } /** True when `edge` reaches `currentThreadId` from its parent or owning agent. */ export function isParentThreadRelationship( edge: ThreadRelationshipEdge, currentThreadId: ThreadId, ): boolean { return edge.kind !== "transfer" && edge.targetThreadId === currentThreadId; } function threadCreatedAtMillis(node: ThreadRelationshipNode | undefined): number | null { // `createdAt` is typed as a DateTime, but the value reaches here from a // decoded shell that may be missing (a related thread we have no shell for). const createdAt: unknown = node?.thread?.createdAt; if (!DateTime.isDateTime(createdAt)) return null; const millis = DateTime.toEpochMillis(createdAt); return Number.isFinite(millis) ? millis : null; } /** * Orders the web thread-details Lineage rows for display. * * Web-specific by design: the panel pins the parent row first and a distinct * merge-back target second so their actions stay where the user expects, and * only then falls back to newest-created-first. Mobile does not share that * exception, so this is not the canonical relationship order and should not be * reused as one. * * Ordering below the pins is `createdAt` descending, which is immutable, so * rows never move when messages or status changes arrive on a related thread. * Threads whose shell is missing (or whose `createdAt` did not decode) sink to * the bottom. Ties break by thread id ascending so the order is total. */ export function orderWebThreadLineageRows(input: { readonly graph: ThreadRelationshipGraph; readonly rows: ReadonlyArray; readonly currentThreadId: ThreadId; readonly mergeTargetThreadId: ThreadId | null; }): ReadonlyArray { const pinRank = (row: ThreadRelationshipWalkRow): number => { if (isParentThreadRelationship(row.edge, input.currentThreadId)) return 0; if (row.threadId === input.mergeTargetThreadId) return 1; return 2; }; return input.rows.toSorted((left, right) => { const rankDelta = pinRank(left) - pinRank(right); if (rankDelta !== 0) return rankDelta; const leftCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(left.threadId)); const rightCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(right.threadId)); if (leftCreatedAt !== rightCreatedAt) { if (leftCreatedAt === null) return 1; if (rightCreatedAt === null) return -1; return rightCreatedAt - leftCreatedAt; } return left.threadId < right.threadId ? -1 : left.threadId > right.threadId ? 1 : 0; }); }