diff --git a/src/features/chat/display/blocks.ts b/src/features/chat/display/blocks.ts index 96a1440c..c8281555 100644 --- a/src/features/chat/display/blocks.ts +++ b/src/features/chat/display/blocks.ts @@ -10,7 +10,6 @@ export function displayBlocksForItems( turnDiffs?: ReadonlyMap, ): DisplayBlock[] { const visibleItems = items.filter(shouldShowDisplayItem); - const orderedItems = activeTurnId ? moveActiveTaskProgressToEnd(visibleItems, activeTurnId) : visibleItems; const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot); const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems); const finalAssistantIdByTurn = finalAssistantItemsByTurn(visibleItems); @@ -18,7 +17,7 @@ export function displayBlocksForItems( const summaryAssistantIdByTurn = new Map([...finalAssistantIdByTurn].filter(([turnId]) => groupedTurnIds.has(turnId))); const groupedActivities = new Map(); - for (const item of orderedItems) { + for (const item of visibleItems) { if (!item.turnId || !groupedTurnIds.has(item.turnId) || !isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) continue; const group = groupedActivities.get(item.turnId) ?? []; group.push(item); @@ -26,7 +25,7 @@ export function displayBlocksForItems( } const blocks: DisplayBlock[] = []; - for (const item of orderedItems) { + for (const item of visibleItems) { const turnId = item.turnId; if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) { continue; @@ -50,12 +49,6 @@ export function displayBlocksForItems( return blocks; } -function moveActiveTaskProgressToEnd(items: readonly DisplayItem[], activeTurnId: string): DisplayItem[] { - const activeTaskProgress = items.filter((item) => item.kind === "taskProgress" && item.turnId === activeTurnId); - if (activeTaskProgress.length === 0) return [...items]; - return [...items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId), ...activeTaskProgress]; -} - function shouldShowDisplayItem(item: DisplayItem): boolean { return item.kind !== "reasoning" || executionState(item) !== "completed" || item.text.trim().length > 0; } diff --git a/src/features/chat/ui/message-stream.tsx b/src/features/chat/ui/message-stream.tsx index aaafd66c..61100d91 100644 --- a/src/features/chat/ui/message-stream.tsx +++ b/src/features/chat/ui/message-stream.tsx @@ -94,7 +94,8 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea return blocks; } - for (const block of displayBlocksForItems(context.displayItems, activeTurn, context.workspaceRoot, context.turnDiffs)) { + const streamItems = activeTurn ? withoutActiveTaskProgress(context.displayItems, activeTurn) : context.displayItems; + for (const block of displayBlocksForItems(streamItems, activeTurn, context.workspaceRoot, context.turnDiffs)) { if (block.type === "item") { blocks.push({ key: `item:${block.item.id}`, @@ -108,13 +109,14 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea } } - const agentSummary = activeAgentRunSummaryBlock(context); - if (agentSummary) { - blocks.push({ - key: `active-agents:${activeTurn ?? "none"}`, - node: agentRunSummaryNode(agentSummary), - }); - } + blocks.push(...bottomLiveBlocks(context, activeTurn)); + + return blocks; +} + +function bottomLiveBlocks(context: MessageStreamContext, activeTurn: string | null): MessageStreamBlock[] { + const blocks: MessageStreamBlock[] = []; + if (activeTurn) blocks.push(...activeTurnLiveBlocks(context, activeTurn)); if (context.renderPendingRequests && context.pendingRequestsSignature) { blocks.push({ @@ -122,10 +124,45 @@ export function messageStreamBlocks(context: MessageStreamContext): MessageStrea node: context.renderPendingRequests(), }); } - return blocks; } +function activeTurnLiveBlocks(context: MessageStreamContext, activeTurn: string): MessageStreamBlock[] { + const agentSummaryAnchorId = activeAgentRunSummaryAnchorId(context.displayItems, activeTurn); + const agentSummary = agentSummaryAnchorId ? activeAgentRunSummaryBlock(context) : null; + const blocks = context.displayItems.flatMap((item): MessageStreamBlock[] => { + if (item.kind === "taskProgress" && item.turnId === activeTurn) { + return [ + { + key: `live-task:${item.id}`, + node: workItemNode(item, context), + }, + ]; + } + if (item.id === agentSummaryAnchorId) { + return agentSummary + ? [ + { + key: `live-agents:${activeTurn}`, + node: agentRunSummaryNode(agentSummary), + }, + ] + : []; + } + return []; + }); + return blocks; +} + +function activeAgentRunSummaryAnchorId(items: readonly DisplayItem[], activeTurn: string): string | null { + const firstActiveAgent = items.find((item) => item.kind === "agent" && item.turnId === activeTurn); + return firstActiveAgent?.id ?? null; +} + +function withoutActiveTaskProgress(items: readonly DisplayItem[], activeTurn: string): DisplayItem[] { + return items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurn); +} + export function renderMessageStreamBlocks(parent: HTMLElement, blocks: MessageStreamBlock[]): void { renderReactRoot(parent, ); } diff --git a/tests/features/chat/display/display-model.test.ts b/tests/features/chat/display/display-model.test.ts index dcd6b71b..e333484f 100644 --- a/tests/features/chat/display/display-model.test.ts +++ b/tests/features/chat/display/display-model.test.ts @@ -985,7 +985,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes expect(displayBlocksForItems(items, "t1").map((block) => block.type)).toEqual(["item", "item"]); }); - it("moves active task progress to the end of the live turn", () => { + it("keeps active task progress chronological in the display model", () => { const items: DisplayItem[] = [ { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" }, { @@ -1003,7 +1003,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes const blocks = displayBlocksForItems(items, "t1"); - expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "a1", "plan-progress-t1"]); + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "plan-progress-t1", "a1"]); }); it("summarizes task progress and agent activity separately from tools", () => { diff --git a/tests/features/chat/ui/message-stream.test.tsx b/tests/features/chat/ui/message-stream.test.tsx index 6ad2bc1f..1ec26521 100644 --- a/tests/features/chat/ui/message-stream.test.tsx +++ b/tests/features/chat/ui/message-stream.test.tsx @@ -1776,13 +1776,173 @@ describe("work log renderer decisions", () => { }), ); - const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="item:plan-progress-turn"]')); + const block = expectPresent(parent.querySelector('[data-codex-panel-block-key="live-task:plan-progress-turn"]')); expect(block.querySelector(".codex-panel__task-progress .codex-panel__message-role")?.textContent).toBe("tasks"); expect(block.textContent).toContain("[x]Inspect code"); expect(block.textContent).toContain("[>]Patch UI"); unmountReactRoot(parent); }); + it("renders active task progress with the shared bottom live blocks", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, + { + id: "plan-progress-turn", + kind: "taskProgress", + role: "tool", + text: "[>] Patch UI", + turnId: "turn", + explanation: null, + steps: [{ step: "Patch UI", status: "inProgress" }], + status: "inProgress", + }, + { id: "a1", kind: "message", role: "assistant", text: "Working", turnId: "turn", markdown: true }, + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "running", + senderThreadId: "parent", + receiverThreadIds: ["running"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "running", status: "running", message: "Inspecting renderer" }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + pendingRequestsSignature: "request:1", + renderPendingRequests: () => "Request", + }); + + expect(blocks.map((block) => block.key)).toEqual([ + "item:u1", + "item:a1", + "item:agent-1", + "live-task:plan-progress-turn", + "live-agents:turn", + "pending-requests", + ]); + }); + + it("orders shared bottom live blocks by insertion order", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, + { + id: "agent-1", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "running", + senderThreadId: "parent", + receiverThreadIds: ["running"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "running", status: "running", message: null }], + }, + { + id: "plan-progress-turn", + kind: "taskProgress", + role: "tool", + text: "[>] Patch UI", + turnId: "turn", + explanation: null, + steps: [{ step: "Patch UI", status: "inProgress" }], + status: "inProgress", + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + expect(blocks.map((block) => block.key)).toEqual(["item:u1", "item:agent-1", "live-agents:turn", "live-task:plan-progress-turn"]); + }); + + it("anchors the live agent summary at the first agent activity", () => { + const blocks = messageStreamBlocks({ + activeThreadId: "thread", + turnLifecycle: runningTurnLifecycle("turn"), + historyCursor: null, + loadingHistory: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "Do it", turnId: "turn", markdown: true }, + { + id: "agent-spawn", + kind: "agent", + role: "tool", + text: "Spawn agent", + turnId: "turn", + tool: "spawnAgent", + status: "completed", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: "Inspect the renderer.", + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "completed", message: null }], + }, + { + id: "plan-progress-turn", + kind: "taskProgress", + role: "tool", + text: "[>] Patch UI", + turnId: "turn", + explanation: null, + steps: [{ step: "Patch UI", status: "inProgress" }], + status: "inProgress", + }, + { + id: "agent-wait", + kind: "agent", + role: "tool", + text: "Wait for agent", + turnId: "turn", + tool: "wait", + status: "running", + senderThreadId: "parent", + receiverThreadIds: ["child"], + prompt: null, + model: null, + reasoningEffort: null, + agents: [{ threadId: "child", status: "running", message: "Inspecting renderer" }], + }, + ], + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + expect(blocks.map((block) => block.key)).toEqual([ + "item:u1", + "item:agent-spawn", + "item:agent-wait", + "live-agents:turn", + "live-task:plan-progress-turn", + ]); + }); + it("renders agent activity with target state and prompt details", () => { const block = messageStreamBlocks({ activeThreadId: "thread", @@ -1999,7 +2159,7 @@ describe("work log renderer decisions", () => { }), ); - const summary = expectPresent(parent.querySelector('[data-codex-panel-block-key="active-agents:turn"]')); + const summary = expectPresent(parent.querySelector('[data-codex-panel-block-key="live-agents:turn"]')); expect(summary.querySelector(".codex-panel__agent-summary")?.textContent).toContain("Agents 1 running, 1 done"); expect(summary.textContent).toContain("runningrunning: Inspecting renderer"); unmountReactRoot(parent); @@ -2061,7 +2221,7 @@ describe("work log renderer decisions", () => { renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), }); - expect(blocks.some((block) => block.key.startsWith("active-agents:"))).toBe(false); + expect(blocks.some((block) => block.key.startsWith("live-agents:"))).toBe(false); }); it("marks the live agent summary failed when any subagent fails", () => {