From d6d04b0c56cff5153a416d02932ba92ac5148c03 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Oct 2026 16:34:58 +0700 Subject: [PATCH] Show a lone tool call as itself and group from the second call (#8109) * fix(app): show a lone tool call as itself and group from the second call A run of one tool call or thought renders as the call. When a second call arrives the row becomes a group that keeps the first call's id, so the row key stays put. Tool call and group open state move into a per-agent store: opening a lone call also opens its future group without notifying the timeline, so a click re-renders only that tool call. * test(app): cover lone tool calls turning into groups Desktop journeys check that an opened lone call keeps its details open inside the new group, that clicking it renders no timeline component, and that the group header lands where the call was. Compact covers the call sheet and a closed group. (cherry picked from commit eee70e7fef6f4ba9cdf7623fec4cf6c43260c89b) --- .../browser/tool-call-overview-sheet.spec.ts | 185 ++++++++++++++---- .../app/e2e/browser/tool-call-shimmer.spec.ts | 127 +----------- .../app/e2e/support/helpers/react-renders.ts | 78 ++++++++ .../app/e2e/support/helpers/tool-call-gate.ts | 125 ++++++++++++ packages/app/src/agent-stream/view.tsx | 109 ++++++----- packages/app/src/components/message.tsx | 17 +- .../src/tool-calls/detail-level/grouping.ts | 17 +- .../detail-level/overview/sheet.tsx | 13 +- .../detail-level/projection.test.ts | 80 +++++--- .../app/src/tool-calls/expansion-context.tsx | 69 +++++++ packages/app/src/tool-calls/expansion.test.ts | 79 ++++++++ packages/app/src/tool-calls/expansion.ts | 67 +++++++ 12 files changed, 716 insertions(+), 250 deletions(-) create mode 100644 packages/app/e2e/support/helpers/react-renders.ts create mode 100644 packages/app/e2e/support/helpers/tool-call-gate.ts create mode 100644 packages/app/src/tool-calls/expansion-context.tsx create mode 100644 packages/app/src/tool-calls/expansion.test.ts create mode 100644 packages/app/src/tool-calls/expansion.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 42831fad8a..0b3a068e63 100644 --- a/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts +++ b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts @@ -1,8 +1,15 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../support/fixtures"; +import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor"; +import { gateSecondToolCall } from "../support/helpers/tool-call-gate"; import { expectComposerVisible } from "../support/helpers/composer"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + readReactRenders, + recordReactRenders, + resetReactRenders, +} from "../support/helpers/react-renders"; type WebSocketMessage = string | Buffer; @@ -115,23 +122,27 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string }); }); + function appendCompletedCalls(extraCompletedCalls: number) { + if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) { + throw new Error("Expected the first completed tool call before release"); + } + for (let index = 0; index < extraCompletedCalls; index += 1) { + sendToPage?.( + cloneToolCallWithId( + firstCompletedMessage as WebSocketMessage, + `${firstCallId}:${sequenceOffset + index}`, + sequenceOffset + index + 1, + ), + ); + } + sequenceOffset += extraCompletedCalls; + } return { waitForFirstCompleted: () => firstCompleted, + appendCompletedCalls, release(extraCompletedCalls = 0) { isHolding = false; - if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) { - throw new Error("Expected the first completed tool call before release"); - } - for (let index = 0; index < extraCompletedCalls; index += 1) { - sendToPage?.( - cloneToolCallWithId( - firstCompletedMessage as WebSocketMessage, - `${firstCallId}:${index}`, - index + 1, - ), - ); - } - sequenceOffset = extraCompletedCalls; + appendCompletedCalls(extraCompletedCalls); for (const message of heldMessages.splice(0)) { sendToPage?.(shiftTimelineSequence(message, sequenceOffset)); } @@ -139,17 +150,24 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string }; } -async function configureOverviewToolCalls(page: Page): Promise { - await page.addInitScript(() => { +type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns"; + +// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone. +async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) { + await page.addInitScript((display) => { localStorage.setItem( "@paseo:app-settings", - JSON.stringify({ chatDisplay: "collapseToolCalls" }), + JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }), ); - }); + }, chatDisplay); } -async function createOverviewAgent(page: Page, title: string) { - await configureOverviewToolCalls(page); +async function createOverviewAgent( + page: Page, + title: string, + chatDisplay: OverviewChatDisplay = "collapseToolCalls", +) { + await configureOverviewToolCalls(page, chatDisplay); return seedMockAgentWorkspace({ repoPrefix: "tool-call-overview-sheet-", title, @@ -204,6 +222,36 @@ function readScrollMetrics(root: HTMLElement) { }; } +const FIRST_CALL_DETAILS = "export function ConversationList()"; + +function firstToolCall(page: Page): Locator { + return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first(); +} + +async function openToolDetails(badge: Locator): Promise { + await badge.getByRole("button").first().click(); +} + +async function timelineRowOf(locator: Locator) { + const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle(); + if (!row) throw new Error("Expected the locator inside a timeline row"); + return row; +} + +/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */ +async function isInsideRow( + locator: Locator, + row: Awaited>, +): Promise { + return locator.evaluate((node, previousRow) => previousRow.contains(node), row); +} + +async function topOf(row: Locator): Promise { + const box = await row.boundingBox(); + if (!box) throw new Error("Expected a visible timeline row"); + return box.y; +} + test.describe("compact overview tool calls", () => { test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true }); @@ -219,8 +267,16 @@ test.describe("compact overview tool calls", () => { await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet."); await gate.waitForFirstCompleted(); + const single = firstToolCall(page); + await expect(single).toBeVisible(); + await expect(page.getByTestId("tool-call-group")).toHaveCount(0); + await openToolDetails(single); + await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible(); + await page.getByTestId("tool-call-sheet-close").click(); + gate.appendCompletedCalls(1); const group = page.getByTestId("tool-call-group").first(); await expect(group).toBeVisible(); + await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden(); await group.click(); const sheet = page.getByTestId("tool-call-group-sheet"); @@ -261,27 +317,86 @@ test.describe("compact overview tool calls", () => { }); }); -test("keeps overview tool calls inline on desktop", async ({ page }) => { +for (const display of ["collapseToolCalls", "collapseTurns"] as const) { + test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({ + page, + }) => { + test.setTimeout(120_000); + const agent = await createOverviewAgent(page, "Desktop overview tool calls", display); + await recordReactRenders(page); + try { + const gate = await gateSecondToolCall(page, agent.agentId); + await openOverviewAgent(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls."); + await gate.waitForFirstCompleted(); + const lone = firstToolCall(page); + const group = page.getByTestId("tool-call-group").first(); + const details = page.getByText(FIRST_CALL_DETAILS); + + await test.step("a lone tool call shows as itself", async () => { + await expect(lone).toBeVisible(); + await expect(group).toHaveCount(0); + }); + + await gate.waitForSecondRunning(); + await test.step("opening it re-renders that tool call and nothing else", async () => { + await resetReactRenders(page); + await openToolDetails(lone); + await expect(details).toBeVisible(); + const renders = await readReactRenders(page); + expect(renders.AgentStreamView ?? 0).toBe(0); + expect(renders.StreamRowContent ?? 0).toBe(0); + expect(renders.ToolCall).toBe(1); + }); + + await test.step("the second call forms an open group around the opened call", async () => { + // Reading above the tail, the row holds still; at the tail the new rows push it up. + await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 }); + const loneTop = await topOf(lone); + const loneRow = await timelineRowOf(lone); + await resetReactRenders(page); + gate.releaseSecondRunning(); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(2); + await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible(); + expect(await isInsideRow(group, loneRow)).toBe(true); + expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2); + expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0); + await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0); + }); + + await test.step("closing and reopening the group keeps the call open", async () => { + await openToolDetails(group); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(0); + await expect(details).toHaveCount(0); + await openToolDetails(group); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(2); + await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible(); + }); + } finally { + await agent.cleanup(); + } + }); +} + +test("forms a closed group when the lone tool call was left closed", async ({ page }) => { test.setTimeout(120_000); - const agent = await createOverviewAgent(page, "Desktop overview tool calls"); - + const agent = await createOverviewAgent(page, "Closed desktop overview tool calls"); try { - const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId); + const gate = await gateSecondToolCall(page, agent.agentId); await openOverviewAgent(page, agent); - await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls."); + await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed."); await gate.waitForFirstCompleted(); + const lone = firstToolCall(page); const group = page.getByTestId("tool-call-group").first(); + await expect(lone).toBeVisible(); + await expect(group).toHaveCount(0); + + await gate.waitForSecondRunning(); + const loneTop = await topOf(lone); + gate.releaseSecondRunning(); await expect(group).toBeVisible(); - - // Keep streaming layout changes out of the pointer-down/pointer-up interval. - const header = group.getByRole("button").first(); - await header.click(); - await expect(group.getByTestId("tool-call-badge").first()).toBeVisible(); - await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0); - - gate.release(1); - await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2); - await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0); + await expect(group.getByTestId("tool-call-badge")).toHaveCount(0); + expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/browser/tool-call-shimmer.spec.ts b/packages/app/e2e/browser/tool-call-shimmer.spec.ts index 40dde0aa99..c37255d35e 100644 --- a/packages/app/e2e/browser/tool-call-shimmer.spec.ts +++ b/packages/app/e2e/browser/tool-call-shimmer.spec.ts @@ -1,11 +1,9 @@ -import type { Locator, Page } from "@playwright/test"; +import type { Locator } from "@playwright/test"; import { test, expect } from "../support/fixtures"; -import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { gateSecondToolCall } from "../support/helpers/tool-call-gate"; import { expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -type WebSocketMessage = string | Buffer; - interface ShimmerEvidence { animationDuration: string; endPx: number; @@ -14,127 +12,6 @@ interface ShimmerEvidence { startPx: number; } -function parseSessionMessage(message: WebSocketMessage): Record | null { - const raw = typeof message === "string" ? message : message.toString("utf8"); - try { - const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; - return envelope.type === "session" && envelope.message && typeof envelope.message === "object" - ? (envelope.message as Record) - : null; - } catch { - return null; - } -} - -function getToolCallStatus( - message: WebSocketMessage, - agentId: string, -): { callId: string; status: string } | null { - const sessionMessage = parseSessionMessage(message); - if (sessionMessage?.type !== "agent_stream") { - return null; - } - const payload = sessionMessage.payload as Record | undefined; - if (payload?.agentId !== agentId) { - return null; - } - const event = payload.event as Record | undefined; - const item = event?.item as Record | undefined; - return event?.type === "timeline" && - item?.type === "tool_call" && - typeof item.callId === "string" && - typeof item.status === "string" - ? { callId: item.callId, status: item.status } - : null; -} - -function replaceToolCallStatus(message: WebSocketMessage, status: string): string { - const raw = typeof message === "string" ? message : message.toString("utf8"); - const envelope = JSON.parse(raw) as { - message?: { payload?: { event?: { item?: { status?: string } } } }; - }; - const item = envelope.message?.payload?.event?.item; - if (!item) { - throw new Error("Expected a tool-call session message"); - } - item.status = status; - return JSON.stringify(envelope); -} - -async function gateSecondToolCall(page: Page, agentId: string) { - let firstCallId: string | null = null; - let secondCallId: string | null = null; - let secondRunningMessage: WebSocketMessage | null = null; - let pauseAfterSecondRunning = false; - let releaseSecondRequested = false; - let forwardSecondRunning: (() => void) | null = null; - let resolveFirstCompleted!: () => void; - let resolveSecondRunning!: () => void; - const firstCompleted = new Promise((resolve) => { - resolveFirstCompleted = resolve; - }); - const secondRunning = new Promise((resolve) => { - resolveSecondRunning = resolve; - }); - - await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { - const server = ws.connectToServer(); - forwardSecondRunning = () => { - if (secondRunningMessage) { - ws.send(secondRunningMessage); - secondRunningMessage = null; - } - }; - ws.onMessage((message) => server.send(message)); - server.onMessage((message) => { - if (pauseAfterSecondRunning) { - return; - } - - const toolCall = getToolCallStatus(message, agentId); - if (!toolCall) { - ws.send(message); - return; - } - - if (!firstCallId) { - firstCallId = toolCall.callId; - } - if (toolCall.callId === firstCallId) { - ws.send(message); - if (toolCall.status === "completed") { - resolveFirstCompleted(); - } - return; - } - - secondCallId ??= toolCall.callId; - if (toolCall.callId === secondCallId) { - const transformedMessage = - toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message; - secondRunningMessage = transformedMessage; - pauseAfterSecondRunning = true; - resolveSecondRunning(); - if (releaseSecondRequested) { - forwardSecondRunning?.(); - } - return; - } - - ws.send(message); - }); - }); - - return { - waitForFirstCompleted: () => firstCompleted, - waitForSecondRunning: () => secondRunning, - releaseSecondRunning() { - releaseSecondRequested = true; - forwardSecondRunning?.(); - }, - }; -} - async function readShimmerEvidence(locator: Locator): Promise { return locator.evaluate((root) => { const shimmer = Array.from(root.querySelectorAll("*")).find((element) => diff --git a/packages/app/e2e/support/helpers/react-renders.ts b/packages/app/e2e/support/helpers/react-renders.ts new file mode 100644 index 0000000000..5e9752cbfa --- /dev/null +++ b/packages/app/e2e/support/helpers/react-renders.ts @@ -0,0 +1,78 @@ +import type { Page } from "@playwright/test"; + +declare global { + var __PASEO_E2E_REACT_RENDERS__: Record | undefined; +} + +/** + * Counts which components render in each React commit, by name, the way React DevTools + * highlights updates: a fiber rendered when React performed work on it in that commit. + * Install before the app loads; React registers with the DevTools hook at startup. + */ +export async function recordReactRenders(page: Page): Promise { + await page.addInitScript(() => { + const PERFORMED_WORK = 1; + interface Fiber { + type: unknown; + flags: number; + child: Fiber | null; + sibling: Fiber | null; + alternate: Fiber | null; + } + const nameOf = (type: unknown): string | null => { + if (typeof type === "function") { + return (type as { displayName?: string }).displayName || type.name || null; + } + if (!type || typeof type !== "object") return null; + const component = type as { displayName?: string; render?: unknown; type?: unknown }; + return component.displayName || nameOf(component.render) || nameOf(component.type); + }; + const record = (root: Fiber) => { + const renders = (globalThis.__PASEO_E2E_REACT_RENDERS__ ??= {}); + const pending: Fiber[] = [root]; + while (pending.length > 0) { + const fiber = pending.pop()!; + const name = typeof fiber.type === "string" ? null : nameOf(fiber.type); + if (name && (fiber.flags & PERFORMED_WORK) !== 0) renders[name] = (renders[name] ?? 0) + 1; + // A subtree React bailed out of is shared with the previous commit; skip it. + if (fiber.alternate && fiber.child === fiber.alternate.child) continue; + for (let child = fiber.child; child; child = child.sibling) pending.push(child); + } + }; + let rendererCount = 0; + Object.defineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", { + configurable: true, + value: { + supportsFiber: true, + isDisabled: false, + renderers: new Map(), + inject(renderer: unknown) { + rendererCount += 1; + this.renderers.set(rendererCount, renderer); + return rendererCount; + }, + checkDCE() {}, + onScheduleFiberRoot() {}, + onCommitFiberRoot(_rendererId: number, root: { current: Fiber }) { + record(root.current); + }, + onPostCommitFiberRoot() {}, + onCommitFiberUnmount() {}, + }, + }); + }); +} + +export async function resetReactRenders(page: Page): Promise { + await page.evaluate(() => { + globalThis.__PASEO_E2E_REACT_RENDERS__ = {}; + }); +} + +/** Render counts per component name since the last reset, read after the next painted frame. */ +export async function readReactRenders(page: Page): Promise> { + return page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ }; + }); +} diff --git a/packages/app/e2e/support/helpers/tool-call-gate.ts b/packages/app/e2e/support/helpers/tool-call-gate.ts new file mode 100644 index 0000000000..5fd823eb27 --- /dev/null +++ b/packages/app/e2e/support/helpers/tool-call-gate.ts @@ -0,0 +1,125 @@ +import type { Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +function parseSessionMessage(message: WebSocketMessage): Record | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; + return envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? (envelope.message as Record) + : null; + } catch { + return null; + } +} + +function getToolCallStatus( + message: WebSocketMessage, + agentId: string, +): { callId: string; status: string } | null { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type !== "agent_stream") { + return null; + } + const payload = sessionMessage.payload as Record | undefined; + if (payload?.agentId !== agentId) { + return null; + } + const event = payload.event as Record | undefined; + const item = event?.item as Record | undefined; + return event?.type === "timeline" && + item?.type === "tool_call" && + typeof item.callId === "string" && + typeof item.status === "string" + ? { callId: item.callId, status: item.status } + : null; +} + +function replaceToolCallStatus(message: WebSocketMessage, status: string): string { + const raw = typeof message === "string" ? message : message.toString("utf8"); + const envelope = JSON.parse(raw) as { + message?: { payload?: { event?: { item?: { status?: string } } } }; + }; + const item = envelope.message?.payload?.event?.item; + if (!item) { + throw new Error("Expected a tool-call session message"); + } + item.status = status; + return JSON.stringify(envelope); +} + +export async function gateSecondToolCall(page: Page, agentId: string) { + let firstCallId: string | null = null; + let secondCallId: string | null = null; + let secondRunningMessage: WebSocketMessage | null = null; + let pauseAfterSecondRunning = false; + let releaseSecondRequested = false; + let forwardSecondRunning: (() => void) | null = null; + let resolveFirstCompleted!: () => void; + let resolveSecondRunning!: () => void; + const firstCompleted = new Promise((resolve) => { + resolveFirstCompleted = resolve; + }); + const secondRunning = new Promise((resolve) => { + resolveSecondRunning = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + forwardSecondRunning = () => { + if (secondRunningMessage) { + ws.send(secondRunningMessage); + secondRunningMessage = null; + } + }; + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (pauseAfterSecondRunning) { + return; + } + + const toolCall = getToolCallStatus(message, agentId); + if (!toolCall) { + ws.send(message); + return; + } + + if (!firstCallId) { + firstCallId = toolCall.callId; + } + if (toolCall.callId === firstCallId) { + ws.send(message); + if (toolCall.status === "completed") { + resolveFirstCompleted(); + } + return; + } + + secondCallId ??= toolCall.callId; + if (toolCall.callId === secondCallId) { + const transformedMessage = + toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message; + secondRunningMessage = transformedMessage; + pauseAfterSecondRunning = true; + resolveSecondRunning(); + if (releaseSecondRequested) { + forwardSecondRunning?.(); + } + return; + } + + ws.send(message); + }); + }); + + return { + waitForFirstCompleted: () => firstCompleted, + waitForSecondRunning: () => secondRunning, + releaseSecondRunning() { + releaseSecondRequested = true; + forwardSecondRunning?.(); + }, + }; +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 90c88fb0d1..cb9999745b 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -61,6 +61,11 @@ import { returnToTimelineTail } from "./timeline-tail-navigation"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { ToolCallDetailsContent } from "@/components/tool-call-details"; import { QuestionFormCard } from "@/components/question-form-card"; +import { + ToolCallExpansionContext, + useAgentToolCallExpansion, + useExpandedToolCallGroups, +} from "@/tool-calls/expansion-context"; import { ToolCallSheetProvider } from "@/components/tool-call-sheet"; import { createStreamPresentation } from "./presentation"; import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view"; @@ -364,9 +369,8 @@ const AgentStreamViewComponent = forwardRef>( new Set(), ); - const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState>( - new Set(), - ); + const toolCallExpansion = useAgentToolCallExpansion(agentId); + const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion); const [expandedTurnHostIds, setExpandedTurnHostIds] = useState>(new Set()); // Get serverId (fallback to agent's serverId if not provided) @@ -431,7 +435,6 @@ const AgentStreamViewComponent = forwardRef { setIsNearBottom(true); setExpandedInlineToolCallIds(new Set()); - setExpandedToolCallGroupIds(new Set()); setExpandedTurnHostIds(new Set()); }, [agentId]); @@ -708,17 +711,7 @@ const AgentStreamViewComponent = forwardRef { - setExpandedToolCallGroupIds((previous) => { - const next = new Set(previous); - if (expanded) { - next.add(groupId); - } else { - next.delete(groupId); - } - return next; - }); - }, []); + const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded; const renderUserMessageItem = useCallback( (layoutItem: StreamLayoutItem, item: Extract) => { @@ -815,7 +808,7 @@ const AgentStreamViewComponent = forwardRef, isLastInSequence: boolean, - maxDetailHeight?: number, + options: { maxDetailHeight?: number; standalone?: boolean } = {}, ) => { const { payload } = item; @@ -847,7 +840,8 @@ const AgentStreamViewComponent = forwardRef ); } @@ -863,7 +857,8 @@ const AgentStreamViewComponent = forwardRef ); }, @@ -886,7 +881,7 @@ const AgentStreamViewComponent = forwardRef ) : ( - renderSingleToolCallItem( - call, - index === group.run.calls.length - 1, - GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, - ) + renderSingleToolCallItem(call, index === group.run.calls.length - 1, { + maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT, + standalone: false, + }) )} )) @@ -922,7 +917,7 @@ const AgentStreamViewComponent = forwardRef ({ contentById: presentation.historyGroupUpdatesByHostId, displayStateById: { + // An opened lone call also marks its future group open; that row stays unrevised + // until a group actually forms there. has: (id: string) => - expandedToolCallGroupIds.has(id) || + (expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) || expandedTurnHostIds.has(id) || nestedItemIds.has(id), }, globalDisplayState: isMobile, }), [ - expandedToolCallGroupIds, + expandedToolCallGroups, + getToolCallGroup, expandedTurnHostIds, isMobile, nestedItemIds, @@ -1207,30 +1205,32 @@ const AgentStreamViewComponent = forwardRef - {streamRenderStrategy.render({ - readingRowReplacements: presentation.readingRowReplacements, - agentId, - segments: renderModel.segments, - historyRowRevision, - liveHeadRowRevision: expandedToolCallGroupIds, - boundary, - renderers, - listEmptyComponent, - viewportRef, - routeBottomAnchorRequest, - isAuthoritativeHistoryReady, - onNearBottomChange: setIsNearBottom, - onReadingPositionChange: handleReadingPositionChange, - onNearHistoryStart: loadOlder, - isLoadingOlderHistory: isLoadingOlder, - hasOlderHistory: hasOlder, - olderHistoryProgressKey: progressKey, - scrollEnabled: streamScrollEnabled, - listStyle: stylesheet.list, - baseListContentContainerStyle: stylesheet.listContentContainer, - forwardListContentContainerStyle: stylesheet.forwardListContentContainer, - contentMaxWidth, - })} + + {streamRenderStrategy.render({ + readingRowReplacements: presentation.readingRowReplacements, + agentId, + segments: renderModel.segments, + historyRowRevision, + liveHeadRowRevision: expandedToolCallGroups, + boundary, + renderers, + listEmptyComponent, + viewportRef, + routeBottomAnchorRequest, + isAuthoritativeHistoryReady, + onNearBottomChange: setIsNearBottom, + onReadingPositionChange: handleReadingPositionChange, + onNearHistoryStart: loadOlder, + isLoadingOlderHistory: isLoadingOlder, + hasOlderHistory: hasOlder, + olderHistoryProgressKey: progressKey, + scrollEnabled: streamScrollEnabled, + listStyle: stylesheet.list, + baseListContentContainerStyle: stylesheet.listContentContainer, + forwardListContentContainerStyle: stylesheet.forwardListContentContainer, + contentMaxWidth, + })} + ["status"]; isLastInSequence: boolean; defaultExpanded: boolean; + standalone?: boolean; } // Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text. @@ -1413,6 +1414,7 @@ function ThoughtSlot({ status, isLastInSequence, defaultExpanded, + standalone, }: ThoughtSlotProps) { const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming"); return ( @@ -1424,6 +1426,7 @@ function ThoughtSlot({ status={status === "ready" ? "completed" : "executing"} isLastInSequence={isLastInSequence} defaultExpanded={defaultExpanded} + standalone={standalone} forceInline={defaultExpanded} /> ); @@ -1438,7 +1441,9 @@ function ToolCallSlot({ (expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded), [onInlineDetailsExpandedChangeByItemId, itemId], ); - return ; + return ( + + ); } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index a075b97c92..2c6165d305 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -27,6 +27,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { useToolCallExpanded } from "@/tool-calls/expansion-context"; import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; @@ -2241,6 +2242,9 @@ interface ToolCallProps { defaultExpanded?: boolean; forceInline?: boolean; maxDetailHeight?: number; + itemId: string; + /** Shown as its own row rather than inside a tool call group. */ + standalone?: boolean; } export const ToolCall = memo(function ToolCall({ @@ -2262,9 +2266,15 @@ export const ToolCall = memo(function ToolCall({ defaultExpanded, forceInline = false, maxDetailHeight = 400, + itemId, + standalone = true, }: ToolCallProps) { const { openToolCall } = useToolCallSheet(); - const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false); + const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({ + callId: itemId, + defaultExpanded: defaultExpanded ?? false, + standalone, + }); const isMobile = useIsCompactFormFactor(); const shouldRenderInline = !isMobile || forceInline; @@ -2319,10 +2329,11 @@ export const ToolCall = memo(function ToolCall({ showLoadingSkeleton: presentation.isLoadingDetails, }); } else { - setIsExpanded((prev) => !prev); + toggleExpanded(); } }, [ shouldRenderInline, + toggleExpanded, openToolCall, toolName, presentation.detail, @@ -2428,5 +2439,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.defaultExpanded !== next.defaultExpanded) return false; if (previous.forceInline !== next.forceInline) return false; if (previous.maxDetailHeight !== next.maxDetailHeight) return false; + if (previous.itemId !== next.itemId) return false; + if (previous.standalone !== next.standalone) return false; return true; } diff --git a/packages/app/src/tool-calls/detail-level/grouping.ts b/packages/app/src/tool-calls/detail-level/grouping.ts index 5713a06de3..aeb0d2ed32 100644 --- a/packages/app/src/tool-calls/detail-level/grouping.ts +++ b/packages/app/src/tool-calls/detail-level/grouping.ts @@ -112,7 +112,7 @@ function appendRun(input: { const run = createRun(input.calls, input.isSealed); const host = createHost(run); input.output.push(host); - input.groups.set(host.id, input.buildGroup(run)); + if (run.calls.length > 1) input.groups.set(host.id, input.buildGroup(run)); } export function prepareGroupedHistory(input: { @@ -180,7 +180,8 @@ export function groupLiveToolCalls(input: { if (hostPlacement === "head") { head.push(createHost(run)); } - if (hostPlacement === "head" || pendingIncludesHead || !isSealed) { + const needsLiveGroup = hostPlacement === "head" || pendingIncludesHead || !isSealed; + if (run.calls.length > 1 && needsLiveGroup) { liveGroups.set(run.id, input.buildGroup(run)); } pending = []; @@ -217,19 +218,13 @@ export function groupLiveToolCalls(input: { historyGroupUpdatesByHostId: EMPTY_GROUPS, }; } - if (input.history.groupsByHostId.size === 0) { - return { - tail: input.history.tail, - head, - groupsByHostId: liveGroups, - historyGroupUpdatesByHostId: EMPTY_GROUPS, - }; - } const groupsByHostId = new Map(input.history.groupsByHostId); let historyGroupUpdatesByHostId: Map | null = null; for (const [id, group] of liveGroups) { groupsByHostId.set(id, group); - if (input.history.groupsByHostId.has(id)) { + const isHistoryHost = + input.history.groupsByHostId.has(id) || input.history.pendingCalls[0]?.id === id; + if (isHistoryHost) { historyGroupUpdatesByHostId ??= new Map(); historyGroupUpdatesByHostId.set(id, group); } diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index b3b5bf39eb..2888f1f424 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -4,8 +4,12 @@ import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorho import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { useIsolatedBottomSheetVisibility } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + type ContextBridge, + useIsolatedBottomSheetVisibility, +} from "@/components/ui/isolated-bottom-sheet-modal"; import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet"; +import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context"; import type { Theme } from "@/styles/theme"; interface OverviewToolCallGroupSheetProps { @@ -29,7 +33,12 @@ export function OverviewToolCallGroupSheet({ }: OverviewToolCallGroupSheetProps) { const { t } = useTranslation(); const scrollRef = useRef(null); - const contextBridge = useToolCallSheetContextBridge(); + const sheetContextBridge = useToolCallSheetContextBridge(); + const expansionContextBridge = useToolCallExpansionContextBridge(); + const contextBridge = useCallback( + (content) => sheetContextBridge(expansionContextBridge(content)), + [expansionContextBridge, sheetContextBridge], + ); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible, onClose, diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index d6233854a2..5c4b4a900b 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -110,11 +110,7 @@ describe("tool call detail-level projection", () => { preparedHistory: prepared, }); expect(single.head).toEqual([firstCall]); - expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({ - calls: [firstCall], - latest: firstCall, - isSealed: false, - }); + expect(single.groupsByHostId.size).toBe(0); const grouped = project({ level: "overview", @@ -142,18 +138,11 @@ describe("tool call detail-level projection", () => { expect(result.groupsByHostId.get("1")?.isLoading).toBe(true); }); - it("builds a loading aggregate for a one-call run", () => { + it("leaves a running lone call visible without an aggregate", () => { const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" }); const result = project({ level: "overview", head: [call], isTurnActive: true }); - const group = result.groupsByHostId.get(call.id); - if (!group) { - throw new Error("Expected an overview group"); - } - - expect(group).toMatchObject({ - isLoading: true, - summary: { commandCount: 1 }, - }); + expect(result.head).toEqual([call]); + expect(result.groupsByHostId.size).toBe(0); }); it("keeps an active overview group on its latest call until a visible boundary arrives", () => { @@ -481,8 +470,7 @@ describe("tool call detail-level projection", () => { const result = project({ level: "overview", head: [singleCall, plan, speak] }); expect(result.head).toEqual([singleCall, plan, speak]); - expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]); - expect(result.groupsByHostId.size).toBe(1); + expect(result.groupsByHostId.size).toBe(0); }); }); @@ -529,12 +517,58 @@ it.each([false, true])( }); expect(result.head).toEqual(autoExpandReasoning ? [thought] : []); expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual( - autoExpandReasoning ? [call] : [call, thought], + autoExpandReasoning ? undefined : [call, thought], + ); + expect(result.groupsByHostId.get(call.id)?.summary).toEqual( + autoExpandReasoning + ? undefined + : expect.objectContaining({ + commandCount: 1, + otherToolCount: 0, + thoughtCount: 1, + }), ); - expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({ - commandCount: 1, - otherToolCount: 0, - thoughtCount: autoExpandReasoning ? 0 : 1, - }); }, ); + +it("turns a retained lone call into a group without replacing its row key", () => { + const first = toolCall("1", { type: "shell", command: "one" }); + const second = toolCall("2", { type: "shell", command: "two" }); + const tail = [assistant("before"), first]; + const preparedHistory = prepareToolCallHistory("overview", tail); + const single = project({ level: "overview", tail, preparedHistory, isTurnActive: true }); + const grouped = project({ + level: "overview", + tail, + head: [second], + preparedHistory, + isTurnActive: true, + }); + expect(single.groupsByHostId.size).toBe(0); + expect(single.tail).toBe(tail); + expect(grouped.tail).toBe(single.tail); + expect(grouped.head).toEqual([]); + expect(grouped.groupsByHostId.get(first.id)?.run.calls).toEqual([first, second]); + expect(grouped.historyGroupUpdatesByHostId.get(first.id)).toBe( + grouped.groupsByHostId.get(first.id), + ); +}); + +it("shows one thought as itself until another groupable item arrives", () => { + const thought: StreamItem = { + kind: "thought", + id: "thought", + text: "Checking", + status: "ready", + timestamp: new Date(0), + }; + const single = project({ level: "overview", tail: [thought] }); + expect(single.tail).toEqual([thought]); + expect(single.groupsByHostId.size).toBe(0); + const grouped = project({ + level: "overview", + tail: [thought, toolCall("1", { type: "shell", command: "one" })], + }); + expect(grouped.tail.map((item) => item.id)).toEqual([thought.id]); + expect(grouped.groupsByHostId.get(thought.id)?.run.calls).toHaveLength(2); +}); diff --git a/packages/app/src/tool-calls/expansion-context.tsx b/packages/app/src/tool-calls/expansion-context.tsx new file mode 100644 index 0000000000..4ac2477247 --- /dev/null +++ b/packages/app/src/tool-calls/expansion-context.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react"; +import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal"; +import { + createToolCallExpansion, + type ExpandedToolCallGroups, + type ToolCallExpansion, +} from "./expansion"; + +export const ToolCallExpansionContext = createContext(null); + +/** One store per viewed agent; switching agents starts with every tool call closed. */ +export function useAgentToolCallExpansion(agentId: string): ToolCallExpansion { + const [owner, setOwner] = useState(() => ({ agentId, expansion: createToolCallExpansion() })); + if (owner.agentId === agentId) return owner.expansion; + const next = { agentId, expansion: createToolCallExpansion() }; + setOwner(next); + return next.expansion; +} + +/** Subscribes the timeline to group toggles only; call toggles never reach it. */ +export function useExpandedToolCallGroups(expansion: ToolCallExpansion): ExpandedToolCallGroups { + return useSyncExternalStore( + expansion.subscribeGroups, + expansion.getExpandedGroups, + expansion.getExpandedGroups, + ); +} + +function useToolCallExpansion(): ToolCallExpansion { + const expansion = useContext(ToolCallExpansionContext); + if (!expansion) throw new Error("ToolCall requires a ToolCallExpansionContext"); + return expansion; +} + +/** Carries tool call open state into sheets that render their content in a portal. */ +export function useToolCallExpansionContextBridge(): ContextBridge { + const expansion = useToolCallExpansion(); + return useCallback( + (children) => ( + + {children} + + ), + [expansion], + ); +} + +/** Subscribes one tool call to its own open state. */ +export function useToolCallExpanded(input: { + callId: string; + defaultExpanded: boolean; + standalone: boolean; +}) { + const expansion = useToolCallExpansion(); + const { callId, defaultExpanded, standalone } = input; + const subscribe = useCallback( + (listener: () => void) => expansion.subscribeCall(callId, listener), + [expansion, callId], + ); + const getSnapshot = useCallback( + () => expansion.isCallExpanded(callId, defaultExpanded), + [expansion, callId, defaultExpanded], + ); + const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const toggle = useCallback(() => { + expansion.setCallExpanded({ callId, expanded: !expanded, standalone }); + }, [expansion, callId, expanded, standalone]); + return { expanded, toggle }; +} diff --git a/packages/app/src/tool-calls/expansion.test.ts b/packages/app/src/tool-calls/expansion.test.ts new file mode 100644 index 0000000000..875d3c6078 --- /dev/null +++ b/packages/app/src/tool-calls/expansion.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { createToolCallExpansion } from "./expansion"; + +function observe(expansion: ReturnType) { + const notified: string[] = []; + expansion.subscribeCall("first", () => notified.push("first")); + expansion.subscribeCall("second", () => notified.push("second")); + expansion.subscribeGroups(() => notified.push("timeline")); + return notified; +} + +describe("tool call expansion", () => { + it("opens a lone call and its future group without notifying the timeline", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + const groupsBefore = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + + expect(notified).toEqual(["first"]); + expect(expansion.getExpandedGroups()).toBe(groupsBefore); + expect(expansion.isCallExpanded("first", false)).toBe(true); + expect(expansion.getExpandedGroups().has("first")).toBe(true); + }); + + it("closes the lone call and its future group together", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + const groupsBefore = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true }); + + expect(notified).toEqual(["first", "first"]); + expect(expansion.getExpandedGroups()).toBe(groupsBefore); + expect(expansion.isCallExpanded("first", false)).toBe(false); + expect(expansion.getExpandedGroups().has("first")).toBe(false); + }); + + it("keeps a call open when its group closes and reopens", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true }); + const groupsWhenFormed = expansion.getExpandedGroups(); + + expansion.setGroupExpanded("first", false); + const closedGroups = expansion.getExpandedGroups(); + expect(closedGroups).not.toBe(groupsWhenFormed); + expect(closedGroups.has("first")).toBe(false); + expect(expansion.isCallExpanded("first", false)).toBe(true); + + expansion.setGroupExpanded("first", true); + expect(expansion.getExpandedGroups()).not.toBe(closedGroups); + expect(expansion.getExpandedGroups().has("first")).toBe(true); + expect(expansion.isCallExpanded("first", false)).toBe(true); + expect(notified).toEqual(["first", "timeline", "timeline"]); + }); + + it("toggles a call inside a group without touching the group", () => { + const expansion = createToolCallExpansion(); + const notified = observe(expansion); + expansion.setGroupExpanded("first", true); + const groups = expansion.getExpandedGroups(); + + expansion.setCallExpanded({ callId: "first", expanded: true, standalone: false }); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: false }); + + expect(notified).toEqual(["timeline", "first", "first"]); + expect(expansion.getExpandedGroups()).toBe(groups); + expect(groups.has("first")).toBe(true); + }); + + it("uses the call's default until the user toggles it", () => { + const expansion = createToolCallExpansion(); + expect(expansion.isCallExpanded("first", true)).toBe(true); + expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true }); + expect(expansion.isCallExpanded("first", true)).toBe(false); + }); +}); diff --git a/packages/app/src/tool-calls/expansion.ts b/packages/app/src/tool-calls/expansion.ts new file mode 100644 index 0000000000..d11a824ae2 --- /dev/null +++ b/packages/app/src/tool-calls/expansion.ts @@ -0,0 +1,67 @@ +type Listener = () => void; + +/** Which tool call groups are open. A new value is published only when a group is toggled. */ +export interface ExpandedToolCallGroups { + has(groupId: string): boolean; +} + +/** + * Open state of one agent's tool calls and tool call groups. + * + * A group takes its first call's id. Opening a lone call (`standalone`) also opens the group it + * will host once a second call arrives, without publishing a group change: the timeline renders + * that row again when the group forms and reads the open state then. A call toggle notifies only + * that call's subscribers. + */ +export function createToolCallExpansion() { + const calls = new Map(); + const callListeners = new Map>(); + const groups = new Set(); + const groupListeners = new Set(); + let expandedGroups = createExpandedGroups(); + + function createExpandedGroups(): ExpandedToolCallGroups { + return { has: (groupId) => groups.has(groupId) }; + } + + function writeGroup(groupId: string, expanded: boolean) { + if (expanded) groups.add(groupId); + else groups.delete(groupId); + } + + return { + isCallExpanded: (callId: string, defaultExpanded: boolean) => + calls.get(callId) ?? defaultExpanded, + subscribeCall(callId: string, listener: Listener) { + let listeners = callListeners.get(callId); + if (!listeners) { + listeners = new Set(); + callListeners.set(callId, listeners); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) callListeners.delete(callId); + }; + }, + setCallExpanded(input: { callId: string; expanded: boolean; standalone: boolean }) { + calls.set(input.callId, input.expanded); + if (input.standalone) writeGroup(input.callId, input.expanded); + for (const listener of callListeners.get(input.callId) ?? []) listener(); + }, + getExpandedGroups: () => expandedGroups, + subscribeGroups(listener: Listener) { + groupListeners.add(listener); + return () => { + groupListeners.delete(listener); + }; + }, + setGroupExpanded(groupId: string, expanded: boolean) { + writeGroup(groupId, expanded); + expandedGroups = createExpandedGroups(); + for (const listener of groupListeners) listener(); + }, + }; +} + +export type ToolCallExpansion = ReturnType;