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;