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"; }