Make a folded turn's summary line its own chat row (#9009)

* fix(app): make a folded turn's summary line its own chat row

The summary line of a collapsed turn was drawn inside its prompt's row, so the
row gap landed below the summary and the prompt touched it. Turn collapse now
emits a `turn_summary` row between the host and the reply, and one spacing
rule covers every prompt: 16 above whatever answers it (reply, summary row or
tool row). The summary row spaces like the work rows it stands for, 4 above the
reply and flush under a tool host.

This removes the per-row summary lookup, the host revision tracking and the
fragments in the view. A fold reuses a turn's previous summary row object while
its work and open state are unchanged, so mounted rows do not re-render.

* fix(app): keep the turn summary row at its host's timeline position

The chat outline resolves the active prompt from the timeline position of
the row under the reading line. The summary row had none, so the outline lost
its mark while the row sat there. The row now carries its host's position and
is replaced when that position lands.

* fix(app): keep the reader on a folding turn's host, not its summary row

When an older page completes the oldest loaded turn and it folds, the reading
position moves from the hidden rows to the turn's host row, as on main, so
the prompt and its summary row both land in view. The pagination helper now
finds the summary line in the row under the prompt.
This commit is contained in:
Mohamed Boudra
2026-11-07 23:00:13 +07:00
committed by GitHub
parent ef7de3d066
commit c6f0c24bf6
14 changed files with 279 additions and 187 deletions
@@ -63,6 +63,26 @@ async function rowsBetweenLatestSummaryAndReply(
}, replyTestId);
}
/** The latest turn's summary line is a chat row of its own, sitting apart from its prompt. */
async function expectSummaryLineSitsApartFromPrompt(page: Page) {
const summaryRow = () =>
page.evaluate(() => {
const latest = (testId: string) =>
[...document.querySelectorAll(`[data-testid="${testId}"]`)].at(-1)!;
const summary = latest("turn-summary");
const row = summary.closest("[data-message-id]")!;
return {
sharesRowWithPrompt: row.querySelector("[data-testid=user-message]") !== null,
gapBelowPrompt: Math.round(
summary.getBoundingClientRect().top -
latest("user-message").getBoundingClientRect().bottom,
),
};
});
await expect.poll(summaryRow).toMatchObject({ sharesRowWithPrompt: false });
expect((await summaryRow()).gapBelowPrompt).toBeGreaterThan(0);
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
@@ -208,6 +228,7 @@ export default function contribute(client) {
/\(end of synthetic stream\)$/,
);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expectSummaryLineSitsApartFromPrompt(page);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
@@ -575,8 +575,10 @@ export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: stri
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
// The folded turn's summary line is the row under its prompt.
const hostId = await promptRow.getAttribute("data-history-row-id");
const summaryRow = timeline.locator(`[data-history-row-id="turn-summary:${hostId}"]`);
await expect(summaryRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
@@ -608,8 +608,11 @@ it("folds work before a plugin-rendered reply and leaves the reply visible", ()
display: "collapseTurns",
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
});
@@ -636,12 +639,14 @@ it("keeps streaming replies native, then folds work without losing manual expans
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
const expandedTurnHostIds = new Set(["prompt"]);
const opened = present({ ...completed, expandedTurnHostIds });
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"tool-one",
"probe-plugin/reply/0",
]);
@@ -649,6 +654,7 @@ it("keeps streaming replies native, then folds work without losing manual expans
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"probe-plugin/reply/0",
]);
});
@@ -675,7 +681,7 @@ it.each(["full", "collapseToolCalls"] as const)(
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.turnsByHostId.size).toBe(0);
expect(result.turns.summaryRowsByHostId.size).toBe(0);
},
);
@@ -697,7 +703,13 @@ it("opening a turn reveals individual calls rather than another tool group", ()
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"tool-one",
"tool-two",
"reply",
]);
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
@@ -739,7 +751,7 @@ it.each(["collapseToolCalls", "collapseTurns"] as const)(
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
],
});
// Both the original partial group and its replacement resolve directly to the turn.
// Both the original partial group and its replacement resolve directly to the turn's host.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
@@ -788,7 +800,7 @@ it("does not reopen the previous turn while a new run waits for its first row",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
});
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
@@ -810,7 +822,7 @@ it("keeps the previous turn folded while a sent prompt waits for its turn to ope
isTurnActive: true,
activeTurnId: null,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "turn-summary:prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
@@ -867,7 +879,13 @@ it.each([false, true])(
const closed = present(input);
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
expect(opened.tail).toEqual(tail);
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"turn-summary:prompt",
"thought",
"tool-one",
"reply",
]);
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
expect(opened.groupsByHostId.size).toBe(0);
},
@@ -50,7 +50,7 @@ const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
previousSummaryRowsByHostId: new Map(),
});
/** Source messages reach plugins before display projection. */
@@ -135,7 +135,8 @@ export function createStreamPresentation() {
isLatestTurnOpen,
expandedHostIds: input.expandedTurnHostIds,
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
previousTurnsByHostId: collapsed?.turnsByHostId ?? NO_COLLAPSED_TURNS.turnsByHostId,
previousSummaryRowsByHostId:
collapsed?.summaryRowsByHostId ?? NO_COLLAPSED_TURNS.summaryRowsByHostId,
});
collapsedSource = projection.tail;
collapsedIsLatestTurnOpen = isLatestTurnOpen;
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAssistantMessageSpacing, getGapBetweenStreamItems } from "./spacing";
import { summarizeToolCalls } from "@/tool-calls/detail-level/overview/model";
import type { StreamItem } from "@/types/stream";
const message: StreamItem = {
kind: "assistant_message",
@@ -7,6 +8,27 @@ const message: StreamItem = {
text: "First\n\nSecond",
timestamp: new Date(0),
};
const prompt: StreamItem = {
kind: "user_message",
id: "p",
text: "Fix it",
timestamp: new Date(0),
};
const thought: StreamItem = {
kind: "thought",
id: "t",
text: "Reading",
timestamp: new Date(0),
status: "ready",
};
const summary: StreamItem = {
kind: "turn_summary",
id: "turn-summary:p",
hostId: "p",
expanded: false,
timestamp: new Date(0),
tools: summarizeToolCalls([]),
};
describe("whole-message spacing", () => {
it("compacts only the gap to its turn footer", () => {
expect(getAssistantMessageSpacing({ item: message })).toBe("default");
@@ -17,4 +39,18 @@ describe("whole-message spacing", () => {
it("keeps separate messages separated", () => {
expect(getGapBetweenStreamItems(message, { ...message, id: "next" })).toBeGreaterThan(0);
});
it("sets a prompt the same distance above whatever answers it", () => {
const belowPrompt = getGapBetweenStreamItems(prompt, thought);
expect(belowPrompt).toBeGreaterThan(0);
expect(getGapBetweenStreamItems(prompt, message)).toBe(belowPrompt);
expect(getGapBetweenStreamItems(prompt, summary)).toBe(belowPrompt);
});
it("spaces a turn's summary row like the work rows it stands for", () => {
expect(getGapBetweenStreamItems(summary, message)).toBe(
getGapBetweenStreamItems(thought, message),
);
expect(getGapBetweenStreamItems(thought, summary)).toBe(
getGapBetweenStreamItems(thought, thought),
);
});
});
+12 -11
View File
@@ -11,8 +11,12 @@ export function getAssistantMessageSpacing(params: {
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
/** A row of the agent's work, including the summary row that stands in for a folded turn's. */
const isWorkItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" ||
item?.kind === "thought" ||
item?.kind === "todo_list" ||
item?.kind === "turn_summary";
export function getGapBetweenStreamItems(
item: StreamItem | null,
@@ -25,19 +29,16 @@ export function getGapBetweenStreamItems(
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (item.kind === "user_message" && belowItem.kind === "assistant_message") {
return 0;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
if (isUserMessageItem(item)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
if (isWorkItem(item) && isWorkItem(belowItem)) {
return 0;
}
if (item.kind === "assistant_message" && isWorkItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
if (isWorkItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
return SPACING[4];
@@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
isLatestTurnOpen: false,
expandedHostIds: new Set(),
getGroupedCalls: () => undefined,
previousTurnsByHostId: new Map(),
previousSummaryRowsByHostId: new Map(),
...options,
});
}
@@ -73,17 +73,29 @@ describe("collapseTurns", () => {
pluginReply("final", "right"),
];
const result = collapse(items);
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
expect(ids(result.tail)).toEqual([
"prompt",
"turn-summary:prompt",
"math/final/left",
"math/final/right",
]);
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
expect(expanded.tail).toEqual(items);
expect(ids(expanded.tail)).toEqual([
"prompt",
"turn-summary:prompt",
"acknowledgment",
"call",
"math/final/left",
"math/final/right",
]);
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
});
it("folds intermediate plugin replies even without tool calls", () => {
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "math/final/0"]);
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
});
@@ -93,7 +105,7 @@ describe("collapseTurns", () => {
const card = { ...pluginReply("card"), sourceKind };
const items = [user("prompt"), shell("call"), card];
expect(collapse(items).tail).toEqual(items);
expect(collapse(items).turnsByHostId.size).toBe(0);
expect(collapse(items).summaryRowsByHostId.size).toBe(0);
},
);
@@ -102,38 +114,41 @@ describe("collapseTurns", () => {
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
});
// A history row renders again only when its item changes, so a prompt that is already retained
// when its turn closes needs its row revised to show the summary line.
it("reports the hosts whose summary line appeared, changed or went away", () => {
const open = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
// A mounted row renders again only when its item changes, so a turn whose work and open state
// are unchanged keeps its summary row object across folds of the same history.
it("keeps a summary row until its turn's work or open state changes", () => {
const first = collapse([user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2")], {
isLatestTurnOpen: true,
});
expect([...open.updatedHostIds]).toEqual(["u1"]);
const closed = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: open.turnsByHostId,
},
{ previousSummaryRowsByHostId: first.summaryRowsByHostId },
);
expect([...closed.updatedHostIds]).toEqual(["u2"]);
const unchanged = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")],
{
previousTurnsByHostId: closed.turnsByHostId,
},
);
expect(unchanged.updatedHostIds.size).toBe(0);
expect(closed.summaryRowsByHostId.get("u1")).toBe(first.summaryRowsByHostId.get("u1"));
const moreWork = collapse(
[user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), shell("c3"), reply("r2")],
{ previousTurnsByHostId: unchanged.turnsByHostId },
{ previousSummaryRowsByHostId: closed.summaryRowsByHostId },
);
expect([...moreWork.updatedHostIds]).toEqual(["u2"]);
const rewound = collapse([user("u1"), shell("c1"), reply("r1")], {
previousTurnsByHostId: moreWork.turnsByHostId,
expect(moreWork.summaryRowsByHostId.get("u1")).toBe(closed.summaryRowsByHostId.get("u1"));
expect(moreWork.summaryRowsByHostId.get("u2")).not.toBe(closed.summaryRowsByHostId.get("u2"));
const opened = collapse(moreWork.tail, {
previousSummaryRowsByHostId: moreWork.summaryRowsByHostId,
expandedHostIds: new Set(["u1"]),
});
expect([...rewound.updatedHostIds]).toEqual(["u2"]);
expect(opened.summaryRowsByHostId.get("u1")).not.toBe(moreWork.summaryRowsByHostId.get("u1"));
});
it("maps hidden rows only while their turn is closed", () => {
it("stands at its host's timeline position, taking a new row when that position lands", () => {
const live = collapse([user("u1"), shell("c1"), reply("r1")]);
expect(live.summaryRowsByHostId.get("u1")).not.toHaveProperty("timelineCursor");
const cursor = { epoch: "epoch-1", seq: 7 };
const placed = [{ ...user("u1"), timelineCursor: cursor }, shell("c1"), reply("r1")];
const canonical = collapse(placed, { previousSummaryRowsByHostId: live.summaryRowsByHostId });
expect(canonical.summaryRowsByHostId.get("u1")).not.toBe(live.summaryRowsByHostId.get("u1"));
expect(canonical.summaryRowsByHostId.get("u1")?.timelineCursor).toEqual(cursor);
const again = collapse(placed, { previousSummaryRowsByHostId: canonical.summaryRowsByHostId });
expect(again.summaryRowsByHostId.get("u1")).toBe(canonical.summaryRowsByHostId.get("u1"));
});
it("maps hidden rows to their turn's host only while the turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
@@ -168,15 +183,15 @@ describe("collapseTurns", () => {
reply("done"),
].map((item) => Object.assign(item, { turnId: "same-run" }));
const result = collapse(items);
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "finished", "compaction", "done"]);
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({
commandCount: 1,
otherToolCount: 0,
paseoActions: { "Created agent": 1 },
});
});
it("folds a finished turn's work into its user message and keeps the reply", () => {
it("folds a finished turn's work into a summary row under its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
thought("t1"),
@@ -186,14 +201,16 @@ describe("collapseTurns", () => {
reply("final"),
]);
expect(ids(result.tail)).toEqual(["u1", "final"]);
expect(result.turnsByHostId.get("u1")).toEqual({
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "final"]);
expect(result.tail[1]).toEqual({
kind: "turn_summary",
id: "turn-summary:u1",
hostId: "u1",
timestamp: at,
expanded: false,
summary: {
tools: expect.objectContaining({ commandCount: 2 }),
},
tools: expect.objectContaining({ commandCount: 2 }),
});
expect(result.summaryRowsByHostId.get("u1")).toBe(result.tail[1]);
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
});
@@ -202,9 +219,9 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["u1"]),
});
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "t1", "c1", "final"]);
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
expect(result.summaryRowsByHostId.get("u1")?.expanded).toBe(true);
});
it("leaves the latest turn alone while it is still producing output", () => {
@@ -212,8 +229,8 @@ describe("collapseTurns", () => {
const result = collapse(tail, { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "u2", "c2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
});
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
@@ -226,8 +243,8 @@ describe("collapseTurns", () => {
reply("r2"),
]);
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "after", "u2", "r2"]);
expect([...result.summaryRowsByHostId.keys()]).toEqual(["u1"]);
});
it("does not fold a turn that only thought before its reply", () => {
@@ -236,7 +253,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
expect(result.summaryRowsByHostId.size).toBe(0);
});
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
@@ -245,7 +262,7 @@ describe("collapseTurns", () => {
const result = collapse(tail);
expect(result.tail).toBe(tail);
expect(result.turnsByHostId.size).toBe(0);
expect(result.summaryRowsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
@@ -254,15 +271,15 @@ describe("collapseTurns", () => {
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
expect(ids(result.tail)).toEqual(["u1", "turn-summary:u1", "r1", "heartbeat"]);
expect(result.tail[1]).toMatchObject({ kind: "turn_summary", turnId: "first" });
});
it("keeps mid-turn messages visible when work is folded", () => {
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
for (const item of tail) item.turnId = "same-turn";
const result = collapse(tail);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
@@ -272,7 +289,7 @@ describe("collapseTurns", () => {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect(ids(result.tail)).toEqual(["prompt", "turn-summary:prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
@@ -282,8 +299,6 @@ describe("collapseTurns", () => {
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
});
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
tools: { commandCount: 3 },
});
expect(result.summaryRowsByHostId.get("u1")?.tools).toMatchObject({ commandCount: 3 });
});
});
@@ -3,35 +3,25 @@ import {
getStreamItemMessageId,
type StreamItem,
type ThoughtItem,
type TimelinePosition,
type ToolCallItem,
type TurnSummaryItem,
} from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The input or first loaded row that carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
}
export interface TurnCollapse {
tail: StreamItem[];
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** Rows of expanded turns, which render inside the turn's indent rail. */
nestedItemIds: ReadonlySet<string>;
/** The turn a folded message belongs to, for surfaces that must reveal it. */
/** The host of the turn a folded message belongs to, for surfaces that must reveal it. */
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
/**
* Hosts whose summary line appeared, changed or went away since `previousTurnsByHostId`. A
* history row renders again only when its item changes, so these rows are revised.
* The summary rows by host. A mounted row renders again only when its item changes, so a fold
* keeps the previous row object for a turn whose work and open state are unchanged.
*/
updatedHostIds: ReadonlySet<string>;
summaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
export interface CollapseTurnsInput {
@@ -41,39 +31,39 @@ export interface CollapseTurnsInput {
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
/** The previous fold of the same history, the baseline for `updatedHostIds`. */
previousTurnsByHostId: ReadonlyMap<string, CollapsedTurn>;
/** The previous fold of the same history, whose unchanged summary rows are reused. */
previousSummaryRowsByHostId: ReadonlyMap<string, TurnSummaryItem>;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
const EMPTY_IDS: ReadonlySet<string> = new Set();
const EMPTY_ROWS: ReadonlyMap<string, TurnSummaryItem> = new Map();
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
function summarizeTurn(
items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
): OverviewSummary {
const calls: (ToolCallItem | ThoughtItem)[] = [];
for (const item of items) {
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { tools: summarizeToolCalls(calls) };
return summarizeToolCalls(calls);
}
function hasWork(summary: TurnSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
function hasWork(tools: OverviewSummary): boolean {
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function isSameTurn(a: CollapsedTurn, b: CollapsedTurn): boolean {
const { paseoActions: actionsA, ...countsA } = a.summary.tools;
const { paseoActions: actionsB, ...countsB } = b.summary.tools;
return (
a.expanded === b.expanded &&
hasSameCounts(countsA, countsB) &&
hasSameCounts(actionsA, actionsB)
);
function hasSameWork(a: OverviewSummary, b: OverviewSummary): boolean {
const { paseoActions: actionsA, ...countsA } = a;
const { paseoActions: actionsB, ...countsB } = b;
return hasSameCounts(countsA, countsB) && hasSameCounts(actionsA, actionsB);
}
function hasSamePosition(a: TimelinePosition | undefined, b: TimelinePosition | undefined) {
return a === b || (a !== undefined && b !== undefined && a.epoch === b.epoch && a.seq === b.seq);
}
function hasSameCounts(a: Record<string, number>, b: Record<string, number>): boolean {
@@ -81,19 +71,32 @@ function hasSameCounts(a: Record<string, number>, b: Record<string, number>): bo
return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
}
function findUpdatedHosts(
previous: ReadonlyMap<string, CollapsedTurn>,
next: ReadonlyMap<string, CollapsedTurn>,
): ReadonlySet<string> {
const updated = new Set<string>();
for (const [hostId, turn] of next) {
const before = previous.get(hostId);
if (!before || !isSameTurn(before, turn)) updated.add(hostId);
function summaryRow(
host: StreamItem,
expanded: boolean,
tools: OverviewSummary,
previous: TurnSummaryItem | undefined,
): TurnSummaryItem {
if (
previous &&
previous.expanded === expanded &&
hasSameWork(previous.tools, tools) &&
hasSamePosition(previous.timelineCursor, host.timelineCursor)
) {
return previous;
}
for (const hostId of previous.keys()) {
if (!next.has(hostId)) updated.add(hostId);
}
return updated.size === 0 ? EMPTY_IDS : updated;
return {
kind: "turn_summary",
id: `turn-summary:${host.id}`,
// The row stands at its host's place in the timeline, so the reading position over it
// still resolves to the turn's prompt.
...(host.timelineCursor !== undefined ? { timelineCursor: host.timelineCursor } : {}),
...(host.turnId !== undefined ? { turnId: host.turnId } : {}),
timestamp: host.timestamp,
hostId: host.id,
expanded,
tools,
};
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
@@ -124,15 +127,15 @@ function findLastReplyIndex(tail: StreamItem[], start: number, end: number): num
}
/**
* Folds each answered turn into its user message: everything the agent did before its last
* message collapses into one summary line, and that message stays in the chat. A turn folds
* only once it is complete, its user message is loaded, and it sent messages or called tools
* before its reply. Reasoning is revealed only when the turn opens.
* Folds each answered turn behind a summary row under its user message: everything the agent
* did before its last message collapses into that row, and the last message stays in the chat.
* A turn folds only once it is complete, its user message is loaded, and it sent messages or
* called tools before its reply. Reasoning is revealed only when the turn opens.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
const output: StreamItem[] = [];
const turnsByHostId = new Map<string, CollapsedTurn>();
const summaryRowsByHostId = new Map<string, TurnSummaryItem>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
@@ -152,13 +155,19 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
const tools = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(tools) && !folded.some(isAssistantReply)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
const summary = summaryRow(
host,
expanded,
tools,
input.previousSummaryRowsByHostId.get(host.id),
);
summaryRowsByHostId.set(host.id, summary);
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
@@ -173,6 +182,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
else hostIdByHiddenRowId.set(item.id, host.id);
}
output.push(
summary,
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
);
output.push(...tail.slice(replyIndex, runEnd));
@@ -190,26 +200,20 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
flushRun(tail.length);
const updatedHostIds = findUpdatedHosts(input.previousTurnsByHostId, turnsByHostId);
if (turnsByHostId.size === 0) {
if (summaryRowsByHostId.size === 0) {
return {
tail,
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
updatedHostIds,
summaryRowsByHostId: EMPTY_ROWS,
};
}
return {
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
tail: output,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
updatedHostIds,
summaryRowsByHostId,
};
}
@@ -4,25 +4,25 @@ import { Summary } from "lucide-react-native";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { ExpandableBadge } from "@/components/message";
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
import type { CollapsedTurn } from "./model";
import type { TurnSummaryItem } from "@/types/stream";
/**
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
* Pressing it opens the turn's rows beneath it.
*/
export const TurnSummaryLine = memo(function TurnSummaryLine({
turn,
item,
onExpandedChange,
}: {
turn: CollapsedTurn;
item: TurnSummaryItem;
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const label = useOverviewSummaryLabel(item.tools) || t("turnSummary.details");
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => onExpandedChange(turn.hostId, !turn.expanded)),
[hold, onExpandedChange, turn.expanded, turn.hostId],
() => hold(() => onExpandedChange(item.hostId, !item.expanded)),
[hold, onExpandedChange, item.expanded, item.hostId],
);
return (
@@ -30,7 +30,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
testID="turn-summary"
label={label}
icon={Summary}
isExpanded={turn.expanded}
isExpanded={item.expanded}
onToggle={toggle}
borderlessWhenExpanded
/>
+13 -45
View File
@@ -689,26 +689,23 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return revealLoadedHistory(messageId);
});
// Read through stable events so a collapse rerun does not change the renderer identity;
// rows whose turn state changed are revised through `historyRowRevision`.
const getCollapsedTurn = useStableEvent((hostId: string) =>
presentation.turns.turnsByHostId.get(hostId),
);
// Read through a stable event so a collapse rerun does not change the renderer identity;
// nested rows are revised through `historyRowRevision`.
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
const { nestedItemIds } = presentation.turns;
if (nestedItemIds.has(layoutItem.item.id)) {
const { item } = layoutItem;
if (nestedItemIds.has(item.id)) {
const below = layoutItem.belowItem;
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
}
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
return item.kind === "turn_summary" && item.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
const renderUserMessageItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
const collapsedTurn = getCollapsedTurn(item.id);
const message = (
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
@@ -728,25 +725,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
/>
);
if (!collapsedTurn) {
return message;
}
return (
<>
{message}
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
</>
);
},
[
context.capabilities,
agentId,
client,
getCollapsedTurn,
pendingClientMessageIds,
resolvedServerId,
setTurnExpanded,
],
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -931,17 +911,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderToolCallItem(layoutItem, item);
case "tool_call":
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
return renderToolCallItem(layoutItem, item);
case "turn_summary":
return <TurnSummaryLine item={item} onExpandedChange={setTurnExpanded} />;
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -972,7 +945,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderUserMessageItem,
renderAssistantMessageItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
@@ -1151,19 +1123,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const streamScrollEnabled =
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
expandedInlineToolCallIds.size === 0;
const { nestedItemIds, updatedHostIds: updatedTurnHostIds } = presentation.turns;
const { nestedItemIds } = presentation.turns;
const historyRowRevision = useMemo(
() => ({
contentById: {
has: (id: string) =>
presentation.historyGroupUpdatesByHostId.has(id) || updatedTurnHostIds.has(id),
has: (id: string) => presentation.historyGroupUpdatesByHostId.has(id),
},
displayStateById: {
// An opened lone call also marks its future group open; that row stays unrevised
// until a group actually forms there.
has: (id: string) =>
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
expandedTurnHostIds.has(id) ||
nestedItemIds.has(id),
},
globalDisplayState: isMobile,
@@ -1171,11 +1141,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[
expandedToolCallGroups,
getToolCallGroup,
expandedTurnHostIds,
isMobile,
nestedItemIds,
presentation.historyGroupUpdatesByHostId,
updatedTurnHostIds,
],
);
@@ -65,6 +65,7 @@ export function estimateStreamItemHeight({
case "tool_call":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "thought":
case "turn_summary":
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
case "todo_list":
return 144;
@@ -76,6 +76,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null {
...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}),
};
case "plugin":
case "turn_summary":
return null;
}
}
@@ -505,6 +505,9 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null {
version: item.version,
data: item.data,
};
// A folded turn's summary row is presentation, not timeline.
case "turn_summary":
return null;
}
}
+22 -1
View File
@@ -8,6 +8,7 @@ import type {
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AttachmentMetadata } from "@/attachments/types";
import type { OverviewSummary } from "@/tool-calls/detail-level/overview/model";
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
/**
@@ -86,7 +87,8 @@ export type StreamItem =
| TodoListItem
| NotificationItem
| CompactionItem
| PluginTimelineStreamItem;
| PluginTimelineStreamItem
| TurnSummaryItem;
export type UserMessageImageAttachment = AttachmentMetadata;
@@ -824,6 +826,25 @@ export interface PluginTimelineStreamItem {
data: JsonValue;
}
/**
* The row a folded turn leaves between its host and its reply, standing in for the work it
* hides. Turn collapse produces it from the loaded rows; it is never stored.
*/
export interface TurnSummaryItem {
kind: "turn_summary";
id: string;
timelineCursor?: TimelinePosition;
turnId?: string;
timestamp: Date;
/**
* The prompt the turn folds under, or its first loaded tool call when the prompt is above the
* loaded history. Keys the reader's expanded turns.
*/
hostId: string;
expanded: boolean;
tools: OverviewSummary;
}
export interface TodoEntry {
text: string;
completed: boolean;