authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 09:43:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 09:43:10-07:00
log6ecb82dc42c4c721fd2c03c92316ee5361306cdb
treecc5346a6f5c664c0b670e4a2cf1cb6e2f69e8bfd
parent521f5b5a5682b18f2d59cf95226fc203cd314240
signature Signed by PGP key B5690EEEBB952194

fix(clients): running subagent cards stay visible after their parent turn settles (#16878)

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

5 files changed, 307 insertions(+), 5 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+68
......@@ -975,6 +975,74 @@ describe("buildThreadFeed", () => {
975975 ).toBe(true);
976976 });
977977
978 it("keeps a settled run's still-running subagents visible while its other work folds", () => {
979 const subagent = (
980 id: string,
981 updatedAt: string,
982 ordinal: number,
983 status: OrchestrationV2TurnItem["status"],
984 ): OrchestrationV2TurnItem => ({
985 ...base(id, updatedAt, ordinal),
986 providerTurnId: ProviderTurnId.make("provider-turn-1"),
987 status,
988 completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt),
989 type: "subagent",
990 subagentId: NodeId.make(id),
991 origin: "app_owned",
992 driver: ProviderDriverKind.make("codex"),
993 providerInstanceId: ProviderInstanceId.make("codex"),
994 childThreadId: sourceThreadId,
995 prompt: `Inspect ${id}`,
996 result: null,
997 });
998 const latestRun = {
999 runId,
1000 status: "completed" as const,
1001 startedAt: "2026-06-20T00:00:01.000Z",
1002 completedAt: "2026-06-20T00:00:05.000Z",
1003 };
1004 const present = (children: ReadonlyArray<OrchestrationV2TurnItem>) =>
1005 deriveThreadFeedPresentation(
1006 buildThreadFeed([
1007 projected(userMessage(), 0),
1008 projected(command("2026-06-20T00:00:02.000Z"), 1),
1009 ...children.map((child, index) => projected(child, index + 2)),
1010 projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5),
1011 ]),
1012 latestRun,
1013 new Set(),
1014 );
1015 const visibleSubagentIds = (presented: ReadonlyArray<ThreadFeedEntry>) =>
1016 presented.flatMap((entry) =>
1017 entry.type === "activity-group"
1018 ? entry.activities.flatMap((activity) =>
1019 activity.projectedItem.item.type === "subagent"
1020 ? [activity.projectedItem.item.id]
1021 : [],
1022 )
1023 : [],
1024 );
1025
1026 const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]);
1027 expect(live.map((entry) => entry.type)).toEqual([
1028 "message",
1029 "run-fold",
1030 "activity-group",
1031 "message",
1032 ]);
1033 expect(visibleSubagentIds(live)).toEqual(["item-live"]);
1034
1035 // A launch batch stays whole while any member is live.
1036 const mixed = present([
1037 subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"),
1038 subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"),
1039 ]);
1040 expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]);
1041
1042 const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]);
1043 expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
1044 });
1045
9781046 it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
9791047 const feed = buildThreadFeed([
9801048 projected(userMessage(), 0),
apps/mobile/src/lib/threadActivity.ts+5-1
......@@ -5,6 +5,7 @@ import type {
55} from "@t3tools/client-runtime/state/thread-requests";
66import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
77import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
8import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
89import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
910import {
1011 turnItemHasDetail,
......@@ -1107,7 +1108,10 @@ function deriveThreadFeedRunFolds(
11071108 (activity) =>
11081109 activity.prominent ||
11091110 activity.projectedItem.item.type === "notification" ||
1110 activity.projectedItem.item.type === "handoff",
1111 activity.projectedItem.item.type === "handoff" ||
1112 // A child still working outlives its settled launching run;
1113 // its group stays visible while any member is live.
1114 isLiveSubagentTurnItem(activity.projectedItem.item),
11111115 )
11121116 ),
11131117 )
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+173
......@@ -2,10 +2,14 @@ import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";
22import {
33 CheckpointRef,
44 NodeId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ProviderTurnId,
58 RunAttemptId,
69 TurnItemId,
710 RuntimeRequestId,
811 type OrchestrationV2ProjectedTurnItem,
12 type OrchestrationV2TurnItem,
913} from "@t3tools/contracts";
1014import * as DateTime from "effect/DateTime";
1115import {
......@@ -4889,3 +4893,172 @@ describe("failed turn transcript", () => {
48894893 },
48904894 );
48914895});
4896
4897describe("live subagents after their parent turn settles", () => {
4898 const runId = RunId.make("parent-run");
4899 const threadId = ThreadId.make("parent-thread");
4900 const providerTurnId = ProviderTurnId.make("parent-provider-turn");
4901 const at = (second: number) =>
4902 DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`);
4903 const base = (id: string, second: number) => ({
4904 id: TurnItemId.make(id),
4905 threadId,
4906 runId,
4907 nodeId: null,
4908 providerThreadId: null,
4909 providerTurnId,
4910 nativeItemRef: null,
4911 parentItemId: null,
4912 ordinal: second,
4913 title: null,
4914 startedAt: at(second),
4915 completedAt: at(second),
4916 updatedAt: at(second),
4917 });
4918 const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
4919 position: item.ordinal,
4920 visibility: "local",
4921 sourceThreadId: threadId,
4922 sourceItemId: item.id,
4923 item,
4924 });
4925 const child = (
4926 id: string,
4927 second: number,
4928 status: OrchestrationV2TurnItem["status"],
4929 origin: "app_owned" | "provider_native" = "app_owned",
4930 ): OrchestrationV2TurnItem => ({
4931 ...base(id, second),
4932 status,
4933 completedAt: status === "running" ? null : at(second),
4934 type: "subagent",
4935 subagentId: NodeId.make(id),
4936 origin,
4937 driver: ProviderDriverKind.make("codex"),
4938 providerInstanceId: ProviderInstanceId.make("codex"),
4939 childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null,
4940 prompt: `Inspect ${id}`,
4941 result: null,
4942 });
4943 const rowsFor = (children: ReadonlyArray<OrchestrationV2TurnItem>) =>
4944 deriveMessagesTimelineRows({
4945 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
4946 visibleTurnItems: (
4947 [
4948 {
4949 ...base("user", 0),
4950 status: "completed",
4951 type: "user_message",
4952 messageId: MessageId.make("user"),
4953 createdBy: "user",
4954 creationSource: "web",
4955 inputIntent: "turn_start",
4956 text: "Delegate the review",
4957 attachments: [],
4958 },
4959 {
4960 ...base("reasoning", 1),
4961 status: "completed",
4962 type: "reasoning",
4963 text: "Two children can split this.",
4964 streaming: false,
4965 },
4966 {
4967 ...base("command", 2),
4968 status: "completed",
4969 type: "command_execution",
4970 input: "pwd",
4971 output: "/repo",
4972 exitCode: 0,
4973 },
4974 ...children,
4975 {
4976 ...base("assistant", 8),
4977 status: "completed",
4978 type: "assistant_message",
4979 messageId: MessageId.make("assistant"),
4980 text: "Both children are running.",
4981 streaming: false,
4982 },
4983 ] satisfies OrchestrationV2TurnItem[]
4984 ).map(projected),
4985 optimisticMessages: [],
4986 }),
4987 latestRun: {
4988 runId,
4989 status: "completed",
4990 startedAt: DateTime.formatIso(at(0)),
4991 completedAt: DateTime.formatIso(at(8)),
4992 },
4993 isWorking: false,
4994 turnDiffSummaries: [],
4995 supportsConversationRollback: false,
4996 });
4997
4998 it.each(["app_owned", "provider_native"] as const)(
4999 "keeps a running %s child visible while the rest of the settled turn folds",
5000 (origin) => {
5001 const rows = rowsFor([child("child", 3, "running", origin)]);
5002 // The reasoning and command still fold; only the live card stays out.
5003 expect(rows.map((row) => row.id)).toEqual([
5004 "user",
5005 `turn-fold:${runId}`,
5006 "child",
5007 "assistant",
5008 ]);
5009 },
5010 );
5011
5012 it.each(["completed", "failed"] as const)("folds a %s child with its settled turn", (status) => {
5013 const rows = rowsFor([child("child", 3, status)]);
5014 expect(rows.some((row) => row.id === "child")).toBe(false);
5015 expect(rows.some((row) => row.kind === "turn-fold")).toBe(true);
5016 });
5017
5018 it("keeps a launch batch visible while any member is still running", () => {
5019 const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]);
5020 expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]);
5021 expect(rows.find((row) => row.id === "done")).toMatchObject({
5022 subagents: [{ item: { id: "done" } }, { item: { id: "live" } }],
5023 });
5024 });
5025
5026 it("keeps a running child visible when a steer supersedes its attempt", () => {
5027 const attempt = {
5028 id: RunAttemptId.make("superseded-attempt"),
5029 runId,
5030 attemptOrdinal: 1,
5031 rootNodeId: NodeId.make("superseded-root"),
5032 status: "superseded" as const,
5033 };
5034 const entries = deriveTimelineEntriesFromVisibleTurnItems({
5035 visibleTurnItems: (
5036 [
5037 {
5038 ...base("command", 2),
5039 status: "completed",
5040 type: "command_execution",
5041 input: "pwd",
5042 output: "/repo",
5043 exitCode: 0,
5044 },
5045 child("child", 3, "running"),
5046 ] satisfies OrchestrationV2TurnItem[]
5047 ).map(projected),
5048 optimisticMessages: [],
5049 }).map((entry) => ({ ...entry, attempt }));
5050 const rows = deriveMessagesTimelineRows({
5051 timelineEntries: entries,
5052 latestRun: {
5053 runId,
5054 status: "running",
5055 startedAt: DateTime.formatIso(at(0)),
5056 completedAt: null,
5057 },
5058 isWorking: false,
5059 turnDiffSummaries: [],
5060 supportsConversationRollback: false,
5061 });
5062 expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]);
5063 });
5064});
apps/web/src/components/chat/MessagesTimeline.logic.ts+51-3
......@@ -1,5 +1,6 @@
11import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
22export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
3import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
34import * as Equal from "effect/Equal";
45import { shallow } from "zustand/vanilla/shallow";
56import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
......@@ -709,6 +710,7 @@ interface SupersededAttemptFold {
709710function deriveSupersededAttemptFolds(
710711 timelineEntries: ReadonlyArray<TimelineEntry>,
711712 unfoldedRunIds: ReadonlySet<RunId>,
713 liveSubagentEntryIds: ReadonlySet<string>,
712714): ReadonlyMap<string, SupersededAttemptFold> {
713715 const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>();
714716 for (const entry of timelineEntries) {
......@@ -719,6 +721,8 @@ function deriveSupersededAttemptFolds(
719721 // A published page stays visible, as it does when its turn folds.
720722 entry.kind === "html-render" ||
721723 timelineEntryIsPersistentResourceCard(entry) ||
724 // A steer supersedes the attempt but leaves its children running.
725 liveSubagentEntryIds.has(entry.id) ||
722726 (entry.kind === "work" && entry.entry.itemType === "system_notice")
723727 ) {
724728 continue;
......@@ -767,6 +771,43 @@ function deriveUnsettledRunId(
767771 return isSettled ? null : latestRun.runId;
768772}
769773
774/**
775 * Subagent cards that stay out of their turn's folds. A child can keep working
776 * after its launching turn settles (or its attempt is superseded), and the
777 * waiting footer counts it, so its card stays visible until the child ends.
778 * Adjacent cards from one provider turn render as a single grouped row; the
779 * whole group stays visible while any member is live, so a launch batch never
780 * shows half of its children.
781 */
782function liveSubagentCardEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> {
783 const visible = new Set<string>();
784 let batch: Array<Extract<TimelineEntry, { kind: "event" }>> = [];
785 const flush = () => {
786 if (batch.length === 0) return;
787 if (batch.some((entry) => isLiveSubagentTurnItem(entry.projectedItem.item))) {
788 for (const entry of batch) visible.add(entry.id);
789 }
790 batch = [];
791 };
792 for (const entry of entries) {
793 if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") {
794 flush();
795 continue;
796 }
797 const item = entry.projectedItem.item;
798 const previous = batch.at(-1)?.projectedItem.item;
799 if (
800 previous !== undefined &&
801 (previous.runId !== item.runId || previous.providerTurnId !== item.providerTurnId)
802 ) {
803 flush();
804 }
805 batch.push(entry);
806 }
807 flush();
808 return visible;
809}
810
770811/** `runlessKey` stands in for the run of entries that have none. */
771812function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null {
772813 if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null;
......@@ -874,6 +915,7 @@ function deriveTurnFolds(input: {
874915 unfoldedRunIds: ReadonlySet<RunId>;
875916 /** Keeps the latest runless response open; V2 work must not reopen imported turns. */
876917 runlessWorkActive: boolean;
918 liveSubagentEntryIds: ReadonlySet<string>;
877919}): ReadonlyMap<string, TurnFold> {
878920 const interruptedRunIds = new Set<RunId>();
879921 for (const entry of input.timelineEntries) {
......@@ -986,9 +1028,12 @@ function deriveTurnFolds(input: {
9861028 if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) {
9871029 continue;
9881030 }
989 // Linked resources can outlive their launching run and stay visible
990 // after the surrounding work folds.
991 if (timelineEntryIsPersistentResourceCard(entry)) {
1031 // Linked resources and still-working children can outlive their
1032 // launching run and stay visible after the surrounding work folds.
1033 if (
1034 timelineEntryIsPersistentResourceCard(entry) ||
1035 input.liveSubagentEntryIds.has(entry.id)
1036 ) {
9921037 continue;
9931038 }
9941039 if (entry.kind === "work" && entry.entry.itemType === "notification") continue;
......@@ -1242,9 +1287,11 @@ export function deriveMessagesTimelineRows(input: {
12421287 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries);
12431288 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
12441289 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
1290 const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);
12451291 const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
12461292 timelineEntries,
12471293 failedRunIds,
1294 liveSubagentEntryIds,
12481295 );
12491296 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
12501297 timelineEntries: timelineEntries,
......@@ -1258,6 +1305,7 @@ export function deriveMessagesTimelineRows(input: {
12581305 latestRun: input.latestRun ?? null,
12591306 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
12601307 runlessWorkActive,
1308 liveSubagentEntryIds,
12611309 });
12621310 const collapsedEntryIds = new Set<string>();
12631311 for (const fold of foldsByAnchorEntryId.values()) {
packages/client-runtime/src/state/subagentRuntime.ts+10-1
......@@ -3,7 +3,7 @@
33 * web agent rows render.
44 */
55import * as DateTime from "effect/DateTime";
6import type { OrchestrationV2Subagent } from "@t3tools/contracts";
6import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts";
77import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
88
99export type RuntimeSubagentStatus =
......@@ -91,6 +91,15 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {
9191 return isOrchestrationV2WorkActive(status);
9292}
9393
94/**
95 * A subagent card whose child is still working. The card outlives its
96 * launching turn: once that turn settles, the waiting footer counts the child,
97 * so the card stays out of the turn's fold until the child finishes.
98 */
99export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean {
100 return item.type === "subagent" && isOrchestrationV2WorkActive(item.status);
101}
102
94103/**
95104 * Projects orchestration-v2 subagent entities into the runtime shape the web
96105 * agent rows render.