Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrchestrationV2TurnItem>) =>
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<ThreadFeedEntry>) =>
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),
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
)
),
)
Expand Down
173 changes: 173 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<OrchestrationV2TurnItem>) =>
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"]);
});
});
54 changes: 51 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -709,6 +710,7 @@ interface SupersededAttemptFold {
function deriveSupersededAttemptFolds(
timelineEntries: ReadonlyArray<TimelineEntry>,
unfoldedRunIds: ReadonlySet<RunId>,
liveSubagentEntryIds: ReadonlySet<string>,
): ReadonlyMap<string, SupersededAttemptFold> {
const entriesByAttemptId = new Map<RunAttemptId, TimelineEntry[]>();
for (const entry of timelineEntries) {
Expand All @@ -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;
Expand Down Expand Up @@ -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<TimelineEntry>): ReadonlySet<string> {
const visible = new Set<string>();
let batch: Array<Extract<TimelineEntry, { kind: "event" }>> = [];
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;
Expand Down Expand Up @@ -874,6 +915,7 @@ function deriveTurnFolds(input: {
unfoldedRunIds: ReadonlySet<RunId>;
/** Keeps the latest runless response open; V2 work must not reopen imported turns. */
runlessWorkActive: boolean;
liveSubagentEntryIds: ReadonlySet<string>;
}): ReadonlyMap<string, TurnFold> {
const interruptedRunIds = new Set<RunId>();
for (const entry of input.timelineEntries) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -1258,6 +1305,7 @@ export function deriveMessagesTimelineRows(input: {
latestRun: input.latestRun ?? null,
unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
runlessWorkActive,
liveSubagentEntryIds,
});
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorEntryId.values()) {
Expand Down
Loading
Loading