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<ThreadId, ThreadRelationshipNode>; readonly edges: ReadonlyArray<ThreadRelationshipEdge>;} export interface ThreadRelationshipWalkRow { readonly threadId: ThreadId; readonly fromThreadId: ThreadId; readonly depth: number; readonly edge: ThreadRelationshipEdge;} export function resolveMergeBackTargetThreadId( projection: Pick<OrchestrationV2ThreadProjection, "thread"> | 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<OrchestrationV2ThreadShell>; readonly projection: OrchestrationV2ThreadProjection | null;}): ThreadRelationshipGraph { const threadsById = new Map<ThreadId, OrchestrationV2ThreadShell>(); 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<ThreadId, ThreadRelationshipNode>( threads.map((thread) => [thread.id, { threadId: thread.id, thread, missing: false }]), ); const edgesByKey = new Map<string, ThreadRelationshipEdge>(); 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<ThreadId> { const ids = new Set<ThreadId>(); 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<ThreadRelationshipWalkRow> { const visited = new Set<ThreadId>([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<ThreadRelationshipWalkRow> { const visited = new Set<ThreadId>(); 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<ThreadRelationshipWalkRow>; readonly currentThreadId: ThreadId; readonly mergeTargetThreadId: ThreadId | null;}): ReadonlyArray<ThreadRelationshipWalkRow> { 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; });}
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<ThreadId, ThreadRelationshipNode>;
readonly edges: ReadonlyArray<ThreadRelationshipEdge>;
}
export interface ThreadRelationshipWalkRow {
readonly threadId: ThreadId;
readonly fromThreadId: ThreadId;
readonly depth: number;
readonly edge: ThreadRelationshipEdge;
}
export function resolveMergeBackTargetThreadId(
projection: Pick<OrchestrationV2ThreadProjection, "thread"> | 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<OrchestrationV2ThreadShell>;
readonly projection: OrchestrationV2ThreadProjection | null;
}): ThreadRelationshipGraph {
const threadsById = new Map<ThreadId, OrchestrationV2ThreadShell>();
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<ThreadId, ThreadRelationshipNode>(
threads.map((thread) => [thread.id, { threadId: thread.id, thread, missing: false }]),
);
const edgesByKey = new Map<string, ThreadRelationshipEdge>();
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<ThreadId> {
const ids = new Set<ThreadId>();
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<ThreadRelationshipWalkRow> {
const visited = new Set<ThreadId>([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<ThreadRelationshipWalkRow> {
const visited = new Set<ThreadId>();
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<ThreadRelationshipWalkRow>;
readonly currentThreadId: ThreadId;
readonly mergeTargetThreadId: ThreadId | null;
}): ReadonlyArray<ThreadRelationshipWalkRow> {
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;
});
}