diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts index bcfca2f763..37d7de28ab 100644 --- a/packages/app/e2e/browser/turn-collapse.spec.ts +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -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 { 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); diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index f54fd86812..ed69897e4e 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -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 () => { diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 9f8213d716..48a8ab757b 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -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); }, diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 9a5ff6469e..8e197aed23 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -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; diff --git a/packages/app/src/agent-stream/spacing.test.ts b/packages/app/src/agent-stream/spacing.test.ts index 07e9f185ff..3f84f0df5e 100644 --- a/packages/app/src/agent-stream/spacing.test.ts +++ b/packages/app/src/agent-stream/spacing.test.ts @@ -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), + ); + }); }); diff --git a/packages/app/src/agent-stream/spacing.ts b/packages/app/src/agent-stream/spacing.ts index 55761f71f3..3caaa9f21e 100644 --- a/packages/app/src/agent-stream/spacing.ts +++ b/packages/app/src/agent-stream/spacing.ts @@ -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]; diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts index 4fab619628..9c48f5695a 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.test.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -56,7 +56,7 @@ function collapse(tail: StreamItem[], options: Partial = {}) 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 }); }); }); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts index 0821c74401..fe931afed5 100644 --- a/packages/app/src/agent-stream/turn-collapse/model.ts +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -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; /** Rows of expanded turns, which render inside the turn's indent rail. */ nestedItemIds: ReadonlySet; - /** 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; /** Mounted rows replaced by a closed turn's host, for preserving the reading position. */ hostIdByHiddenRowId: ReadonlyMap; /** - * 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; + summaryRowsByHostId: ReadonlyMap; } export interface CollapseTurnsInput { @@ -41,39 +31,39 @@ export interface CollapseTurnsInput { expandedHostIds: ReadonlySet; /** 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; + /** The previous fold of the same history, whose unchanged summary rows are reused. */ + previousSummaryRowsByHostId: ReadonlyMap; } -const EMPTY_TURNS: ReadonlyMap = new Map(); const EMPTY_IDS: ReadonlySet = new Set(); +const EMPTY_ROWS: ReadonlyMap = new Map(); const EMPTY_HOSTS: ReadonlyMap = 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, b: Record): boolean { @@ -81,19 +71,32 @@ function hasSameCounts(a: Record, b: Record): bo return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]); } -function findUpdatedHosts( - previous: ReadonlyMap, - next: ReadonlyMap, -): ReadonlySet { - const updated = new Set(); - 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(); + const summaryRowsByHostId = new Map(); const nestedItemIds = new Set(); const hostIdByFoldedMessageId = new Map(); const hostIdByHiddenRowId = new Map(); @@ -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((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, }; } diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx index 22660b4606..289a6f748f 100644 --- a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -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 /> diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index ab9d3b8258..6b039ba74e 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -689,26 +689,23 @@ const AgentStreamViewComponent = forwardRef - 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) => { - const collapsedTurn = getCollapsedTurn(item.id); - const message = ( + return ( ); - if (!collapsedTurn) { - return message; - } - return ( - <> - {message} - - - ); }, - [ - context.capabilities, - agentId, - client, - getCollapsedTurn, - pendingClientMessageIds, - resolvedServerId, - setTurnExpanded, - ], + [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -931,17 +911,10 @@ const AgentStreamViewComponent = forwardRef - {renderToolCallItem(layoutItem, item)} - {getCollapsedTurn(item.id) ? ( - - ) : null} - - ); + return renderToolCallItem(layoutItem, item); + + case "turn_summary": + return ; case "notification": return ; @@ -972,7 +945,6 @@ const AgentStreamViewComponent = forwardRef ({ 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