Unify live bottom chat blocks

This commit is contained in:
murashit 2026-05-29 18:57:23 +09:00
parent 82f3e61c10
commit 2ac9a141c9
4 changed files with 213 additions and 23 deletions

View file

@ -10,7 +10,6 @@ export function displayBlocksForItems(
turnDiffs?: ReadonlyMap<string, string>,
): 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<string, DisplayItem[]>();
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;
}

View file

@ -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, <MessageStreamBlocks blocks={blocks} />);
}

View file

@ -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", () => {

View file

@ -1776,13 +1776,173 @@ describe("work log renderer decisions", () => {
}),
);
const block = expectPresent(parent.querySelector<HTMLElement>('[data-codex-panel-block-key="item:plan-progress-turn"]'));
const block = expectPresent(parent.querySelector<HTMLElement>('[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<HTMLElement>('[data-codex-panel-block-key="active-agents:turn"]'));
const summary = expectPresent(parent.querySelector<HTMLElement>('[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", () => {