From 4b90b754c03fc47867d18c3acf38c331579a955c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Oct 2026 07:54:07 +0200 Subject: [PATCH] feat(app): fold finished agent turns behind a summary line Long agent turns bury the answer under intermediate messages, thoughts and tool calls. A new Chat display preference (Full / Collapse tool calls / Collapse turns) replaces the tool call detail level. Collapse turns folds everything a finished turn did before its final message into one summary line under the prompt; opening it shows those rows inline behind the same indent rail that expanded tool call groups now use. Expanded turns stay real timeline rows so virtualization, chat find and scroll-to-message keep working; find and outline jumps open the turn that holds their target. Opening or closing a turn stops following output so the summary stays under the pointer. Collapse turns is the default for new installs only. Existing settings were materialized on first launch, so stored blobs without the new field map from the legacy tool call detail level and otherwise stay on Full. Co-authored-by: Ready <127541578+akaready@users.noreply.github.com> Co-authored-by: Waishnav <86405648+Waishnav@users.noreply.github.com> Co-authored-by: Shreyan Dey <90114886+Dey11@users.noreply.github.com> Co-authored-by: Sebastian Parfitt <7753735+Sebeb@users.noreply.github.com> --- .../browser/tool-call-overview-sheet.spec.ts | 2 +- .../app/e2e/browser/tool-call-shimmer.spec.ts | 2 +- .../app/e2e/browser/turn-collapse.spec.ts | 65 ++++++++ packages/app/e2e/support/fixtures.ts | 5 + .../e2e/support/helpers/plugin-timeline.ts | 2 +- .../chat-find/viewport.browser.test.ts | 1 + .../chat-outline/use-chat-outline.test.tsx | 3 + .../app/src/agent-stream/presentation.test.ts | 9 +- packages/app/src/agent-stream/presentation.ts | 80 ++++++++-- .../app/src/agent-stream/strategy-native.tsx | 1 + .../app/src/agent-stream/strategy-web.tsx | 9 +- packages/app/src/agent-stream/strategy.ts | 2 + .../agent-stream/turn-collapse/model.test.ts | 134 +++++++++++++++++ .../src/agent-stream/turn-collapse/model.ts | 122 +++++++++++++++ .../turn-collapse/summary-line.tsx | 48 ++++++ .../agent-stream/use-scroll-to-message.web.ts | 3 +- .../agent-stream/use-stream-history-window.ts | 3 +- packages/app/src/agent-stream/view.tsx | 140 ++++++++++++++++-- packages/app/src/components/indent-rail.tsx | 32 ++++ packages/app/src/components/message.tsx | 9 +- packages/app/src/hooks/use-settings/index.ts | 2 + .../src/hooks/use-settings/storage.test.ts | 37 ++++- .../app/src/hooks/use-settings/storage.ts | 21 ++- packages/app/src/i18n/resources/ar.ts | 17 ++- packages/app/src/i18n/resources/en.ts | 17 ++- packages/app/src/i18n/resources/es.ts | 17 ++- packages/app/src/i18n/resources/fr.ts | 17 ++- packages/app/src/i18n/resources/ja.ts | 17 ++- packages/app/src/i18n/resources/ko.ts | 17 ++- packages/app/src/i18n/resources/pt-BR.ts | 17 ++- packages/app/src/i18n/resources/ru.ts | 17 ++- packages/app/src/i18n/resources/zh-CN.ts | 17 ++- .../screens/settings/chat/chat-section.tsx | 28 ++-- .../tool-calls/detail-level/overview/label.ts | 45 ++++++ .../tool-calls/detail-level/overview/model.ts | 19 ++- .../tool-calls/detail-level/overview/view.tsx | 46 +----- .../src/tool-calls/detail-level/projection.ts | 4 +- packages/app/src/types/stream.ts | 6 + 38 files changed, 866 insertions(+), 167 deletions(-) create mode 100644 packages/app/e2e/browser/turn-collapse.spec.ts create mode 100644 packages/app/src/agent-stream/turn-collapse/model.test.ts create mode 100644 packages/app/src/agent-stream/turn-collapse/model.ts create mode 100644 packages/app/src/agent-stream/turn-collapse/summary-line.tsx create mode 100644 packages/app/src/components/indent-rail.tsx create mode 100644 packages/app/src/tool-calls/detail-level/overview/label.ts diff --git a/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts index 927585b545..42831fad8a 100644 --- a/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts +++ b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts @@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise { await page.addInitScript(() => { localStorage.setItem( "@paseo:app-settings", - JSON.stringify({ toolCallDetailLevel: "overview" }), + JSON.stringify({ chatDisplay: "collapseToolCalls" }), ); }); } diff --git a/packages/app/e2e/browser/tool-call-shimmer.spec.ts b/packages/app/e2e/browser/tool-call-shimmer.spec.ts index 71ef6555e6..40dde0aa99 100644 --- a/packages/app/e2e/browser/tool-call-shimmer.spec.ts +++ b/packages/app/e2e/browser/tool-call-shimmer.spec.ts @@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a await page.addInitScript(() => { localStorage.setItem( "@paseo:app-settings", - JSON.stringify({ toolCallDetailLevel: "overview" }), + JSON.stringify({ chatDisplay: "collapseToolCalls" }), ); }); const agent = await seedMockAgentWorkspace({ diff --git a/packages/app/e2e/browser/turn-collapse.spec.ts b/packages/app/e2e/browser/turn-collapse.spec.ts new file mode 100644 index 0000000000..e2ed9ab239 --- /dev/null +++ b/packages/app/e2e/browser/turn-collapse.spec.ts @@ -0,0 +1,65 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns"; + +async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise { + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value })); + }, chatDisplay); +} + +async function seedAnsweredAgent(title: string) { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "turn-collapse-", + title, + model: "ten-second-stream", + initialPrompt: "Why does the chat stop following new output?", + }); + await agent.client.waitForFinish(agent.agentId, 30_000); + await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script."); + await agent.client.waitForFinish(agent.agentId, 30_000); + return agent; +} + +/** Chat rows between the latest turn's summary line and its reply, in document order. */ +async function rowsBetweenLatestSummaryAndReply(page: Page): Promise { + return page.evaluate(() => { + const rows = [...document.querySelectorAll("[data-message-id]")]; + const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]")); + const replyRow = rows.findLastIndex((row) => + row.querySelector("[data-testid=assistant-message]"), + ); + return replyRow - summaryRow - 1; + }); +} + +test.describe("Turn collapse", () => { + test("folds each answered turn behind a summary line that opens it", async ({ page }) => { + test.setTimeout(120_000); + await useChatDisplay(page, "collapseTurns"); + const agent = await seedAnsweredAgent("Turn collapse"); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + const summary = page.getByTestId("turn-summary").last().getByRole("button"); + await expect(summary).toHaveText(/^Wrote \d+ messages, edited 1 file/); + await expect(page.getByTestId("assistant-message").last()).toHaveText( + "(end of synthetic stream)", + ); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + + await summary.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0); + await expect(summary).toBeInViewport(); + + await summary.click(); + await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index c621f43fce..89cfb6937d 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -180,6 +180,11 @@ const test = daemonTest.extend<{ localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts])); localStorage.removeItem("@paseo:settings"); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + // New installs collapse finished turns; specs assert on the rows inside them, so they + // start from the full chat unless they seed their own settings. + if (!localStorage.getItem("@paseo:app-settings")) { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" })); + } }, { daemon: testDaemon, diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index 145addc2ef..533b546022 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -116,7 +116,7 @@ export async function withTimelinePlugin( await page.addInitScript(() => { localStorage.setItem( "@paseo:app-settings", - JSON.stringify({ toolCallDetailLevel: "overview" }), + JSON.stringify({ chatDisplay: "collapseToolCalls" }), ); }); const agent = await seedMockAgentWorkspace({ diff --git a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts index a7cf29994c..1b4b8d18e8 100644 --- a/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts +++ b/packages/app/src/agent-stream/chat-find/viewport.browser.test.ts @@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) { const handle: StreamViewportHandle = { scrollToBottom() {}, prepareForViewportChange() {}, + holdReadingPosition() {}, scrollToMessage(_messageId, occurrence) { if (occurrence) capture.occurrence = occurrence; }, diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index bd5a73ce94..890f6eaae7 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -265,6 +265,7 @@ describe("useChatOutline", () => { const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), + holdReadingPosition: vi.fn(), scrollToMessage, }; const viewportRef = { current: viewport }; @@ -316,6 +317,7 @@ describe("useChatOutline", () => { current: { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), + holdReadingPosition: vi.fn(), scrollToMessage, }, }; @@ -371,6 +373,7 @@ describe("useChatOutline", () => { const viewport: StreamViewportHandle = { scrollToBottom: vi.fn(), prepareForViewportChange: vi.fn(), + holdReadingPosition: vi.fn(), scrollToMessage, }; const livePrompt = { diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index ef3a30e5c5..357b6c079b 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -4,6 +4,7 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim import type { InstalledPlugin } from "@/plugins/types"; import { applyStreamEvent, + getStreamItemMessageId, hydrateStreamState, type StreamItem, type ToolCallItem, @@ -13,7 +14,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { buildAgentStreamRenderModel } from "./model"; -import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; +import { createStreamPresentation } from "./presentation"; const runtime = { paseo: {}, @@ -97,7 +98,11 @@ function pluginData(items: StreamItem[]): unknown[] { return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : [])); } -const presentationOptions = { level: "overview" as const, isTurnActive: true }; +const presentationOptions = { + display: "collapseToolCalls" as const, + expandedTurnHostIds: new Set(), + isTurnActive: true, +}; function streamHarness(transform?: TimelineItemTransform) { let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] }; diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 4459486a7b..9e8e5b430e 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -1,3 +1,4 @@ +import type { ChatDisplay } from "@/hooks/use-settings"; import type { TimelineItemTransform } from "@/plugins/timeline/model"; import { projectPluginTimelineItems } from "@/plugins/timeline/projection"; import { @@ -5,23 +6,32 @@ import { projectToolCallDetailLevel, type PreparedToolCallHistory, type ToolCallDetailLevel, + type ToolCallDetailProjection, } from "@/tool-calls/detail-level/projection"; import type { StreamItem, UserMessageItem } from "@/types/stream"; +import { collapseTurns, type TurnCollapse } from "./turn-collapse/model"; interface PresentationInput { tail: StreamItem[]; head: StreamItem[]; transform: TimelineItemTransform | undefined; - level: ToolCallDetailLevel; + display: ChatDisplay; + /** User messages whose collapsed turn the reader opened. */ + expandedTurnHostIds: ReadonlySet; isTurnActive: boolean; } -/** Find and scrolling address a plugin row by the source it replaces. */ -export function getStreamItemMessageId(item: StreamItem): string { - if (item.kind === "plugin") return item.sourceId ?? item.id; - return item.id; +export interface StreamPresentation extends ToolCallDetailProjection { + turns: Omit; } +const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({ + tail: [], + isLatestTurnOpen: false, + expandedHostIds: new Set(), + getGroupedCalls: () => undefined, +}); + /** Source messages reach plugins before display projection. */ export function createStreamPresentation() { const userMessageCache = new WeakMap(); @@ -47,12 +57,47 @@ export function createStreamPresentation() { let preparedTail: StreamItem[] | undefined; let preparedLevel: ToolCallDetailLevel | undefined; let preparedHistory: PreparedToolCallHistory | null = null; + let collapsedSource: StreamItem[] | undefined; + let collapsedIsLatestTurnOpen = false; + let collapsedExpandedHostIds: ReadonlySet | undefined; + let collapsed: TurnCollapse | undefined; function presentRows(item: StreamItem): StreamItem[] { return [item.kind === "user_message" ? presentUserMessage(item) : item]; } - return (input: PresentationInput) => { + function collapse( + projection: ToolCallDetailProjection, + input: PresentationInput, + ): StreamPresentation { + if (input.display !== "collapseTurns") { + return { ...projection, turns: NO_COLLAPSED_TURNS }; + } + // Live text updates reach the head only, so the folded history is reused until the + // retained rows, the latest turn's liveness, or the reader's expanded turns change. + const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0; + if ( + !collapsed || + collapsedSource !== projection.tail || + collapsedIsLatestTurnOpen !== isLatestTurnOpen || + collapsedExpandedHostIds !== input.expandedTurnHostIds + ) { + collapsed = collapseTurns({ + tail: projection.tail, + isLatestTurnOpen, + expandedHostIds: input.expandedTurnHostIds, + getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls, + }); + collapsedSource = projection.tail; + collapsedIsLatestTurnOpen = isLatestTurnOpen; + collapsedExpandedHostIds = input.expandedTurnHostIds; + } + const { tail, ...turns } = collapsed; + return { ...projection, tail, turns }; + } + + return (input: PresentationInput): StreamPresentation => { + const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview"; // Retained history is not reprojected or regrouped on each live text update. if (historySource !== input.tail || historyTransform !== input.transform) { historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap( @@ -66,17 +111,20 @@ export function createStreamPresentation() { const blocks = presentRows(item); head.push(...blocks); } - if (preparedTail !== historyRows || preparedLevel !== input.level) { - preparedHistory = prepareToolCallHistory(input.level, historyRows); + if (preparedTail !== historyRows || preparedLevel !== level) { + preparedHistory = prepareToolCallHistory(level, historyRows); preparedTail = historyRows; - preparedLevel = input.level; + preparedLevel = level; } - return projectToolCallDetailLevel({ - level: input.level, - tail: historyRows, - head, - preparedHistory, - isTurnActive: input.isTurnActive, - }); + return collapse( + projectToolCallDetailLevel({ + level, + tail: historyRows, + head, + preparedHistory, + isTurnActive: input.isTurnActive, + }), + input, + ); }; } diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index e7568edfe8..7dc6e8d521 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -361,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat bottomAnchorController.prepareForStickyViewportChange(); markNativeViewportSettling(); }, + holdReadingPosition: () => bottomAnchorController.detachByUser(), }; viewportRef.current = handle; return () => { diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index e57fc98e48..8e8bdf7680 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; -import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; @@ -44,7 +44,6 @@ import { type HistoryStartSettleScheduler, } from "./history-start-settle-scheduler"; import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find"; -import { getStreamItemMessageId } from "./presentation"; import { useScrollToMessage } from "./use-scroll-to-message.web"; interface CreateWebStreamStrategyInput { @@ -1174,6 +1173,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } scheduleStickToBottom(); }, + holdReadingPosition: () => { + cancelPendingStickToBottom(); + setFollowOutput(false); + readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0); + }, scrollToMessage, }; viewportRef.current = handle; @@ -1186,6 +1190,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [ cancelPendingStickToBottom, forceStickToBottom, + readingAnchor, scheduleStickToBottom, scrollToMessage, viewportRef, diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 7604b48cdd..3d9df4d201 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -55,6 +55,8 @@ export interface StreamViewportHandle { focus?: () => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; + /** Stop following output so the rows on screen stay put while rows below them open or close. */ + holdReadingPosition: () => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void; } diff --git a/packages/app/src/agent-stream/turn-collapse/model.test.ts b/packages/app/src/agent-stream/turn-collapse/model.test.ts new file mode 100644 index 0000000000..4535b14d9e --- /dev/null +++ b/packages/app/src/agent-stream/turn-collapse/model.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem, ToolCallItem } from "@/types/stream"; +import { collapseTurns, type CollapseTurnsInput } from "./model"; + +const at = new Date("2026-01-01T00:00:00.000Z"); + +function user(id: string): StreamItem { + return { kind: "user_message", id, text: id, timestamp: at }; +} + +function reply(id: string): StreamItem { + return { kind: "assistant_message", id, text: id, timestamp: at }; +} + +function thought(id: string): StreamItem { + return { kind: "thought", id, text: id, timestamp: at, status: "ready" }; +} + +function shell(id: string): ToolCallItem { + return { + kind: "tool_call", + id, + timestamp: at, + payload: { + source: "agent", + data: { + provider: "claude", + callId: id, + name: "Bash", + status: "completed", + error: null, + detail: { type: "shell", command: "ls" }, + }, + }, + }; +} + +function collapse(tail: StreamItem[], options: Partial = {}) { + return collapseTurns({ + tail, + isLatestTurnOpen: false, + expandedHostIds: new Set(), + getGroupedCalls: () => undefined, + ...options, + }); +} + +const ids = (items: StreamItem[]) => items.map((item) => item.id); + +describe("collapseTurns", () => { + it("folds a finished turn's work into its user message and keeps the reply", () => { + const result = collapse([ + user("u1"), + thought("t1"), + reply("early"), + shell("c1"), + shell("c2"), + reply("final"), + ]); + + expect(ids(result.tail)).toEqual(["u1", "final"]); + expect(result.turnsByHostId.get("u1")).toEqual({ + hostId: "u1", + expanded: false, + summary: { + messageCount: 1, + tools: expect.objectContaining({ commandCount: 2 }), + }, + }); + expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1"); + }); + + it("shows an expanded turn's work as nested rows between the prompt and the reply", () => { + const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], { + expandedHostIds: new Set(["u1"]), + }); + + expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]); + expect([...result.nestedItemIds]).toEqual(["t1", "c1"]); + expect(result.turnsByHostId.get("u1")?.expanded).toBe(true); + }); + + it("leaves the latest turn alone while it is still producing output", () => { + const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")]; + + const result = collapse(tail, { isLatestTurnOpen: true }); + + expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]); + expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + }); + + it("keeps rows after the reply, and turns with no work before the reply, as they are", () => { + const result = collapse([ + user("u1"), + shell("c1"), + reply("r1"), + shell("after"), + user("u2"), + reply("r2"), + ]); + + expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]); + expect([...result.turnsByHostId.keys()]).toEqual(["u1"]); + }); + + it("does not fold a turn that only thought before its reply", () => { + const tail = [user("u1"), thought("t1"), reply("r1")]; + + const result = collapse(tail); + + expect(result.tail).toBe(tail); + expect(result.turnsByHostId.size).toBe(0); + }); + + it("does not fold a turn without a reply or whose prompt is above the loaded history", () => { + const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")]; + + const result = collapse(tail); + + expect(result.tail).toBe(tail); + expect(result.turnsByHostId.size).toBe(0); + }); + + it("counts every call a tool-call group row stands for", () => { + const group = [shell("c1"), shell("c2"), shell("c3")]; + const result = collapse([user("u1"), group[0]!, reply("final")], { + getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined), + }); + + expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({ + tools: { commandCount: 3 }, + }); + }); +}); diff --git a/packages/app/src/agent-stream/turn-collapse/model.ts b/packages/app/src/agent-stream/turn-collapse/model.ts new file mode 100644 index 0000000000..d9f532ad73 --- /dev/null +++ b/packages/app/src/agent-stream/turn-collapse/model.ts @@ -0,0 +1,122 @@ +import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream"; +import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model"; + +export interface TurnSummary { + messageCount: number; + tools: OverviewSummary; +} + +export interface CollapsedTurn { + /** The user message the turn answers. Its row 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. */ + hostIdByFoldedMessageId: ReadonlyMap; +} + +export interface CollapseTurnsInput { + tail: StreamItem[]; + /** The latest turn is still producing output, so it stays unfolded. */ + isLatestTurnOpen: boolean; + expandedHostIds: ReadonlySet; + /** The calls a tool-call group row stands for; undefined for a single call. */ + getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined; +} + +const EMPTY_TURNS: ReadonlyMap = new Map(); +const EMPTY_IDS: ReadonlySet = new Set(); +const EMPTY_HOSTS: ReadonlyMap = new Map(); + +function summarizeTurn( + items: readonly StreamItem[], + getGroupedCalls: CollapseTurnsInput["getGroupedCalls"], +): TurnSummary { + const calls: ToolCallItem[] = []; + let messageCount = 0; + for (const item of items) { + if (item.kind === "assistant_message") messageCount += 1; + else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item])); + } + return { messageCount, tools: summarizeToolCalls(calls) }; +} + +function hasWork(summary: TurnSummary): boolean { + return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0); +} + +function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number { + for (let index = end - 1; index >= start; index -= 1) { + if (tail[index]?.kind === "assistant_message") return index; + } + return -1; +} + +/** + * 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. Thoughts fold with the turn but are not counted. + */ +export function collapseTurns(input: CollapseTurnsInput): TurnCollapse { + const { tail } = input; + const output: StreamItem[] = []; + const turnsByHostId = new Map(); + const nestedItemIds = new Set(); + const hostIdByFoldedMessageId = new Map(); + + let runStart = 0; + const flushRun = (runEnd: number) => { + const host = tail[runStart - 1]; + const isClosed = runEnd < tail.length || !input.isLatestTurnOpen; + const replyIndex = findLastAssistantIndex(tail, runStart, runEnd); + if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) { + output.push(...tail.slice(runStart, runEnd)); + return; + } + const folded = tail.slice(runStart, replyIndex); + const summary = summarizeTurn(folded, input.getGroupedCalls); + if (!hasWork(summary)) { + output.push(...tail.slice(runStart, runEnd)); + return; + } + const expanded = input.expandedHostIds.has(host.id); + turnsByHostId.set(host.id, { hostId: host.id, expanded, summary }); + for (const item of folded) { + hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id); + if (expanded) nestedItemIds.add(item.id); + } + if (expanded) output.push(...folded); + output.push(...tail.slice(replyIndex, runEnd)); + }; + + for (let index = 0; index < tail.length; index += 1) { + if (tail[index]?.kind !== "user_message") continue; + flushRun(index); + output.push(tail[index]!); + runStart = index + 1; + } + flushRun(tail.length); + + if (turnsByHostId.size === 0) { + return { + tail, + turnsByHostId: EMPTY_TURNS, + nestedItemIds: EMPTY_IDS, + hostIdByFoldedMessageId: EMPTY_HOSTS, + }; + } + return { + tail: output.length === tail.length ? tail : output, + turnsByHostId, + nestedItemIds, + hostIdByFoldedMessageId, + }; +} diff --git a/packages/app/src/agent-stream/turn-collapse/summary-line.tsx b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx new file mode 100644 index 0000000000..9f73f1ac22 --- /dev/null +++ b/packages/app/src/agent-stream/turn-collapse/summary-line.tsx @@ -0,0 +1,48 @@ +import { memo, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Summary } from "lucide-react-native"; +import { ExpandableBadge } from "@/components/message"; +import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label"; +import type { CollapsedTurn } from "./model"; + +/** + * 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, + onExpandedChange, +}: { + turn: CollapsedTurn; + onExpandedChange: (hostId: string, expanded: boolean) => void; +}) { + const { t } = useTranslation(); + const { messageCount, tools } = turn.summary; + const messageParts = useMemo( + () => + messageCount > 0 + ? [ + t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, { + count: messageCount, + }), + ] + : [], + [messageCount, t], + ); + const label = useOverviewSummaryLabel(tools, messageParts); + const toggle = useCallback( + () => onExpandedChange(turn.hostId, !turn.expanded), + [onExpandedChange, turn.expanded, turn.hostId], + ); + + return ( + + ); +}); diff --git a/packages/app/src/agent-stream/use-scroll-to-message.web.ts b/packages/app/src/agent-stream/use-scroll-to-message.web.ts index 6de7a3296c..e593f1d323 100644 --- a/packages/app/src/agent-stream/use-scroll-to-message.web.ts +++ b/packages/app/src/agent-stream/use-scroll-to-message.web.ts @@ -1,8 +1,7 @@ import type React from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { Virtualizer } from "@tanstack/react-virtual"; -import type { StreamItem } from "@/types/stream"; -import { getStreamItemMessageId } from "./presentation"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle"; import type { ScrollToMessageOccurrence } from "./strategy"; diff --git a/packages/app/src/agent-stream/use-stream-history-window.ts b/packages/app/src/agent-stream/use-stream-history-window.ts index 9874158dcc..340ecb4da4 100644 --- a/packages/app/src/agent-stream/use-stream-history-window.ts +++ b/packages/app/src/agent-stream/use-stream-history-window.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window"; -import { getStreamItemMessageId } from "./presentation"; // Callers reveal a message, not a row: an assistant message is several block rows and // the window has to open at the first of them. diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index d662a2f345..67e08a6f82 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,7 +41,7 @@ import { type InlinePathTarget, } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; -import type { StreamItem } from "@/types/stream"; +import { getStreamItemMessageId, type StreamItem } from "@/types/stream"; import type { PendingMessageSubmission } from "@/composer/submission/model"; import type { TurnPresentation } from "@/timeline/turn-liveness"; import type { PendingPermission } from "@/types/shared"; @@ -62,8 +62,10 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; -import { createStreamPresentation, getStreamItemMessageId } from "./presentation"; +import { createStreamPresentation } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; +import { IndentRail } from "@/components/indent-rail"; +import { TurnSummaryLine } from "./turn-collapse/summary-line"; import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model"; import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; @@ -148,9 +150,16 @@ function renderPendingPermissionsNode(input: { ); } +/** + * Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the + * turn's last row. The rail runs through the gaps between them so the turn reads as one block. + */ +type TurnIndent = "none" | "openedHost" | "nested" | "lastNested"; + function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; + turnIndent: TurnIndent; strategy: TurnContentStrategy; supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; @@ -171,7 +180,7 @@ function renderStreamItemWithTurnFooter(input: { /> ) : null; const content = ( - + {input.content} ); @@ -333,7 +342,7 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); - const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); + const chatDisplay = useSettings((settings) => settings.chatDisplay); const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled); const contentMaxWidth = useSettings(resolveContentMaxWidth); const viewportRef = useRef(null); @@ -357,6 +366,7 @@ const AgentStreamViewComponent = forwardRef>( new Set(), ); + const [expandedTurnHostIds, setExpandedTurnHostIds] = useState>(new Set()); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? context.serverId ?? ""; @@ -421,6 +431,7 @@ const AgentStreamViewComponent = forwardRef { + // A turn opens below its summary line; keep that line under the reader's pointer. + viewportRef.current?.holdReadingPosition(); + setExpandedTurnHostIds((previous) => { + if (previous.has(hostId) === expanded) return previous; + const next = new Set(previous); + if (expanded) { + next.add(hostId); + } else { + next.delete(hostId); + } + return next; + }); + }, []); + // Chat find reveals a match inside a collapsed turn by opening the turn first. + const revealLoadedMessage = useStableEvent((messageId: string): boolean => { + const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId); + if (hostId && !expandedTurnHostIds.has(hostId)) { + setTurnExpanded(hostId, true); + return true; + } + 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), + ); + const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => { + const { nestedItemIds } = presentation.turns; + if (nestedItemIds.has(layoutItem.item.id)) { + const below = layoutItem.belowItem; + return below && nestedItemIds.has(below.id) ? "nested" : "lastNested"; + } + return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none"; + }); + const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => { setExpandedToolCallGroupIds((previous) => { const next = new Set(previous); @@ -665,7 +715,8 @@ const AgentStreamViewComponent = forwardRef) => { - return ( + const collapsedTurn = getCollapsedTurn(item.id); + const message = ( ); + if (!collapsedTurn) { + return message; + } + return ( + <> + {message} + + + ); }, - [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], + [ + context.capabilities, + agentId, + client, + getCollapsedTurn, + pendingClientMessageIds, + resolvedServerId, + setTurnExpanded, + ], ); const renderAssistantMessageItem = useCallback( @@ -893,12 +961,14 @@ const AgentStreamViewComponent = forwardRef ({ contentById: presentation.historyGroupUpdatesByHostId, - displayStateById: expandedToolCallGroupIds, + displayStateById: { + has: (id: string) => + expandedToolCallGroupIds.has(id) || + expandedTurnHostIds.has(id) || + nestedItemIds.has(id), + }, globalDisplayState: isMobile, }), - [expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId], + [ + expandedToolCallGroupIds, + expandedTurnHostIds, + isMobile, + nestedItemIds, + presentation.historyGroupUpdatesByHostId, + ], ); const findItems = useMemo( @@ -1080,7 +1162,7 @@ const AgentStreamViewComponent = forwardRef @@ -1763,15 +1845,41 @@ const permissionStyles = StyleSheet.create((theme) => ({ })); interface StreamItemWrapperProps { - itemId: string; gapBelow: number; + turnIndent: TurnIndent; children: ReactNode; } -function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) { +function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) { const wrapperStyle = useMemo( - () => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }], - [gapBelow], + () => [ + stylesheet.streamItemWrapper, + { marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow }, + ], + [gapBelow, turnIndent], ); - return {children}; + const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]); + switch (turnIndent) { + case "none": + return {children}; + case "openedHost": + return ( + + {children} + + + ); + case "nested": + return ( + + {children} + + ); + case "lastNested": + return ( + + {children} + + ); + } } diff --git a/packages/app/src/components/indent-rail.tsx b/packages/app/src/components/indent-rail.tsx new file mode 100644 index 0000000000..b54ac1ca9b --- /dev/null +++ b/packages/app/src/components/indent-rail.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * Collapsed chat rows badge their summary with an icon whose center sits 7px into the content + * column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail + * hangs from that center so opened rows read as children of the line that opened them. + */ +const RAIL_LEFT = 7; +const RAIL_WIDTH = 1; +export const INDENT_RAIL_CONTENT_OFFSET = 21; + +/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */ +export function IndentRail({ + children, + style, +}: { + children?: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + rail: { + marginLeft: RAIL_LEFT, + paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH, + borderLeftWidth: RAIL_WIDTH, + borderLeftColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d5a2244505..9430aa3a12 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -971,6 +971,10 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + // Details that do not sit in a card leave the header as a lone highlighted pill. + pressableExpandedDetached: { + borderRadius: theme.borderRadius.full, + }, detailWrapperBorderless: { borderWidth: 0, }, @@ -2048,7 +2052,10 @@ export const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.pressable, isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, - isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached, + isExpanded && + (borderlessWhenExpanded + ? expandableBadgeStylesheet.pressableExpandedDetached + : expandableBadgeStylesheet.pressableExpandedAttached), ], [borderlessWhenExpanded, isExpanded, isInteractive, isPressed], ); diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 66a8e767b1..9f6f22fb23 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -42,6 +42,7 @@ import { saveAppSettings as saveAppSettingsPure, type AppSettings, type AppSettingsUpdate, + type ChatDisplay, type OpenInSidePanePreferences, type PullRequestOpenLocation, type DesktopSettingsBridge, @@ -84,6 +85,7 @@ export { export type { AppSettings, AppSettingsUpdate, + ChatDisplay, AppLanguage, OpenInSidePanePreferences, PullRequestOpenLocation, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index dadb912a24..fe2ffdd2a0 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -185,6 +185,7 @@ describe("loadAppSettingsFromStorage", () => { expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system"); + expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns"); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual( DEFAULT_CLIENT_SETTINGS, ); @@ -454,6 +455,7 @@ describe("loadAppSettingsFromStorage", () => { ...DEFAULT_CLIENT_SETTINGS, theme: "dark", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ manageBuiltInDaemon: false, @@ -592,6 +594,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); }); @@ -636,6 +639,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", manageBuiltInDaemon: false, releaseChannel: "beta", }); @@ -657,6 +661,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); }); }); @@ -732,7 +737,7 @@ describe("saveAppSettings", () => { ...DEFAULT_CLIENT_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, - toolCallDetailLevel: "overview", + chatDisplay: "collapseToolCalls", }); }); @@ -787,27 +792,45 @@ describe("appearance settings", () => { expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE); expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); expect(result.syntaxTheme).toBe("one"); - expect(result.toolCallDetailLevel).toBe("detailed"); + expect(result.chatDisplay).toBe("full"); }); - it("migrates the enabled compact tool call preference to overview", async () => { + it("migrates the enabled compact tool call preference to collapsed tool calls", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls"); }); - it("clears settings with an unrecognized tool call detail level", async () => { + it.each([ + ["overview", "collapseToolCalls"], + ["concise", "collapseToolCalls"], + ["detailed", "full"], + ["unknown", "full"], + ])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed"); + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display); + }); + + it("keeps a stored chat display over the tool call detail level it replaced", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + chatDisplay: "collapseTurns", + toolCallDetailLevel: "detailed", + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns"); }); it("migrates a switched-off checks row item to the hidden checks display", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 4fe08bb6d5..3a5be63441 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -40,7 +40,8 @@ export type WorkspaceTitleSource = "title" | "branch"; export type PullRequestOpenLocation = "main" | "side" | "explorer"; /** What a sidebar workspace row shows in the space to the right of its title. */ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; -export type ToolCallDetailLevel = "overview" | "detailed"; +/** How much of an agent's work the chat shows between the user's message and the reply. */ +export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns"; const ThemePreferenceSchema = z.enum([ ...THEME_OPTIONS.map((option) => option.name), @@ -100,7 +101,7 @@ export interface AppSettings { /** How usage reads and which windows the sidebar summary shows. */ usage: UsagePreferences; autoExpandReasoning: boolean; - toolCallDetailLevel: ToolCallDetailLevel; + chatDisplay: ChatDisplay; chatOutlineEnabled: boolean; vimKeybindings: boolean; filePanelAssociations: Record; @@ -156,7 +157,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sidebarFooterItems: [], usage: DEFAULT_USAGE_PREFERENCES, autoExpandReasoning: false, - toolCallDetailLevel: "detailed", + chatDisplay: "collapseTurns", chatOutlineEnabled: true, vimKeybindings: false, filePanelAssociations: {}, @@ -199,6 +200,7 @@ const SidebarRowItemsSchema = z type StoredAppSettingsFallback = AppSettings & { uiFontSize?: number; compactToolCalls?: boolean; + toolCallDetailLevel?: "overview" | "detailed"; manageBuiltInDaemon?: boolean; releaseChannel?: ReleaseChannel; needsWrite: boolean; @@ -251,11 +253,13 @@ const StoredAppSettingsSchema = z sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), usage: UsagePreferencesSchema, autoExpandReasoning: z.boolean().catch(false), + chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"), + // COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24. toolCallDetailLevel: z .enum(["overview", "detailed"]) .or(z.literal("concise").transform(() => "overview" as const)) .optional() - .catch("detailed"), + .catch(undefined), // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), @@ -305,8 +309,10 @@ const StoredAppSettingsSchema = z (isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) ? "none" : DEFAULT_SIDEBAR_CHECKS_DISPLAY); - const toolCallDetailLevel = - stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed"); + const groupsToolCalls = stored.toolCallDetailLevel + ? stored.toolCallDetailLevel === "overview" + : stored.compactToolCalls === true; + const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full"); return { ...stored, openInSidePane, @@ -321,7 +327,7 @@ const StoredAppSettingsSchema = z stored.sidebarRowItems.services ?? (stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services), }, - toolCallDetailLevel, + chatDisplay, needsWrite, }; }) @@ -449,6 +455,7 @@ export function normalizeAppSettings(value: unknown): AppSettings { manageBuiltInDaemon: _manageBuiltInDaemon, releaseChannel: _releaseChannel, compactToolCalls: _compactToolCalls, + toolCallDetailLevel: _toolCallDetailLevel, uiFontSize: _uiFontSize, ...settings } = StoredAppSettingsSchema.parse(value); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c765f3b835..3ff25adf40 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2008,6 +2008,12 @@ export const ar: TranslationResources = { input: "مدخل", output: "الإخراج", }, + turnSummary: { + messages: { + one: "كتب {{count}} رسالة", + other: "كتب {{count}} رسائل", + }, + }, toolCallGroup: { editedFiles: { one: "حرّر {{count}} ملفًا", @@ -2312,12 +2318,13 @@ export const ar: TranslationResources = { label: "عرض التفكير دائماً", description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, - toolCallDetail: { - label: "عرض استدعاءات الأدوات", - description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", + chatDisplay: { + label: "عرض المحادثة", + description: "مقدار عمل الوكيل الذي تعرضه المحادثة", options: { - overview: "ملخص", - detailed: "التفاصيل الكاملة", + full: "كامل", + collapseToolCalls: "طيّ استدعاءات الأدوات", + collapseTurns: "طيّ الأدوار", }, }, language: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 351fb33ac3..1d0d1da97a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2033,6 +2033,12 @@ export const en = { input: "Input", output: "Output", }, + turnSummary: { + messages: { + one: "wrote {{count}} message", + other: "wrote {{count}} messages", + }, + }, toolCallGroup: { editedFiles: { one: "edited {{count}} file", @@ -2400,12 +2406,13 @@ export const en = { label: "Always expand reasoning", description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, - toolCallDetail: { - label: "Tool call display", - description: "How tool calls appear in the timeline", + chatDisplay: { + label: "Chat display", + description: "How much of an agent's work the chat shows", options: { - overview: "Summary", - detailed: "Full detail", + full: "Full", + collapseToolCalls: "Collapse tool calls", + collapseTurns: "Collapse turns", }, }, language: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fd195ce599..feafcd225f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2056,6 +2056,12 @@ export const es: TranslationResources = { input: "Aporte", output: "Producción", }, + turnSummary: { + messages: { + one: "escribió {{count}} mensaje", + other: "escribió {{count}} mensajes", + }, + }, toolCallGroup: { editedFiles: { one: "editó {{count}} archivo", @@ -2328,12 +2334,13 @@ export const es: TranslationResources = { description: "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, - toolCallDetail: { - label: "Visualización de llamadas a herramientas", - description: "Cómo aparecen las llamadas a herramientas en la cronología", + chatDisplay: { + label: "Visualización del chat", + description: "Cuánto del trabajo del agente muestra el chat", options: { - overview: "Resumen", - detailed: "Detalle completo", + full: "Completo", + collapseToolCalls: "Contraer llamadas a herramientas", + collapseTurns: "Contraer turnos", }, }, language: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 975d6a3736..6d8ee58400 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2055,6 +2055,12 @@ export const fr: TranslationResources = { input: "Entrée", output: "Sortie", }, + turnSummary: { + messages: { + one: "{{count}} message écrit", + other: "{{count}} messages écrits", + }, + }, toolCallGroup: { editedFiles: { one: "{{count}} fichier modifié", @@ -2326,12 +2332,13 @@ export const fr: TranslationResources = { label: "Toujours afficher le raisonnement", description: "Afficher par défaut le raisonnement de l’agent entièrement déplié", }, - toolCallDetail: { - label: "Affichage des appels d’outils", - description: "Comment les appels d’outils apparaissent dans la chronologie", + chatDisplay: { + label: "Affichage du chat", + description: "La part du travail de l’agent affichée dans le chat", options: { - overview: "Résumé", - detailed: "Détails complets", + full: "Complet", + collapseToolCalls: "Réduire les appels d’outils", + collapseTurns: "Réduire les tours", }, }, language: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 60a207e1e1..b8af6f91f6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2028,6 +2028,12 @@ export const ja: TranslationResources = { input: "入力", output: "出力", }, + turnSummary: { + messages: { + one: "{{count}}件のメッセージを送信", + other: "{{count}}件のメッセージを送信", + }, + }, toolCallGroup: { editedFiles: { one: "{{count}}個のファイルを編集", @@ -2296,12 +2302,13 @@ export const ja: TranslationResources = { label: "常に思考プロセスを展開", description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, - toolCallDetail: { - label: "ツール呼び出しの表示", - description: "タイムラインでのツール呼び出しの表示方法", + chatDisplay: { + label: "チャットの表示", + description: "チャットに表示するエージェントの作業量", options: { - overview: "要約", - detailed: "すべての詳細", + full: "すべて", + collapseToolCalls: "ツール呼び出しを折りたたむ", + collapseTurns: "ターンを折りたたむ", }, }, language: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 4773625327..1a9b36d9bd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2018,6 +2018,12 @@ export const ko: TranslationResources = { input: "입력", output: "출력", }, + turnSummary: { + messages: { + one: "{{count}} 메시지를 작성했습니다.", + other: "{{count}} 메시지를 작성했습니다.", + }, + }, toolCallGroup: { editedFiles: { one: "{{count}} 파일을 편집했습니다.", @@ -2287,12 +2293,13 @@ export const ko: TranslationResources = { label: "추론 항상 펼치기", description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.", }, - toolCallDetail: { - label: "도구 호출 표시", - description: "타임라인에 도구 호출이 표시되는 방식", + chatDisplay: { + label: "채팅 표시", + description: "채팅에 표시할 에이전트 작업의 양", options: { - overview: "요약", - detailed: "전체 세부정보", + full: "전체", + collapseToolCalls: "도구 호출 접기", + collapseTurns: "턴 접기", }, }, language: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 766c6d98ed..0d7e6acb89 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2041,6 +2041,12 @@ export const ptBR: TranslationResources = { input: "Entrada", output: "Saída", }, + turnSummary: { + messages: { + one: "escreveu {{count}} mensagem", + other: "escreveu {{count}} mensagens", + }, + }, toolCallGroup: { editedFiles: { one: "editou {{count}} arquivo", @@ -2311,12 +2317,13 @@ export const ptBR: TranslationResources = { description: "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, - toolCallDetail: { - label: "Exibição de chamadas de ferramentas", - description: "Como as chamadas de ferramentas aparecem na linha do tempo", + chatDisplay: { + label: "Exibição do chat", + description: "Quanto do trabalho do agente o chat mostra", options: { - overview: "Resumo", - detailed: "Detalhes completos", + full: "Completo", + collapseToolCalls: "Recolher chamadas de ferramentas", + collapseTurns: "Recolher turnos", }, }, language: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 23f3d810ee..09d46fa075 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2041,6 +2041,12 @@ export const ru: TranslationResources = { input: "Входные данные", output: "Выходные данные", }, + turnSummary: { + messages: { + one: "написано {{count}} сообщение", + other: "написаны сообщения ({{count}})", + }, + }, toolCallGroup: { editedFiles: { one: "изменён {{count}} файл", @@ -2313,12 +2319,13 @@ export const ru: TranslationResources = { description: "По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента", }, - toolCallDetail: { - label: "Отображение вызовов инструментов", - description: "Как вызовы инструментов отображаются на временной шкале", + chatDisplay: { + label: "Отображение чата", + description: "Какую часть работы агента показывает чат", options: { - overview: "Сводка", - detailed: "Все подробности", + full: "Полностью", + collapseToolCalls: "Сворачивать вызовы инструментов", + collapseTurns: "Сворачивать ходы", }, }, language: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 43d8ceeef2..374bef6e88 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1986,6 +1986,12 @@ export const zhCN: TranslationResources = { input: "输入", output: "输出", }, + turnSummary: { + messages: { + one: "写了 {{count}} 条消息", + other: "写了 {{count}} 条消息", + }, + }, toolCallGroup: { editedFiles: { one: "编辑了 {{count}} 个文件", @@ -2251,12 +2257,13 @@ export const zhCN: TranslationResources = { label: "始终展开推理过程", description: "默认情况下完全展开 AI 的思考和推理过程", }, - toolCallDetail: { - label: "工具调用显示", - description: "工具调用在时间线中的显示方式", + chatDisplay: { + label: "聊天显示", + description: "聊天中显示多少智能体的工作", options: { - overview: "摘要", - detailed: "完整详情", + full: "完整", + collapseToolCalls: "折叠工具调用", + collapseTurns: "折叠轮次", }, }, language: { diff --git a/packages/app/src/screens/settings/chat/chat-section.tsx b/packages/app/src/screens/settings/chat/chat-section.tsx index 8a81d30073..4f4254cb8e 100644 --- a/packages/app/src/screens/settings/chat/chat-section.tsx +++ b/packages/app/src/screens/settings/chat/chat-section.tsx @@ -8,29 +8,25 @@ import { SettingsSwitch, } from "@/components/settings"; import { isNative } from "@/constants/platform"; -import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings"; -const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "detailed", - "overview", -]; +const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"]; export function ChatSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); - const toolCallDetailOptions = useMemo( + const chatDisplayOptions = useMemo( () => - TOOL_CALL_DETAIL_LEVELS.map((value) => ({ + CHAT_DISPLAYS.map((value) => ({ value, - label: t(`settings.general.toolCallDetail.options.${value}`), + label: t(`settings.general.chatDisplay.options.${value}`), })), [t], ); - const changeToolCallDetailLevel = useCallback( - (toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => - void updateSettings({ toolCallDetailLevel }), + const changeChatDisplay = useCallback( + (chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }), [updateSettings], ); const changeAutoExpandReasoning = useCallback( @@ -53,11 +49,11 @@ export function ChatSection() { onValueChange={changeAutoExpandReasoning} /> {isNative ? null : ( 2) { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +/** + * One sentence of what a stretch of agent work did, such as "Edited 2 files and ran 3 commands". + * Leading parts come first, for summaries that count more than tool calls. + */ +export function useOverviewSummaryLabel( + summary: OverviewSummary, + leadingParts: readonly string[] = [], +): string { + const { t } = useTranslation(); + return useMemo(() => { + const parts = [...leadingParts]; + const entries = [ + [summary.editedFileCount, "toolCallGroup.editedFiles"], + [summary.commandCount, "toolCallGroup.commands"], + [summary.readFileCount, "toolCallGroup.readFiles"], + [summary.searchCount, "toolCallGroup.searches"], + [summary.otherToolCount, "toolCallGroup.otherTools"], + [summary.paseoCallCount, "toolCallGroup.paseoCalls"], + ] as const; + for (const [count, key] of entries) { + if (count > 0) { + parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); + } + } + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [leadingParts, summary, t]); +} diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index f41790461c..0fb638c012 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -1,4 +1,5 @@ import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import type { ToolCallItem } from "@/types/stream"; import { describeToolCall, type ToolCallRun } from "../grouping"; const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; @@ -28,19 +29,18 @@ function isSearchCall(name: string): boolean { return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); } -export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { +/** Counts tool calls by what they did, for any summary of a stretch of agent work. */ +export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary { const editedFiles = new Set(); const readFiles = new Set(); - let isLoading = false; let commandCount = 0; let searchCount = 0; let otherToolCount = 0; let paseoCallCount = 0; - for (const call of run.calls) { + for (const call of calls) { const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; if (isPaseoCall(descriptor.name, normalizedName)) { paseoCallCount += 1; } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { @@ -56,7 +56,7 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { } } - const summary = { + return { editedFileCount: editedFiles.size, commandCount, readFileCount: readFiles.size, @@ -64,10 +64,17 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { otherToolCount, paseoCallCount, }; +} + +export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { + const isLoading = run.calls.some((call) => { + const status = describeToolCall(call).status; + return status === "running" || status === "executing"; + }); return { mode: "overview", run, isLoading, - summary, + summary: summarizeToolCalls(run.calls), }; } diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index 326cc172b2..b11e82e4f7 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -1,11 +1,12 @@ -import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import React, { memo, useCallback, useRef, type ReactNode } from "react"; import { ScrollView } from "react-native"; -import { useTranslation } from "react-i18next"; import { Wrench } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; +import { IndentRail } from "@/components/indent-rail"; import { ExpandableBadge } from "@/components/message"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { type OverviewSummary, type OverviewToolCallGroup } from "./model"; +import { useOverviewSummaryLabel } from "./label"; +import type { OverviewToolCallGroup } from "./model"; import { OverviewToolCallGroupSheet } from "./sheet"; interface OverviewGroupProps { @@ -18,41 +19,6 @@ interface OverviewGroupProps { const TOOL_CALL_GROUP_MAX_HEIGHT = 400; -function joinSummaryParts(parts: string[], conjunction: string): string { - if (parts.length === 0) { - return ""; - } - let joined = parts[0] ?? ""; - if (parts.length === 2) { - joined = `${parts[0]} ${conjunction} ${parts[1]}`; - } else if (parts.length > 2) { - joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; - } - const firstCharacter = joined[0]; - return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; -} - -function useOverviewSummary(summary: OverviewSummary): string { - const { t } = useTranslation(); - return useMemo(() => { - const parts: string[] = []; - const entries = [ - [summary.editedFileCount, "toolCallGroup.editedFiles"], - [summary.commandCount, "toolCallGroup.commands"], - [summary.readFileCount, "toolCallGroup.readFiles"], - [summary.searchCount, "toolCallGroup.searches"], - [summary.otherToolCount, "toolCallGroup.otherTools"], - [summary.paseoCallCount, "toolCallGroup.paseoCalls"], - ] as const; - for (const [count, key] of entries) { - if (count > 0) { - parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); - } - } - return joinSummaryParts(parts, t("toolCallGroup.and")); - }, [summary, t]); -} - export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ group, expanded, @@ -62,7 +28,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView }: OverviewGroupProps) { const scrollRef = useRef(null); const isCompact = useIsCompactFormFactor(); - const aggregateSummary = useOverviewSummary(group.summary); + const aggregateSummary = useOverviewSummaryLabel(group.summary); const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -82,7 +48,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView showsVerticalScrollIndicator onContentSizeChange={scrollToLatest} > - {children} + {children} ), [children, scrollToLatest], diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 5dc1067914..9d57391b2e 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -1,5 +1,4 @@ import type { StreamItem } from "@/types/stream"; -import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; import { groupLiveToolCalls, prepareGroupedHistory, @@ -8,7 +7,8 @@ import { } from "./grouping"; import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model"; -export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +/** Whether consecutive tool calls show one by one or as one summary row. */ +export type ToolCallDetailLevel = "overview" | "detailed"; export type ToolCallDetailGroup = OverviewToolCallGroup; export interface PreparedToolCallHistory { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 9425cabbed..657d218b35 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -771,6 +771,12 @@ export type AgentToolCallItem = ToolCallItem & { payload: { source: "agent"; data: AgentToolCallData }; }; +/** Find and scrolling address a plugin row by the source it replaces. */ +export function getStreamItemMessageId(item: StreamItem): string { + if (item.kind === "plugin") return item.sourceId ?? item.id; + return item.id; +} + export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem { return item.kind === "tool_call" && item.payload.source === "agent"; }