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
11 changed files with 184 additions and 116 deletions
+3 -4
View File
@@ -873,10 +873,9 @@ await paseo.agents.ref(agentId).timeline.append({
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
payload that cannot be rendered intact. The daemon advertises this RPC through
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
be rendered intact. The daemon advertises this RPC through
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
@@ -20,21 +20,6 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
}
const LAYOUT_VIEWPORTS = {
compact: { width: 390, height: 844 },
wide: { width: 1280, height: 900 },
};
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
// switch so the next step reads the layout it resized to.
async function resizeToLayout(
page: Parameters<typeof ensureExplorerSidebar>[0],
layout: keyof typeof LAYOUT_VIEWPORTS,
) {
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
}
async function expectExplorerActiveTabForeground(
page: Parameters<typeof ensureExplorerSidebar>[0],
) {
@@ -259,7 +244,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
await openExplorerAgentDraft(page);
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
@@ -304,10 +289,10 @@ test("retains the Explorer host through appearance changes and folding", async (
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
.not.toBe(previousColor);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
@@ -342,7 +327,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
@@ -409,9 +394,9 @@ test("updates populated code and diff appearance without losing the editor insta
.getByTestId("file-source-editor")
.elementHandle();
await text.fill(`${source}\n// retained Explorer edit`);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
@@ -476,9 +461,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
const terminalNode = await terminal.elementHandle();
await page.getByTestId("workspace-explorer-toggle").first().click();
await ensureExplorerSidebar(page);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
@@ -498,7 +483,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
try {
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
await gotoWorkspace(page, workspace.workspaceId);
await resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
@@ -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();
}
});
}
@@ -91,13 +91,10 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 });
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
// a compact layout draws its closed Explorer for a frame before hiding it.
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const explorer = explorerSidebar(page);
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
@@ -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>;
}
@@ -1,6 +1,5 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { PluginIdSchema } from "@getpaseo/protocol/plugin-config";
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
@@ -15,15 +14,6 @@ const MARKER_COST = 32;
const SHORT_STRING = 1024;
const MAX_DEPTH = 64;
// The app registers renderer kinds under the plugin ID rule, so stored kinds must use that format.
export function assertPluginTimelineKind(kind: string): void {
if (!PluginIdSchema.safeParse(kind).success) {
throw new Error(
`Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
);
}
}
export function assertPluginTimelineDataSize(data: JsonValue): void {
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
@@ -1660,37 +1660,6 @@ describe("plugin timeline append RPC", () => {
}),
);
});
test.each(["repro.card", "Review", "review_card", "1review", ""])(
"rejects the kind %j that no client renderer can register",
async (kind) => {
const messages: SessionOutboundMessage[] = [];
const appendTimelineItem = vi.fn();
const session = createSessionForTest({
clientId: "plugin:review",
messages,
agentManager: { appendTimelineItem },
});
await session.handleMessage({
type: "agent.timeline.append.request",
requestId: "append-kind",
agentId: "agent-1",
item: { type: "plugin", id: "review-1", kind, version: 1, data: {} },
});
expect(appendTimelineItem).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "rpc_error",
payload: {
requestId: "append-kind",
requestType: "agent.timeline.append.request",
error: `Request failed: Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
code: "handler_error",
},
});
},
);
});
describe("agent detach RPC", () => {
+1 -5
View File
@@ -144,10 +144,7 @@ import {
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
import {
assertPluginTimelineDataSize,
assertPluginTimelineKind,
} from "./agent/agent-timeline-content.js";
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
@@ -8507,7 +8504,6 @@ export class Session {
): Promise<void> {
const pluginId = parsePluginClientId(this.clientId);
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
assertPluginTimelineKind(msg.item.kind);
assertPluginTimelineDataSize(msg.item.data);
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
...msg.item,
+3 -6
View File
@@ -1279,12 +1279,9 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
The daemon stamps `pluginId` from the calling plugin session and rejects this RPC from non-plugin
sessions. The row appears live, survives timeline refetches, and keeps only the latest value for the
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row.
`kind` starts with a lowercase letter and contains lowercase letters, numbers, or hyphens, the same
rule `addTimelineRenderer` applies. Daemons reject any other `kind`. Daemons reject `data` over the
limit rather than truncating it. Daemons that support this operation advertise
`server_info.features.pluginTimelineItems`.
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row. Daemons
reject `data` over the limit rather than truncating it. Daemons that support this operation
advertise `server_info.features.pluginTimelineItems`.
## Theme and layout
+1 -3
View File
@@ -1060,9 +1060,7 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。
`kind` 以小写字母开头,只包含小写字母、数字或连字符,与 `addTimelineRenderer` 使用的规则相同。daemon 会拒绝其他任何 `kind`。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
## 主题与布局 {#theme-and-layout}