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
5 changes: 2 additions & 3 deletions apps/web/src/components/AgentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Agents right-panel surface: the fleet view over the native subagent fold,
* and the ONLY place the roster renders (the chat carries one CTA row per
* spawn batch).
* Agents right-panel surface: the fleet view over the native subagent fold.
* The chat carries one expandable row per spawn batch and links here.
*
* Visualization rules (from live-test feedback):
* - Spawn order is stable. Activity and completion update rows in place.
Expand Down
138 changes: 137 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,141 @@ describe("deriveMessagesTimelineRows", () => {
]);
});

it("folds a settled subagent spawn row and keeps a live one outside the fold", () => {
const firstMessage: ChatMessage = {
id: MessageId.make("assistant-first-entry"),
role: "assistant",
text: "Fanning out.",
turnId: TurnId.make("turn-1"),
createdAt: "2026-01-01T00:00:01Z",
updatedAt: "2026-01-01T00:00:02Z",
streaming: false,
};
const entriesWith = (agentSpawn: { workflowId: string | null; agentTaskIds: string[] }) =>
deriveTimelineEntries(
[
firstMessage,
{
...firstMessage,
id: MessageId.make("assistant-final-entry"),
text: "Done.",
createdAt: "2026-01-01T00:00:05Z",
updatedAt: "2026-01-01T00:00:06Z",
},
],
[],
[
{
id: "spawn-entry",
createdAt: "2026-01-01T00:00:03Z",
turnId: firstMessage.turnId,
label: "Ran 2 subagents",
tone: "tool",
agentSpawn,
},
],
);
const direct = entriesWith({ workflowId: null, agentTaskIds: ["agent-a", "agent-b"] });
const workflow = entriesWith({ workflowId: "wf-1", agentTaskIds: ["wf-1", "agent-a"] });
const derive = (
timelineEntries: typeof direct,
liveAgentTaskIds: ReadonlySet<string> | undefined,
expandedSpawnEntryIds?: ReadonlySet<string>,
expandedTurnIds?: ReadonlySet<TurnId>,
) =>
deriveMessagesTimelineRows({
timelineEntries,
isWorking: false,
activeTurnStartedAt: null,
turnDiffSummaries: [],
supportsConversationRollback: false,
liveAgentTaskIds,
expandedSpawnEntryIds,
...(expandedTurnIds ? { expandedTurnIds } : {}),
}).map((row) => row.id);
const folded = ["turn-fold:turn-1", "assistant-final-entry"];
const unfolded = ["turn-fold:turn-1", "spawn-entry", "assistant-final-entry"];

const activeRows = (
timelineEntries: typeof direct,
liveAgentTaskIds: ReadonlySet<string>,
runningTurnId = "turn-1",
) =>
deriveMessagesTimelineRows({
timelineEntries: timelineEntries.slice(0, 2),
isWorking: true,
runningTurnId: runningTurnId as TurnId,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaries: [],
supportsConversationRollback: false,
liveAgentTaskIds,
}).map((row) => row.kind);
expect(activeRows(direct, new Set(["agent-b"]))).not.toContain("thinking");
expect(activeRows(workflow, new Set(["wf-1"]))).not.toContain("thinking");
expect(activeRows(direct, new Set())).toContain("thinking");
expect(activeRows(direct, new Set(["agent-b"]), "turn-2")).toContain("thinking");

for (const [toolLifecycleStatus, tone, sourceActivityKind] of [
["inProgress", "tool", "tool.updated"],
["completed", "tool", "tool.completed"],
// Claude background Bash completions arrive without a command or item type.
["completed", "info", "task.completed"],
] as const) {
const laterTool = {
id: "later-tool",
kind: "work" as const,
createdAt: "2026-01-01T00:00:04Z",
entry: {
id: "later-tool",
turnId: "turn-1" as TurnId,
createdAt: "2026-01-01T00:00:04Z",
label: "Read file",
tone,
sourceActivityKind,
toolLifecycleStatus,
},
};
for (const trailingEntries of [[], [laterTool]]) {
const rows = deriveMessagesTimelineRows({
timelineEntries: [...direct.slice(0, 2), ...trailingEntries],
isWorking: true,
runningTurnId: "turn-1" as TurnId,
activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaries: [],
supportsConversationRollback: false,
liveAgentTaskIds: new Set(["agent-b"]),
});
expect(rows.filter((row) => row.kind === "work-live")).toMatchObject([
{
id: "live-activity-row",
entry: { id: trailingEntries.length ? "later-tool" : "spawn-entry" },
active: true,
},
]);
expect(rows.some((row) => row.kind === "work" || row.kind === "thinking")).toBe(false);
}
}

expect(derive(direct, new Set())).toEqual(folded);
expect(derive(direct, new Set(["agent-b"]))).toEqual(unfolded);
// A workflow coordinator between phases keeps its batch out of the fold.
expect(derive(workflow, new Set(["wf-1"]))).toEqual(unfolded);
expect(derive(workflow, new Set())).toEqual(folded);
// No live set is known.
expect(derive(direct, undefined)).toEqual(unfolded);
// The user has it open: it stays visible under the collapsed fold and
// keeps its place when the fold is expanded.
expect(derive(direct, new Set(), new Set(["spawn-entry"]))).toEqual(unfolded);
expect(
derive(direct, new Set(), new Set(["spawn-entry"]), new Set(["turn-1" as TurnId])),
).toEqual([
"turn-fold:turn-1",
"assistant-first-entry",
"spawn-entry",
"assistant-final-entry",
]);
});

it("only enables assistant copy for the terminal assistant message in a turn", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries: [
Expand Down Expand Up @@ -2286,7 +2421,7 @@ describe("deriveMessagesTimelineRows", () => {
it.each([
[undefined, true],
["inProgress", true],
["completed", false],
["completed", true],
["failed", null],
["declined", false],
["stopped", false],
Expand Down Expand Up @@ -2329,6 +2464,7 @@ describe("deriveMessagesTimelineRows", () => {
expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
} else {
expect(workLiveRow).toMatchObject({ active });
if (active) expect(rows.some((row) => row.kind === "thinking")).toBe(false);
}
},
);
Expand Down
56 changes: 46 additions & 10 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ function deriveTurnFolds(input: {
terminalAssistantMessageIds: ReadonlySet<string>;
latestTurn: TimelineLatestTurn | null;
unfoldedTurnIds: ReadonlySet<TurnId>;
liveAgentTaskIds: ReadonlySet<string> | undefined;
}): ReadonlyMap<string, TurnFold> {
interface TurnGroup {
entries: Array<TimelineEntry>;
Expand Down Expand Up @@ -661,11 +662,22 @@ function deriveTurnFolds(input: {
if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) {
continue;
}
// Agent-spawn CTA rows never fold: workflows outlive their launching
// turn (dynamic spawns, background execution), and folding the CTA
// when the turn settles makes a still-running fleet invisible.
// Workflows outlive their launching turn (dynamic spawns, background
// execution), so a spawn row with a live member or coordinator stays
// outside the fold instead of hiding a still-running fleet. Settled
// spawns fold with the rest of the turn. Without a live set (no agent
// panel model, as in the held paint during a thread switch) every
// spawn row stays out.
if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
continue;
const live = input.liveAgentTaskIds;
const { workflowId, agentTaskIds } = entry.entry.agentSpawn;
if (
live === undefined ||
(workflowId !== null && live.has(workflowId)) ||
agentTaskIds.some((taskId) => live.has(taskId))
) {
continue;
}
}
hiddenEntryIds.add(entry.id);
}
Expand Down Expand Up @@ -858,6 +870,13 @@ export function deriveMessagesTimelineRows(input: {
activeTurnStartedAt: string | null;
turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
supportsConversationRollback: boolean;
/**
* Task ids of subagents still working; their spawn row stays outside turn
* folds. Undefined means unknown, which keeps every spawn row out.
*/
liveAgentTaskIds?: ReadonlySet<string> | undefined;
/** Spawn rows the user opened stay visible while their turn fold is collapsed. */
expandedSpawnEntryIds?: ReadonlySet<string> | undefined;
}): MessagesTimelineRow[] {
const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
for (const summary of input.turnDiffSummaries) {
Expand Down Expand Up @@ -892,11 +911,15 @@ export function deriveMessagesTimelineRows(input: {
terminalAssistantMessageIds,
latestTurn: input.latestTurn ?? null,
unfoldedTurnIds: activeVisualResponseTurnIds,
liveAgentTaskIds: input.liveAgentTaskIds,
});
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorEntryId.values()) {
if (!input.expandedTurnIds?.has(fold.turnId)) {
for (const entryId of fold.hiddenEntryIds) {
// An opened spawn row keeps its fold membership but is not pulled
// away mid-read when its last member settles.
if (input.expandedSpawnEntryIds?.has(entryId)) continue;
collapsedEntryIds.add(entryId);
}
}
Expand All @@ -922,7 +945,6 @@ export function deriveMessagesTimelineRows(input: {
if (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
!entryBelongsToActiveTurn(entry, index) ||
entry.kind !== "work" ||
entry.entry.agentSpawn !== undefined ||
entry.entry.questionAnswer !== undefined ||
entry.entry.sourceActivityKind === "context-compaction" ||
entry.entry.tone === "error"
Expand All @@ -937,9 +959,14 @@ export function deriveMessagesTimelineRows(input: {
);
const activeWorkAnchor = activeToolEntries[0];
const latestVisibleToolEntry = visibleActiveToolEntries.at(-1);
const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
workEntryIsActiveTurnActivity(entry.entry),
);
const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => {
const spawn = entry.entry.agentSpawn;
return spawn
? entry === latestVisibleToolEntry &&
((spawn.workflowId !== null && input.liveAgentTaskIds?.has(spawn.workflowId)) ||
spawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId)))
: workEntryIsActiveTurnActivity(entry.entry);
});
const latestToolFailed =
latestRunningToolEntry === undefined &&
latestVisibleToolEntry !== undefined &&
Expand All @@ -948,7 +975,10 @@ export function deriveMessagesTimelineRows(input: {
const latestToolKeepsActivityLive =
latestRunningToolEntry !== undefined ||
(latestVisibleToolEntry !== undefined &&
workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
latestVisibleToolEntry.entry.agentSpawn === undefined &&
(workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry) ||
(latestVisibleToolEntry.entry.toolLifecycleStatus === "completed" &&
!workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry))));
const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
const activeWorkRow =
activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
Expand Down Expand Up @@ -990,7 +1020,7 @@ export function deriveMessagesTimelineRows(input: {
if (activeWorkRow === null) return;
nextRows.push(activeWorkRow);
hasActivityRow ||= activeWorkRow.active;
if (!activeWorkRow.expanded) return;
if (!activeWorkRow.expanded || activeWorkRow.entry.agentSpawn) return;
nextRows.push(
expandedWorkGroupRow(
activeWorkRow.groupId,
Expand Down Expand Up @@ -1053,6 +1083,12 @@ export function deriveMessagesTimelineRows(input: {
timelineEntry.entry.questionAnswer !== undefined ||
timelineEntry.entry.tone === "error"
) {
const spawn = timelineEntry.entry.agentSpawn;
if (spawn && entryBelongsToActiveTurn(timelineEntry, index)) {
hasActivityRow ||=
(spawn.workflowId !== null && input.liveAgentTaskIds?.has(spawn.workflowId)) ||
spawn.agentTaskIds.some((taskId) => input.liveAgentTaskIds?.has(taskId));
}
nextRows.push({
kind: "work",
id: timelineEntry.id,
Expand Down
Loading
Loading