authorgravatar for scratchywon@gmail.comscratchyone <scratchywon@gmail.com> 2026-10-03 17:12:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 21:12:09-03:00
logd3071275d576bb289a6cb177c6cc28fff8e95015
tree3404a006d0db0e278318c29225790c72ad59bb38
parentf2cc80a7a44ee05c1f39442779f2b719e31faeb5
signature Signed by PGP key B5690EEEBB952194

fix(web): subagents sent a follow-up show as running in Lineage (#15334)

Co-authored-by: scratchyone <11479077+scratchyone@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 72 insertions(+), 5 deletions(-)

apps/web/src/components/chat/ThreadRelationshipsControl.tsx+37-4
......@@ -5,7 +5,10 @@ import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible
55import { SubagentTooltipContent } from "./SubagentTooltipContent";
66import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
77import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
8import { projectedSubagentsToRuntime } from "@t3tools/client-runtime/state/subagentRuntime";
8import {
9 projectedSubagentsToRuntime,
10 type RuntimeSubagent,
11} from "@t3tools/client-runtime/state/subagentRuntime";
912import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
1013import {
1114 deriveThreadRelationshipGraph,
......@@ -23,6 +26,7 @@ import {
2326} from "@t3tools/client-runtime/state/thread-workflows";
2427import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
2528import { groupBy } from "effect/Array";
29import * as DateTime from "effect/DateTime";
2630import { useNavigate } from "@tanstack/react-router";
2731import {
2832 ArrowRightIcon,
......@@ -162,6 +166,30 @@ function relationshipThreadTitle(input: {
162166 return formatSubagentDisplayTitle(input.title);
163167}
164168
169/**
170 * A delegated task settles with its first run, but the parent can keep sending
171 * the child follow-ups. While the child thread has a live run, the row's timer
172 * and hover card follow that run instead of the settled task.
173 */
174function liveSubagent<Agent extends RuntimeSubagent>(
175 agent: Agent | undefined,
176 childThread: OrchestrationV2ThreadShell | null | undefined,
177): Agent | undefined {
178 const liveStatus = childThread?.activityRunStatus;
179 if (!agent || !liveStatus) return agent;
180 const startedAt = childThread.activityRunStartedAt;
181 return {
182 ...agent,
183 status: liveStatus === "running" || liveStatus === "waiting" ? liveStatus : "pending",
184 startedAt: startedAt ? DateTime.formatIso(startedAt) : null,
185 completedAt: null,
186 // The settled task's output belongs to its first run, not this one.
187 progress: null,
188 result: null,
189 error: null,
190 };
191}
192
165193export function ThreadRelationshipsPanel(props: {
166194 readonly environmentId: EnvironmentId;
167195 readonly threadId: ThreadId;
......@@ -239,9 +267,11 @@ export function ThreadRelationshipsPanel(props: {
239267 { id: "active", label: null, rows: active, expanded: true },
240268 { id: "previous", label: "Previous agents", rows: previous, expanded: false },
241269 ];
270 // Subagents without a child thread yet have no row, so count them separately.
242271 const runningCount =
243 projection?.subagents.filter((agent) => agent.status === "running").length ??
244 active.filter(({ edge }) => edge.status === "running").length;
272 (projection?.subagents.filter(
273 (agent) => agent.childThreadId === null && agent.status === "running",
274 ).length ?? 0) + active.filter(({ edge }) => edge.status === "running").length;
245275
246276 if (relationshipRows.length === 0 && runningCount === 0) {
247277 return null;
......@@ -330,7 +360,10 @@ export function ThreadRelationshipsPanel(props: {
330360 ? BotIcon
331361 : GitForkIcon;
332362 const relationship = relationshipLabel(edge, props.threadId);
333 const agent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined;
363 const agent = liveSubagent(
364 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined,
365 node?.thread,
366 );
334367 const threadTitle = relationshipThreadTitle({
335368 title: node?.thread?.title ?? agent?.title ?? threadId,
336369 isSubagent,
packages/client-runtime/src/state/threadRelationships.test.ts+31
......@@ -187,6 +187,37 @@ describe("thread relationships", () => {
187187 );
188188 });
189189
190 it.each([
191 ["running", "running"],
192 [null, "completed"],
193 ])(
194 "shows a subagent whose child activity is %s as %s after its delegated task settled",
195 (childActivity, expected) => {
196 const parent = ThreadId.make("thread-parent");
197 const child = ThreadId.make("thread-child");
198 const graph = deriveThreadRelationshipGraph({
199 threads: [
200 { id: parent, status: "completed", forkedFrom: null, lineage: { parentThreadId: null } },
201 {
202 id: child,
203 status: childActivity ?? "completed",
204 activityRunStatus: childActivity,
205 forkedFrom: null,
206 lineage: { parentThreadId: parent, relationshipToParent: "subagent" },
207 },
208 ] as never,
209 projection: {
210 thread: { id: parent },
211 subagents: [{ childThreadId: child, status: "completed" }],
212 contextTransfers: [],
213 } as never,
214 });
215
216 const row = immediateThreadRelationships(graph, parent)[0]!;
217 expect(threadRelationshipRowStatus(graph, row)).toBe(expected);
218 },
219 );
220
190221 it("keeps the live shell when an archived snapshot contains the same thread id", () => {
191222 const parent = ThreadId.make("thread-parent");
192223 const staleParent = ThreadId.make("thread-stale-parent");
packages/client-runtime/src/state/threadRelationships.ts+4-1
......@@ -92,11 +92,14 @@ export function deriveThreadRelationshipGraph(input: {
9292 const ownerThreadId = input.projection.thread.id;
9393 for (const subagent of input.projection.subagents) {
9494 if (subagent.childThreadId === null) continue;
95 // The subagent record settles with the delegated task's first run, but the
96 // parent can keep sending the child follow-ups. A live run on the child
97 // thread outranks that settled status.
9598 addEdge({
9699 sourceThreadId: ownerThreadId,
97100 targetThreadId: subagent.childThreadId,
98101 kind: "subagent",
99 status: subagent.status,
102 status: threadsById.get(subagent.childThreadId)?.activityRunStatus ?? subagent.status,
100103 });
101104 }
102105 for (const transfer of input.projection.contextTransfers) {