diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 1cf78cc29f3fd07013e5f1b952998ace328aaec2..16be0186e68b83637deee483580af8c0edbf3aaf 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -975,6 +975,74 @@ describe("buildThreadFeed", () => { ).toBe(true); }); + it("keeps a settled run's still-running subagents visible while its other work folds", () => { + const subagent = ( + id: string, + updatedAt: string, + ordinal: number, + status: OrchestrationV2TurnItem["status"], + ): OrchestrationV2TurnItem => ({ + ...base(id, updatedAt, ordinal), + providerTurnId: ProviderTurnId.make("provider-turn-1"), + status, + completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt), + type: "subagent", + subagentId: NodeId.make(id), + origin: "app_owned", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: sourceThreadId, + prompt: `Inspect ${id}`, + result: null, + }); + const latestRun = { + runId, + status: "completed" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:05.000Z", + }; + const present = (children: ReadonlyArray) => + deriveThreadFeedPresentation( + buildThreadFeed([ + projected(userMessage(), 0), + projected(command("2026-06-20T00:00:02.000Z"), 1), + ...children.map((child, index) => projected(child, index + 2)), + projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5), + ]), + latestRun, + new Set(), + ); + const visibleSubagentIds = (presented: ReadonlyArray) => + presented.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap((activity) => + activity.projectedItem.item.type === "subagent" + ? [activity.projectedItem.item.id] + : [], + ) + : [], + ); + + const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]); + expect(live.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "activity-group", + "message", + ]); + expect(visibleSubagentIds(live)).toEqual(["item-live"]); + + // A launch batch stays whole while any member is live. + const mixed = present([ + subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"), + subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"), + ]); + expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]); + + const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]); + expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); + }); + it("folds settled V2 run work while keeping the terminal assistant message visible", () => { const feed = buildThreadFeed([ projected(userMessage(), 0), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 927fd59b7dec20f781520f5a8741b02d4e8eafde..4038fa4982c77e4fb36e00afdf180a17f596d6ce 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -5,6 +5,7 @@ import type { } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { turnItemHasDetail, @@ -1107,7 +1108,10 @@ function deriveThreadFeedRunFolds( (activity) => activity.prominent || activity.projectedItem.item.type === "notification" || - activity.projectedItem.item.type === "handoff", + activity.projectedItem.item.type === "handoff" || + // A child still working outlives its settled launching run; + // its group stays visible while any member is live. + isLiveSubagentTurnItem(activity.projectedItem.item), ) ), ) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 173a964d5efc8ab27e01a588d01847701248d434..0f22324e0ca1a612cbbc888b63a8122067d2b471 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2,10 +2,14 @@ import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; import { CheckpointRef, NodeId, + ProviderDriverKind, + ProviderInstanceId, + ProviderTurnId, RunAttemptId, TurnItemId, RuntimeRequestId, type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { @@ -4889,3 +4893,172 @@ describe("failed turn transcript", () => { }, ); }); + +describe("live subagents after their parent turn settles", () => { + const runId = RunId.make("parent-run"); + const threadId = ThreadId.make("parent-thread"); + const providerTurnId = ProviderTurnId.make("parent-provider-turn"); + const at = (second: number) => + DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`); + const base = (id: string, second: number) => ({ + id: TurnItemId.make(id), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: second, + title: null, + startedAt: at(second), + completedAt: at(second), + updatedAt: at(second), + }); + const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({ + position: item.ordinal, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: item.id, + item, + }); + const child = ( + id: string, + second: number, + status: OrchestrationV2TurnItem["status"], + origin: "app_owned" | "provider_native" = "app_owned", + ): OrchestrationV2TurnItem => ({ + ...base(id, second), + status, + completedAt: status === "running" ? null : at(second), + type: "subagent", + subagentId: NodeId.make(id), + origin, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null, + prompt: `Inspect ${id}`, + result: null, + }); + const rowsFor = (children: ReadonlyArray) => + deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: ( + [ + { + ...base("user", 0), + status: "completed", + type: "user_message", + messageId: MessageId.make("user"), + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + text: "Delegate the review", + attachments: [], + }, + { + ...base("reasoning", 1), + status: "completed", + type: "reasoning", + text: "Two children can split this.", + streaming: false, + }, + { + ...base("command", 2), + status: "completed", + type: "command_execution", + input: "pwd", + output: "/repo", + exitCode: 0, + }, + ...children, + { + ...base("assistant", 8), + status: "completed", + type: "assistant_message", + messageId: MessageId.make("assistant"), + text: "Both children are running.", + streaming: false, + }, + ] satisfies OrchestrationV2TurnItem[] + ).map(projected), + optimisticMessages: [], + }), + latestRun: { + runId, + status: "completed", + startedAt: DateTime.formatIso(at(0)), + completedAt: DateTime.formatIso(at(8)), + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + it.each(["app_owned", "provider_native"] as const)( + "keeps a running %s child visible while the rest of the settled turn folds", + (origin) => { + const rows = rowsFor([child("child", 3, "running", origin)]); + // The reasoning and command still fold; only the live card stays out. + expect(rows.map((row) => row.id)).toEqual([ + "user", + `turn-fold:${runId}`, + "child", + "assistant", + ]); + }, + ); + + it.each(["completed", "failed"] as const)("folds a %s child with its settled turn", (status) => { + const rows = rowsFor([child("child", 3, status)]); + expect(rows.some((row) => row.id === "child")).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold")).toBe(true); + }); + + it("keeps a launch batch visible while any member is still running", () => { + const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]); + expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]); + expect(rows.find((row) => row.id === "done")).toMatchObject({ + subagents: [{ item: { id: "done" } }, { item: { id: "live" } }], + }); + }); + + it("keeps a running child visible when a steer supersedes its attempt", () => { + const attempt = { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + }; + const entries = deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: ( + [ + { + ...base("command", 2), + status: "completed", + type: "command_execution", + input: "pwd", + output: "/repo", + exitCode: 0, + }, + child("child", 3, "running"), + ] satisfies OrchestrationV2TurnItem[] + ).map(projected), + optimisticMessages: [], + }).map((entry) => ({ ...entry, attempt })); + const rows = deriveMessagesTimelineRows({ + timelineEntries: entries, + latestRun: { + runId, + status: "running", + startedAt: DateTime.formatIso(at(0)), + completedAt: null, + }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7360650a3a998f6dafc0e56bed09ea27e1594f02..d403e37c33221e6997f851c561316e115fb73a09 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,5 +1,6 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; @@ -709,6 +710,7 @@ interface SupersededAttemptFold { function deriveSupersededAttemptFolds( timelineEntries: ReadonlyArray, unfoldedRunIds: ReadonlySet, + liveSubagentEntryIds: ReadonlySet, ): ReadonlyMap { const entriesByAttemptId = new Map(); for (const entry of timelineEntries) { @@ -719,6 +721,8 @@ function deriveSupersededAttemptFolds( // A published page stays visible, as it does when its turn folds. entry.kind === "html-render" || timelineEntryIsPersistentResourceCard(entry) || + // A steer supersedes the attempt but leaves its children running. + liveSubagentEntryIds.has(entry.id) || (entry.kind === "work" && entry.entry.itemType === "system_notice") ) { continue; @@ -767,6 +771,43 @@ function deriveUnsettledRunId( return isSettled ? null : latestRun.runId; } +/** + * Subagent cards that stay out of their turn's folds. A child can keep working + * after its launching turn settles (or its attempt is superseded), and the + * waiting footer counts it, so its card stays visible until the child ends. + * Adjacent cards from one provider turn render as a single grouped row; the + * whole group stays visible while any member is live, so a launch batch never + * shows half of its children. + */ +function liveSubagentCardEntryIds(entries: ReadonlyArray): ReadonlySet { + const visible = new Set(); + let batch: Array> = []; + const flush = () => { + if (batch.length === 0) return; + if (batch.some((entry) => isLiveSubagentTurnItem(entry.projectedItem.item))) { + for (const entry of batch) visible.add(entry.id); + } + batch = []; + }; + for (const entry of entries) { + if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") { + flush(); + continue; + } + const item = entry.projectedItem.item; + const previous = batch.at(-1)?.projectedItem.item; + if ( + previous !== undefined && + (previous.runId !== item.runId || previous.providerTurnId !== item.providerTurnId) + ) { + flush(); + } + batch.push(entry); + } + flush(); + return visible; +} + /** `runlessKey` stands in for the run of entries that have none. */ function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null { if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null; @@ -874,6 +915,7 @@ function deriveTurnFolds(input: { unfoldedRunIds: ReadonlySet; /** Keeps the latest runless response open; V2 work must not reopen imported turns. */ runlessWorkActive: boolean; + liveSubagentEntryIds: ReadonlySet; }): ReadonlyMap { const interruptedRunIds = new Set(); for (const entry of input.timelineEntries) { @@ -986,9 +1028,12 @@ function deriveTurnFolds(input: { if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) { continue; } - // Linked resources can outlive their launching run and stay visible - // after the surrounding work folds. - if (timelineEntryIsPersistentResourceCard(entry)) { + // Linked resources and still-working children can outlive their + // launching run and stay visible after the surrounding work folds. + if ( + timelineEntryIsPersistentResourceCard(entry) || + input.liveSubagentEntryIds.has(entry.id) + ) { continue; } if (entry.kind === "work" && entry.entry.itemType === "notification") continue; @@ -1242,9 +1287,11 @@ export function deriveMessagesTimelineRows(input: { const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( timelineEntries, failedRunIds, + liveSubagentEntryIds, ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, @@ -1258,6 +1305,7 @@ export function deriveMessagesTimelineRows(input: { latestRun: input.latestRun ?? null, unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]), runlessWorkActive, + liveSubagentEntryIds, }); const collapsedEntryIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index da222ecfef3d711fbc910a553a28ac9542bfb074..2b1fc96e54ac71883147f9ae9be820516c165bff 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -3,7 +3,7 @@ * web agent rows render. */ import * as DateTime from "effect/DateTime"; -import type { OrchestrationV2Subagent } from "@t3tools/contracts"; +import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; export type RuntimeSubagentStatus = @@ -91,6 +91,15 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { return isOrchestrationV2WorkActive(status); } +/** + * A subagent card whose child is still working. The card outlives its + * launching turn: once that turn settles, the waiting footer counts the child, + * so the card stays out of the turn's fold until the child finishes. + */ +export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { + return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); +} + /** * Projects orchestration-v2 subagent entities into the runtime shape the web * agent rows render.