([...current.visibleRows, ...previous.visibleRows]);
+ const visibleRows = props.rows.filter((row) => shownSet.has(row));
+ const failedCount = previousRows.filter((row) => {
const status = lineageRowStatus(row);
return status === "failed" || status === "error";
}).length;
if (props.rows.length === 0) return null;
return (
- {props.label ? (
+ {visibleRows.length > 0 ? (
+ 0 ? current.hiddenCount : previous.hiddenCount}
+ onShowMore={() =>
+ (current.hiddenCount > 0 ? setVisibleCount : setPreviousVisibleCount)(
+ (count) => count + THREAD_LINEAGE_PAGE_COUNT,
+ )
+ }
+ >
+ {props.children(visibleRows)}
+
+ ) : null}
+ {previousRows.length > 0 ? (
setExpanded(!expanded)}>
- {props.label}
+ Previous agents
{failedCount > 0 ? {failedCount} failed : null}
{expanded ? null : (
- {props.rows.length}
+ {previousRows.length}
)}
(props: {
/>
) : null}
- {expanded ? (
- setVisibleCount((count) => count + THREAD_LINEAGE_PAGE_COUNT)}
- >
- {props.children(visibleRows)}
-
- ) : null}
);
}
@@ -498,22 +512,22 @@ export function ThreadRelationshipsPanel(props: {
const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
const relationshipRows = orderWebThreadLineageRows({
graph,
- rows: immediateThreadRelationships(graph, props.threadId),
+ rows: immediateThreadRelationships(graph, props.threadId)
+ // The graph rebuilds missing parents from lineage, so filter the rows it returns.
+ .filter((row) => !isHiddenRelation(row.threadId) && !isHiddenUnloadedRelation(row))
+ .map((row) => {
+ const agent =
+ row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId)
+ ? subagentsByThreadId.get(row.threadId)
+ : undefined;
+ const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id);
+ return workflowGroup === undefined
+ ? row
+ : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } };
+ }),
currentThreadId: props.threadId,
mergeTargetThreadId,
- })
- // The graph rebuilds missing parents from lineage, so filter the rows it returns.
- .filter((row) => !isHiddenRelation(row.threadId) && !isHiddenUnloadedRelation(row))
- .map((row) => {
- const agent =
- row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId)
- ? subagentsByThreadId.get(row.threadId)
- : undefined;
- const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id);
- return workflowGroup === undefined
- ? row
- : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } };
- });
+ });
const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;
const unlistedThreadIds = relationshipRows
.filter(
@@ -542,55 +556,54 @@ export function ThreadRelationshipsPanel(props: {
);
const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
- const {
- related = [],
- active = [],
- previous = [],
- sideChats = [],
- } = groupBy(visibleRelationships, ({ threadId, edge }) => {
+ const { lineage = [], sideChats = [] } = groupBy(visibleRelationships, ({ threadId, edge }) => {
const isParent = isParentThreadRelationship(edge, props.threadId);
if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true)
return "sideChats";
- if (edge.kind !== "subagent" || isParent) return "related";
- return isFinishedAgentStatus(edge.status) ? "previous" : "active";
+ return "lineage";
});
- const { remoteActive = [], remotePrevious = [] } = groupBy(
- (projection?.subagents ?? [])
+ const isPinnedRow = ({ threadId, edge }: ThreadRelationshipWalkRow) =>
+ isParentThreadRelationship(edge, props.threadId) || threadId === mergeTargetThreadId;
+ // Remote tasks have no thread here; stable-sort them into the status band they belong to.
+ const lineageRows: ReadonlyArray = [
+ ...lineage,
+ ...(projection?.subagents ?? [])
.filter((agent): agent is RemoteSubagent => agent.remoteChild !== undefined)
.map((remote) => ({ remote })),
- ({ remote }) => (isFinishedAgentStatus(remote.status) ? "remotePrevious" : "remoteActive"),
+ ].toSorted((left, right) => {
+ const band = (row: LineageRow) =>
+ "remote" in row
+ ? webThreadLineageStatusRank(row.remote.status)
+ : isPinnedRow(row)
+ ? -1
+ : webThreadLineageStatusRank(threadRelationshipRowStatus(graph, row));
+ return band(left) - band(right);
+ });
+ const previous = lineageRows.filter((row) =>
+ "remote" in row
+ ? isFinishedAgentStatus(row.remote.status)
+ : row.edge.kind === "subagent" &&
+ !isParentThreadRelationship(row.edge, props.threadId) &&
+ isFinishedAgentStatus(row.edge.status),
);
- const groups: ReadonlyArray<{
- id: string;
- label: string | null;
- rows: ReadonlyArray;
- expanded: boolean;
- }> = [
- { id: "related", label: null, rows: related, expanded: true },
- { id: "active", label: null, rows: [...active, ...remoteActive], expanded: true },
- {
- id: "previous",
- label: "Previous agents",
- rows: [...previous, ...remotePrevious],
- expanded: false,
- },
- ];
const runningCount =
(projection?.subagents.filter(
(agent) => agent.childThreadId === null && agent.status === "running",
- ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length;
+ ).length ?? 0) +
+ lineage.filter(
+ ({ edge }) =>
+ edge.kind === "subagent" &&
+ !isParentThreadRelationship(edge, props.threadId) &&
+ edge.status === "running",
+ ).length;
// A linked environment's agent may have delegated this thread from there.
const delegatedFrom = currentThread?.delegatedFrom;
const lineageVisible =
props.showLineage &&
- (related.length > 0 ||
- active.length > 0 ||
- previous.length > 0 ||
+ (lineageRows.length > 0 ||
runningCount > 0 ||
- remoteActive.length > 0 ||
- remotePrevious.length > 0 ||
delegatedFrom !== undefined ||
// Disconnect lives in this heading, so a thread with only side chats still shows it.
(canDetach && sideChats.length > 0));
@@ -950,9 +963,7 @@ export function ThreadRelationshipsPanel(props: {
showHeading={
runningCount > 0 ||
canDetach ||
- related.length > 0 ||
- active.length > 0 ||
- remoteActive.length > 0 ||
+ lineageRows.length > previous.length ||
delegatedFrom !== undefined
}
data-thread-relationships-panel
@@ -990,26 +1001,28 @@ export function ThreadRelationshipsPanel(props: {
/>
)}
- {groups.map((group) => (
-
- {(visibleRows) =>
- visibleRows.map((row) =>
- "remote" in row ? (
- entry.instanceId === row.remote.providerInstanceId)}
- />
- ) : (
- renderRelationshipRow(row)
- ),
- )
- }
-
- ))}
+
+ {(visibleRows) =>
+ visibleRows.map((row) =>
+ "remote" in row ? (
+ entry.instanceId === row.remote.providerInstanceId)}
+ />
+ ) : (
+ renderRelationshipRow(row)
+ ),
+ )
+ }
+
) : null}
{sideChatsVisible ? (
@@ -1018,12 +1031,7 @@ export function ThreadRelationshipsPanel(props: {
title={runningSideChats > 0 ? `Side chats ยท ${runningSideChats} running` : "Side chats"}
data-thread-side-chats-panel
>
-
+
{(visibleRows) => visibleRows.map(renderRelationshipRow)}
diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts
index 90dc68c21dd82..7ade5c7796bd8 100644
--- a/packages/client-runtime/src/state/threadRelationships.test.ts
+++ b/packages/client-runtime/src/state/threadRelationships.test.ts
@@ -368,7 +368,6 @@ describe("web thread lineage ordering", () => {
parentThreadId: current,
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-07-30T00:00:00.000Z",
- status: "running",
}),
forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }),
forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
@@ -377,7 +376,6 @@ describe("web thread lineage ordering", () => {
parentThreadId: current,
createdAt: "2026-06-02T00:00:00.000Z",
updatedAt: "2026-07-31T00:00:00.000Z",
- status: "failed",
}),
],
});
@@ -386,6 +384,56 @@ describe("web thread lineage ordering", () => {
expect(after).toEqual(before);
});
+ it("puts working rows above idle rows, and idle rows above finished ones", () => {
+ const parent = ThreadId.make("thread-parent");
+ const newestDone = ThreadId.make("thread-newest-done");
+ const failed = ThreadId.make("thread-failed");
+ const idle = ThreadId.make("thread-idle");
+ const oldRunning = ThreadId.make("thread-old-running");
+ const pendingSubagent = ThreadId.make("thread-pending-subagent");
+
+ expect(
+ orderedLineageIds({
+ threads: [
+ forkShell({ id: parent, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
+ forkShell({
+ id: current,
+ parentThreadId: parent,
+ createdAt: "2026-06-02T00:00:00.000Z",
+ }),
+ forkShell({
+ id: newestDone,
+ parentThreadId: current,
+ createdAt: "2026-06-09T00:00:00.000Z",
+ }),
+ forkShell({
+ id: failed,
+ parentThreadId: current,
+ createdAt: "2026-06-08T00:00:00.000Z",
+ status: "failed",
+ }),
+ forkShell({
+ id: idle,
+ parentThreadId: current,
+ createdAt: "2026-06-07T00:00:00.000Z",
+ status: "idle",
+ }),
+ forkShell({
+ id: oldRunning,
+ parentThreadId: current,
+ createdAt: "2026-06-03T00:00:00.000Z",
+ status: "running",
+ }),
+ ],
+ projection: {
+ thread: { id: current },
+ subagents: [{ childThreadId: pendingSubagent, status: "pending" }],
+ contextTransfers: [],
+ },
+ }),
+ ).toEqual([parent, oldRunning, pendingSubagent, idle, newestDone, failed]);
+ });
+
it("pins the parent first and a distinct merge-back target second", () => {
const parent = ThreadId.make("thread-parent");
const mergeTarget = ThreadId.make("thread-merge-target");
diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts
index 2aecc93470c2f..d0bd68cb6c5ec 100644
--- a/packages/client-runtime/src/state/threadRelationships.ts
+++ b/packages/client-runtime/src/state/threadRelationships.ts
@@ -176,9 +176,32 @@ function threadCreatedAtMillis(node: ThreadRelationshipNode | undefined): number
return Number.isFinite(millis) ? millis : null;
}
+const WORKING_ROW_STATUSES = new Set([
+ "pending",
+ "preparing",
+ "queued",
+ "starting",
+ "running",
+ "waiting",
+]);
+const FINISHED_ROW_STATUSES = new Set([
+ "completed",
+ "failed",
+ "error",
+ "cancelled",
+ "interrupted",
+ "rolled_back",
+]);
+
+export function webThreadLineageStatusRank(status: string | null): number {
+ if (status !== null && WORKING_ROW_STATUSES.has(status)) return 0;
+ if (status !== null && FINISHED_ROW_STATUSES.has(status)) return 2;
+ return 1;
+}
+
/**
- * Web pins the parent and distinct merge-back target before newest-created-first ordering.
- * Mobile does not share this exception; do not reuse it as the canonical relationship order.
+ * Web pins the parent and distinct merge-back target, then orders working, idle and finished
+ * rows newest-created-first. Mobile does not share this order; do not reuse it as canonical.
*/
export function orderWebThreadLineageRows(input: {
readonly graph: ThreadRelationshipGraph;
@@ -195,6 +218,10 @@ export function orderWebThreadLineageRows(input: {
return [...input.rows].sort((left, right) => {
const rankDelta = pinRank(left) - pinRank(right);
if (rankDelta !== 0) return rankDelta;
+ const statusDelta =
+ webThreadLineageStatusRank(threadRelationshipRowStatus(input.graph, left)) -
+ webThreadLineageStatusRank(threadRelationshipRowStatus(input.graph, right));
+ if (statusDelta !== 0) return statusDelta;
const leftCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(left.threadId));
const rightCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(right.threadId));
if (leftCreatedAt !== rightCreatedAt) {