From 472f65bb92ab1d8c98d088a6a00225b6d10758e2 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:33:42 -0400
Subject: [PATCH] Keep the turn activity tracker steady while subagents run
Two fixes for the inline subagent activity bar in the conversation:
Flicker: a running agent's streamed commentary is a timeline entry that
renders nothing, but its timestamp moves with every update. It could
split and re-merge the turn's work groups as the agent streamed, and the
live group never rendered the activity receipt, so the tracker blinked
in and out mid-turn. Live commentary entries now stay out of grouping
entirely, and a live work group that owns the turn's tracker renders the
receipt above its spine so the bar survives group churn.
Missing model/effort: the durable roster seeds a record for every agent
before lifecycle rows fold in, so the "only inherit for a brand-new
record" guard from #155 never fired and a collab spawn that stated no
model lost the parent turn's dispatched selection. Native-spawn omission
is now a sticky per-record fact instead: native subAgentActivity spawns
still never inherit, while collab and Task spawns inherit the turn's
model and effort even when the roster seeded their record first.
---
.../chat/MessagesTimeline.browser.tsx | 34 +++++++++++
.../chat/MessagesTimeline.logic.test.ts | 56 +++++++++++++++++++
.../components/chat/MessagesTimeline.logic.ts | 10 +++-
.../src/components/chat/MessagesTimeline.tsx | 35 ++++++++++--
apps/web/src/session-logic.test.ts | 53 ++++++++++++++++++
apps/web/src/session-logic.ts | 39 ++++++++-----
6 files changed, 206 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx
index b77786840..7c1e54822 100644
--- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx
@@ -1136,6 +1136,40 @@ describe("MessagesTimeline", () => {
}
});
+ it("keeps the tracker visible while the turn's work group is live", async () => {
+ const onOpenAgentsPanel = vi.fn();
+ // The tail work group renders as the live spine while the turn works. The
+ // turn's tracker must not blink out with the settled receipt when the live
+ // group absorbs the turn's earlier steps — it rides above the live steps.
+ const screen = await renderTimeline(
+ ,
+ );
+
+ try {
+ await expect
+ .element(page.getByRole("button", { name: "2 subagents. Open the agents panel." }))
+ .toBeVisible();
+ expect(document.querySelector("[data-work-activity-live-tracker='true']")).not.toBeNull();
+ expect(document.querySelector("[data-live-activity-strip='true']")).not.toBeNull();
+ } finally {
+ await screen.unmount();
+ }
+ });
+
it("keeps the tracker row on a reloaded turn that only delegated, with no count and nothing to expand", async () => {
const onOpenAgentsPanel = vi.fn();
// Every entry in the turn is agent lifecycle plumbing, so the conversation
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index 28ddae5ba..56eaeaaed 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -316,6 +316,62 @@ describe("deriveMessagesTimelineRows", () => {
]);
});
+ it("keeps one work group when live agent commentary interleaves with the turn's steps", () => {
+ const workEntry = (id: string, createdAt: string) => ({
+ id,
+ kind: "work" as const,
+ createdAt,
+ entry: {
+ id,
+ createdAt,
+ label: id,
+ tone: "tool" as const,
+ turnId: "turn-1" as never,
+ },
+ });
+ // The live entry's timestamp moves with every streamed update, so if it
+ // could split work groups, the receipt would flicker as the agent streams.
+ const liveEntry = {
+ id: "subagent-live:turn-1:agent-1",
+ kind: "subagent-live" as const,
+ createdAt: "2026-01-01T00:00:05Z",
+ live: {
+ id: "subagent-live:turn-1:agent-1",
+ createdAt: "2026-01-01T00:00:05Z",
+ turnId: "turn-1" as never,
+ agentThreadId: "agent-1",
+ label: "Subagent",
+ role: null,
+ objective: null,
+ body: "Checking the runtime path.",
+ model: null,
+ reasoningEffort: null,
+ },
+ };
+
+ const rows = deriveMessagesTimelineRows({
+ timelineEntries: [
+ workEntry("main-read", "2026-01-01T00:00:00Z"),
+ liveEntry,
+ workEntry("main-edit", "2026-01-01T00:00:10Z"),
+ ],
+ completionDividerBeforeEntryId: null,
+ isWorking: true,
+ activeTurnId: "turn-1" as never,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
+ turnDiffSummaryByAssistantMessageId: new Map(),
+ revertTurnCountByUserMessageId: new Map(),
+ });
+
+ expect(rows).toHaveLength(1);
+ const [row] = rows;
+ expect(row?.kind === "work" ? row.groupedEntries.map((entry) => entry.id) : null).toEqual([
+ "main-read",
+ "main-edit",
+ ]);
+ expect(row?.kind === "work" ? row.isLive : null).toBe(true);
+ });
+
it("uses the active status label for the live activity row", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries: [],
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 9face3fd0..4142abc4e 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -355,7 +355,8 @@ export function deriveMessagesTimelineRows(input: {
// A running agent's streamed commentary never reaches the conversation:
// the turn's activity row summarizes what is running, and the rail carries
- // the detail. Only the finished agent's receipt lands here.
+ // the detail. Filtered with the agent-attributed entries above; this arm
+ // only narrows the type for the message handling below.
if (timelineEntry.kind === "subagent-live") {
continue;
}
@@ -486,9 +487,12 @@ function deriveVisibleTimelineEntries(input: {
}): TimelineEntry[] {
// Agent lifecycle entries stay in this pass: they still count as concrete turn
// activity for the provider-lifecycle row's own visibility, and the grouping
- // step below is what parks them out of sight.
+ // step below is what parks them out of sight. Live agent commentary leaves
+ // here entirely: it renders nothing, and its timestamp moves with every
+ // streamed update — left in, it would split and re-merge the turn's work
+ // groups as the agent streams, flickering the activity receipt in and out.
const timelineEntries = input.timelineEntries.filter(
- (entry) => !isSubagentAttributedEntry(entry),
+ (entry) => !isSubagentAttributedEntry(entry) && entry.kind !== "subagent-live",
);
const visibleByIndex = Array.from({ length: timelineEntries.length }, () => true);
let hasLaterProviderLifecycle = false;
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 36b512459..afa6da078 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -2662,12 +2662,37 @@ const WorkGroupSection = memo(function WorkGroupSection({
}
if (isLiveActivity) {
+ // A turn that delegated keeps its receipt while the group is still live:
+ // the tracker on it is the conversation's only inline signal that agents
+ // are running, and it must not blink out whenever the live group happens
+ // to absorb the turn's earlier steps. The receipt stays collapsed here —
+ // the live spine below already narrates the recent steps.
+ const showLiveTracker = turnAgentTracker.summary !== null && onOpenAgentsPanel;
return (
-
+ <>
+ {showLiveTracker ? (
+
+
} connectTop={false} connectBottom={false}>
+
+
+
+ ) : null}
+
+ >
);
}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 869e79cfb..b8a85c690 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -373,6 +373,59 @@ describe("deriveThreadSubagentHistory", () => {
expect(history[0]?.item.reasoningEffort).toBeNull();
});
+ /** The durable roster seeds a record for every agent before the lifecycle
+ * rows are folded, so the collab spawn is never the record's first sighting.
+ * A spawn that stated no model still means "the parent turn's settings" —
+ * the roster seed must not swallow that inheritance. */
+ it("inherits the turn's dispatched settings for a roster-seeded collab spawn", () => {
+ const durable: OrchestrationSubagent = {
+ id: "codex-child-1",
+ agentThreadId: "codex-child-1",
+ parentAgentThreadId: null,
+ spawnCallId: "call_spawn",
+ transcriptAgentId: "codex-child-1",
+ turnId: TurnId.make("turn-1"),
+ agentPath: "/root/web_tsx_count",
+ parentAgentPath: "/root",
+ treeDepth: 0,
+ nickname: null,
+ role: null,
+ objective: null,
+ status: "running",
+ requestedModel: null,
+ resolvedModel: null,
+ reasoningEffort: null,
+ modelProvenance: null,
+ reasoningEffortProvenance: null,
+ resultBody: null,
+ resultCreatedAt: null,
+ createdAt: "2026-08-11T19:34:59.000Z",
+ updatedAt: "2026-08-11T19:34:59.000Z",
+ };
+ const [turnPreparing, turnStarted, spawn, wait] = codexSpawnActivities();
+ const collabSpawn = structuredClone(spawn) as typeof spawn;
+ const collabItem = (
+ (collabSpawn?.payload as { data: { item: Record } }).data as {
+ item: Record;
+ }
+ ).item;
+ // The same spawn shaped as a collab tool call rather than a native item:
+ // this omission means "parent settings", not "unknown".
+ collabItem.type = "collabAgentToolCall";
+
+ const history = deriveThreadSubagentHistory(
+ [turnPreparing!, turnStarted!, collabSpawn!, wait!],
+ [durable],
+ );
+
+ expect(history).toHaveLength(1);
+ expect(history[0]?.item).toMatchObject({
+ agentThreadId: "codex-child-1",
+ model: "gpt-5.6-sol",
+ reasoningEffort: "high",
+ });
+ });
+
/** Claude's `provider.turn.started` does state a model, so the unscoped
* dispatch row has to act as a floor rather than be skipped: the effort only
* ever appears in the dispatch, and the provider's model still has to win. */
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 74ba7fa39..3ed59a63d 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -768,6 +768,11 @@ interface InternalSubagentRecord extends SubagentProgressItem {
* spawn asked for. Sticky: later lifecycle rows only carry the alias
* again, and the model has not changed. */
resolvedModel: string | null;
+ /** Whether a native `subAgentActivity` spawn established this agent. Sticky:
+ * a native spawn's omitted model and effort mean unknown, so the parent
+ * turn's dispatched selection must never fill them in — not at spawn, and
+ * not through the coordination items that follow. */
+ nativeSpawn: boolean;
liveBodyUpdatedAt: string | null;
resultActivityId: string | null;
resultBody: string | null;
@@ -837,6 +842,7 @@ export function deriveSubagentProgressState(input: {
function toSubagentProgressItem(record: InternalSubagentRecord): SubagentProgressItem {
const {
resolvedModel: _resolvedModel,
+ nativeSpawn: _nativeSpawn,
liveBodyUpdatedAt: _liveBodyUpdatedAt,
resultActivityId: _resultActivityId,
resultBody: _resultBody,
@@ -1154,6 +1160,10 @@ function collectSubagentActivityRecords(
status: subagent.status,
statusLabel: subagentProgressStatusLabel(subagent.status),
resolvedModel: subagent.resolvedModel,
+ // The roster does not record how the agent was spawned; the lifecycle
+ // rows below re-flag native spawns before any coordination item could
+ // inherit on their behalf, since the spawn row precedes them.
+ nativeSpawn: false,
model: subagent.resolvedModel ?? subagent.requestedModel,
reasoningEffort: subagent.reasoningEffort,
liveBody: null,
@@ -1339,18 +1349,14 @@ function collectSubagentActivityRecords(
const turnId = activity.turnId ?? previous?.turnId ?? null;
// What the spawn asked for always wins; the turn's own selection is the
- // floor for providers that only state a model when it was overridden.
+ // floor for spawns that only state a model when it was overridden. A
+ // native spawn's omission means unknown, and that fact must survive the
+ // coordination items (`wait`, `sendInput`, `closeAgent`) that follow, so
+ // it lives on the record rather than on whichever item arrived first —
+ // a record seeded from the durable roster still inherits its floor.
+ const nativeSpawn = previous?.nativeSpawn === true || item.type === "subAgentActivity";
const inherited =
- item.type === "subAgentActivity" || turnId === null
- ? undefined
- : turnModelSelections.get(turnId);
- // Inheritance is only a seed for a newly discovered child. Once a native
- // spawn has established the record, later coordination items such as
- // `wait` must preserve its deliberately omitted model and effort rather
- // than filling them from the parent turn.
- const previousOrInheritedModel = previous === undefined ? inherited?.model : previous.model;
- const previousOrInheritedReasoningEffort =
- previous === undefined ? inherited?.reasoningEffort : previous.reasoningEffort;
+ nativeSpawn || turnId === null ? undefined : turnModelSelections.get(turnId);
byAgentId.set(agentId, {
id: agentId,
@@ -1369,9 +1375,16 @@ function collectSubagentActivityRecords(
status,
statusLabel: subagentProgressStatusLabel(status),
resolvedModel: resolvedModel ?? previous?.resolvedModel ?? null,
+ nativeSpawn,
model:
- resolvedModel ?? previous?.resolvedModel ?? model ?? previousOrInheritedModel ?? null,
- reasoningEffort: reasoningEffort ?? previousOrInheritedReasoningEffort ?? null,
+ resolvedModel ??
+ previous?.resolvedModel ??
+ model ??
+ previous?.model ??
+ inherited?.model ??
+ null,
+ reasoningEffort:
+ reasoningEffort ?? previous?.reasoningEffort ?? inherited?.reasoningEffort ?? null,
liveBody,
liveBodyUpdatedAt,
// Claude supplies a dedicated task stream. Codex child work arrives as