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.
This commit is contained in:
Mohamed Boudra
2026-10-28 16:34:58 +07:00
committed by GitHub
parent d5a27e0164
commit eee70e7fef
12 changed files with 716 additions and 250 deletions
@@ -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<void> {
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<void> {
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<ReturnType<typeof timelineRowOf>>,
): Promise<boolean> {
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
}
async function topOf(row: Locator): Promise<number> {
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();
}
@@ -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<string, unknown> | 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<string, unknown>)
: 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<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | 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<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((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<ShimmerEvidence> {
return locator.evaluate((root) => {
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
@@ -0,0 +1,78 @@
import type { Page } from "@playwright/test";
declare global {
var __PASEO_E2E_REACT_RENDERS__: Record<string, number> | 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<void> {
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<void> {
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<Record<string, number>> {
return page.evaluate(async () => {
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ };
});
}
@@ -0,0 +1,125 @@
import type { Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | 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<string, unknown>)
: 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<string, unknown> | undefined;
if (payload?.agentId !== agentId) {
return null;
}
const event = payload.event as Record<string, unknown> | undefined;
const item = event?.item as Record<string, unknown> | 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<void>((resolve) => {
resolveFirstCompleted = resolve;
});
const secondRunning = new Promise<void>((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?.();
},
};
}
+57 -52
View File
@@ -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<AgentStreamViewHandle, AgentStreamVi
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
new Set(),
);
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
new Set(),
);
const toolCallExpansion = useAgentToolCallExpansion(agentId);
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
// Get serverId (fallback to agent's serverId if not provided)
@@ -431,7 +435,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
setIsNearBottom(true);
setExpandedInlineToolCallIds(new Set());
setExpandedToolCallGroupIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
@@ -708,17 +711,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
});
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
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<StreamItem, { kind: "user_message" }>) => {
@@ -815,7 +808,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(
item: Extract<StreamItem, { kind: "tool_call" }>,
isLastInSequence: boolean,
maxDetailHeight?: number,
options: { maxDetailHeight?: number; standalone?: boolean } = {},
) => {
const { payload } = item;
@@ -847,7 +840,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
standalone={options.standalone}
maxDetailHeight={options.maxDetailHeight}
/>
);
}
@@ -863,7 +857,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
standalone={options.standalone}
maxDetailHeight={options.maxDetailHeight}
/>
);
},
@@ -886,7 +881,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroupIds.has(group.run.id);
const expanded = expandedToolCallGroups.has(group.run.id);
return (
<OverviewToolCallGroupView
group={group}
@@ -906,14 +901,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded={false}
standalone={false}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
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,
})
)}
</React.Fragment>
))
@@ -922,7 +917,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
},
[
expandedToolCallGroupIds,
expandedToolCallGroups,
renderThoughtItem,
setInlineDetailsExpanded,
getToolCallGroup,
@@ -1174,15 +1169,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() => ({
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<AgentStreamViewHandle, AgentStreamVi
<ToolCallSheetProvider>
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{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,
})}
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
{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,
})}
</ToolCallExpansionContext.Provider>
</MessageOuterSpacingProvider>
<ChatOutlineRail
prompts={chatOutline.prompts}
@@ -1403,6 +1403,7 @@ interface ThoughtSlotProps {
status: Extract<StreamItem, { kind: "thought" }>["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 <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
return (
<ToolCall {...rest} itemId={itemId} onInlineDetailsExpandedChange={handleExpandedChange} />
);
}
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
+15 -2
View File
@@ -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;
}
@@ -112,7 +112,7 @@ function appendRun<TGroup>(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<TGroup>(input: {
@@ -180,7 +180,8 @@ export function groupLiveToolCalls<TGroup>(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<TGroup>(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<string, TGroup> | 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);
}
@@ -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<BottomSheetScrollViewMethods>(null);
const contextBridge = useToolCallSheetContextBridge();
const sheetContextBridge = useToolCallSheetContextBridge();
const expansionContextBridge = useToolCallExpansionContextBridge();
const contextBridge = useCallback<ContextBridge>(
(content) => sheetContextBridge(expansionContextBridge(content)),
[expansionContextBridge, sheetContextBridge],
);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible,
onClose,
@@ -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);
});
@@ -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<ToolCallExpansion | null>(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) => (
<ToolCallExpansionContext.Provider value={expansion}>
{children}
</ToolCallExpansionContext.Provider>
),
[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 };
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { createToolCallExpansion } from "./expansion";
function observe(expansion: ReturnType<typeof createToolCallExpansion>) {
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);
});
});
+67
View File
@@ -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<string, boolean>();
const callListeners = new Map<string, Set<Listener>>();
const groups = new Set<string>();
const groupListeners = new Set<Listener>();
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<typeof createToolCallExpansion>;