Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0ea11b814c test(app): verify tool disclosure geometry across painted frames 2026-11-15 13:46:38 +01:00
Mohamed Boudra 3373a73d38 fix(app): bound complete inline tool details within groups
Apply the detail height allowance to the complete input/output/error disclosure so expanding a tool cannot bury its collapse header outside the group viewport.
2026-11-15 13:17:17 +01:00
3 changed files with 165 additions and 28 deletions
@@ -401,3 +401,123 @@ test("forms a closed group when the lone tool call was left closed", async ({ pa
await agent.cleanup();
}
});
const longGroupedDetail = {
type: "unknown",
input: Array.from({ length: 80 }, (_, i) => `Long input ${i}`),
output: Array.from({ length: 80 }, (_, i) => `Long output ${i}`),
};
async function provideLongToolDetails(page: Page) {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => server.send(message));
server.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
const envelope = JSON.parse(raw);
const item = envelope.message?.payload?.event?.item;
if (item?.type === "tool_call" && item.status === "completed") {
item.detail = longGroupedDetail;
}
ws.send(JSON.stringify(envelope));
});
});
}
async function toggleAndMeasureTool(badge: Locator) {
await badge.scrollIntoViewIfNeeded();
await badge.evaluate((node) => {
const group = node.closest('[data-testid="tool-call-group"]');
const frames: { top: number; height: number; groupHeight: number }[] = [];
(node as HTMLElement & { disclosureFrames: typeof frames }).disclosureFrames = frames;
node.querySelector("button")!.addEventListener(
"click",
() => {
function measure() {
const bounds = node.getBoundingClientRect();
frames.push({
top: bounds.top,
height: bounds.height,
groupHeight: group?.getBoundingClientRect().height ?? 433,
});
if (frames.length < 20) requestAnimationFrame(measure);
}
requestAnimationFrame(measure);
},
{ once: true },
);
});
await openToolDetails(badge);
await expect
.poll(() =>
badge.evaluate(
(node) => (node as HTMLElement & { disclosureFrames: unknown[] }).disclosureFrames.length,
),
)
.toBe(20);
return badge.evaluate(
(node) =>
(
node as HTMLElement & {
disclosureFrames: { top: number; height: number; groupHeight: number }[];
}
).disclosureFrames,
);
}
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
test(`keeps multi-section tool details within the group (${display})`, async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Bounded grouped tool details", display);
try {
await provideLongToolDetails(page);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise bounded grouped details.");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await scrollChatAwayFromBottom(page, { deltaY: -10_000, minDistanceFromBottom: 1 });
const group = page.getByTestId("tool-call-group").first();
if (display === "collapseTurns") {
if ((await page.getByTestId("tool-call-badge").count()) === 0) {
await openToolDetails(page.getByTestId("turn-summary").first());
}
} else {
await expect(group).toBeVisible();
if ((await group.getByTestId("tool-call-badge").count()) === 0)
await openToolDetails(group);
}
const scope = display === "collapseTurns" ? page : group;
const badge = scope.getByTestId("tool-call-badge").filter({ hasText: "Read" }).first();
await expect(badge).toBeVisible();
const expandedFrames = await toggleAndMeasureTool(badge);
for (const frame of expandedFrames)
expect(frame.height).toBeLessThanOrEqual(frame.groupHeight);
expect(
Math.max(...expandedFrames.map((frame) => frame.top)) -
Math.min(...expandedFrames.map((frame) => frame.top)),
).toBeLessThanOrEqual(1);
await expect(badge).toContainText("Long output");
await page.screenshot({
path: `../../.artifacts/group-${process.env.HEIGHT_EVIDENCE ?? "after"}.png`,
});
const childBox = await badge.boundingBox();
if (display === "collapseToolCalls") {
const groupBox = await group.boundingBox();
expect(childBox!.height).toBeLessThanOrEqual(groupBox!.height);
} else {
expect(childBox!.height).toBeLessThanOrEqual(433);
}
const collapsedFrames = await toggleAndMeasureTool(badge);
expect(
Math.max(...collapsedFrames.map((frame) => frame.top)) -
Math.min(...collapsedFrames.map((frame) => frame.top)),
).toBeLessThanOrEqual(1);
await expect(badge).not.toContainText("Long output");
} finally {
await agent.cleanup();
}
});
}
@@ -4,6 +4,7 @@ import { within } from "@testing-library/dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { ToolCallDetailsContent } from "@/components/tool-call-details";
import { ExpandableBadge } from "@/components/message";
import { page, userEvent } from "vitest/browser";
import { PlanCard } from "@/components/plan-card";
@@ -202,3 +203,28 @@ describe("tool badge file action", () => {
expect(view().queryByText("File contents")).toBeNull();
});
});
const longDetail = {
type: "unknown" as const,
input: { text: "input\n".repeat(80) },
output: { text: "output\n".repeat(80) },
};
describe("inline tool detail height", () => {
it("caps the complete detail, including input, output and error, to its allowance", () => {
act(() =>
root.render(
<QueryClientProvider client={queryClient}>
<ToolCallDetailsContent
detail={longDetail}
errorText={"error\n".repeat(80)}
maxHeight={200}
/>
</QueryClientProvider>,
),
);
expect(container.getBoundingClientRect().height).toBeLessThanOrEqual(200);
expect(container.textContent).toContain("output");
expect(container.textContent).toContain("error");
});
});
@@ -49,7 +49,6 @@ interface DetailStyles {
jsonScrollErrorCombined: StyleProp<ViewStyle>;
fullBleedContainerStyle: StyleProp<ViewStyle>;
loadingContainerStyle: StyleProp<ViewStyle>;
resolvedMaxHeight: number | undefined;
shouldFill: boolean;
isFullBleed: boolean;
}
@@ -69,7 +68,6 @@ function resolveShouldFill(
function useDetailStyles(
detail: ToolCallDetail | undefined,
resolvedMaxHeight: number | undefined,
fillAvailableHeight: boolean,
): DetailStyles {
const isFullBleed = resolveIsFullBleed(detail);
@@ -85,28 +83,14 @@ function useDetailStyles(
[codeBlockStyle, shouldFill],
);
const codeVerticalScrollStyle = useMemo(
() => [
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
],
[resolvedMaxHeight, shouldFill],
() => [styles.codeVerticalScroll, shouldFill && styles.fillHeight],
[shouldFill],
);
const scrollAreaFillStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
shouldFill && styles.fillHeight,
],
[resolvedMaxHeight, shouldFill],
);
const scrollAreaStyle = useMemo(
() => [
styles.scrollArea,
resolvedMaxHeight !== undefined && inlineUnistylesStyle({ maxHeight: resolvedMaxHeight }),
],
[resolvedMaxHeight],
() => [styles.scrollArea, shouldFill && styles.fillHeight],
[shouldFill],
);
const scrollAreaStyle = useMemo(() => [styles.scrollArea], []);
const jsonScrollCombined = styles.jsonScroll;
const jsonScrollErrorCombined = [styles.jsonScroll, styles.jsonScrollError];
const fullBleedContainerStyle = useMemo(
@@ -131,7 +115,6 @@ function useDetailStyles(
jsonScrollErrorCombined,
fullBleedContainerStyle,
loadingContainerStyle,
resolvedMaxHeight,
shouldFill,
isFullBleed,
};
@@ -196,11 +179,7 @@ function EditDetailSection({ diffLines, ds }: EditDetailProps) {
<View style={ds.sectionFillStyle}>
{diffLines ? (
<View style={ds.codeBlockFillStyle}>
<DiffViewer
diffLines={diffLines}
maxHeight={ds.resolvedMaxHeight}
fillAvailableHeight={ds.shouldFill}
/>
<DiffViewer diffLines={diffLines} fillAvailableHeight={ds.shouldFill} />
</View>
) : null}
</View>
@@ -548,7 +527,8 @@ export function ToolCallDetailsContent({
}: ToolCallDetailsContentProps) {
const { t } = useTranslation();
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
const ds = useDetailStyles(detail, resolvedMaxHeight, fillAvailableHeight);
// The allowance belongs to the whole disclosure, rather than each section.
const ds = useDetailStyles(detail, fillAvailableHeight);
const diffLines = useDiffLines(detail);
const sections: ReactNode[] = buildDetailSections(toolName, detail, diffLines, ds, t);
@@ -564,6 +544,17 @@ export function ToolCallDetailsContent({
return <Text style={styles.emptyStateText}>{t("toolCallDetails.empty")}</Text>;
}
if (resolvedMaxHeight !== undefined) {
return (
<ScrollView
style={inlineUnistylesStyle({ maxHeight: resolvedMaxHeight })}
contentContainerStyle={ds.fullBleedContainerStyle}
nestedScrollEnabled
>
{sections}
</ScrollView>
);
}
return <View style={ds.fullBleedContainerStyle}>{sections}</View>;
}