Compare commits

...
Author SHA1 Message Date
Mohamed Boudra da8288accf fix(app): apply grouped detail allowance to reasoning 2026-11-15 17:30:45 +01:00
Mohamed Boudra 84965ef0ee docs(app): correct inline detail test coverage note 2026-11-15 17:11:44 +01:00
Mohamed Boudra d5718f1d40 refactor(app): remove unused tool detail hover callbacks 2026-11-15 17:03:22 +01:00
Mohamed Boudra ad59f025d2 test(app): assert inline tool visibility on iPad
Offscreen Output nodes exist before the pan, so selector waits cannot prove reachability. Assert visibility before and after scrolling and verify that collapsing hides the contents before reopening.
2026-11-15 16:46:10 +01:00
Mohamed Boudra c3e52c0612 fix(app): preserve scroll position inside overflowing tool groups
Content-size changes also come from nested disclosures. Follow newly appended calls while preserving the reader’s position when a disclosure changes height.
2026-11-15 16:46:09 +01:00
Mohamed Boudra ee8c6bbf91 fix(app): allow inline tool details to scroll on iPad
A hover-only Pressable claimed the native responder around the detail scroll view. Keep hover reporting on a passive View so drags reach the capped detail and its later sections remain accessible.
2026-11-15 15:55:32 +01:00
Mohamed Boudra c0c0e04a9d fix(app): give capped tool details one vertical scroll owner 2026-11-15 15:10:11 +01:00
Mohamed Boudra 92e5b9b127 test(app): measure disclosure bounds without evidence capture 2026-11-15 14:24:34 +01:00
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
9 changed files with 463 additions and 96 deletions
@@ -153,21 +153,29 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
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: display, autoExpandReasoning: true }),
);
}, chatDisplay);
async function configureOverviewToolCalls(
page: Page,
chatDisplay: OverviewChatDisplay,
autoExpandReasoning: boolean,
) {
await page.addInitScript(
({ display, expandReasoning }) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ chatDisplay: display, autoExpandReasoning: expandReasoning }),
);
},
{ display: chatDisplay, expandReasoning: autoExpandReasoning },
);
}
async function createOverviewAgent(
page: Page,
title: string,
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
autoExpandReasoning = true,
) {
await configureOverviewToolCalls(page, chatDisplay);
await configureOverviewToolCalls(page, chatDisplay, autoExpandReasoning);
return seedMockAgentWorkspace({
repoPrefix: "tool-call-overview-sheet-",
title,
@@ -401,3 +409,211 @@ 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}`),
};
function provideLongReadDetail(item: Record<string, unknown> | undefined) {
if (item?.type === "tool_call" && item.name === "read") item.detail = longGroupedDetail;
}
function provideLongReasoningDetail(item: Record<string, unknown> | undefined) {
if (item?.type === "reasoning") {
item.text = Array.from({ length: 80 }, (_, i) => `Long reasoning ${i}`).join("\n");
}
}
async function provideLongToolDetails(page: Page, transformItem = provideLongReadDetail) {
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;
transformItem(item);
if (envelope.message?.type === "fetch_agent_timeline_response") {
for (const entry of envelope.message.payload.entries) transformItem(entry.item);
}
ws.send(JSON.stringify(envelope));
});
});
}
async function toggleAndMeasureTool(badge: Locator) {
await badge.scrollIntoViewIfNeeded();
await badge.evaluate((node) => {
// Inline groups own a bounded viewport; opened turns use the chat viewport.
const container =
node.closest('[data-testid="tool-call-group"]') ??
node.closest('[data-testid="agent-chat-scroll"]');
if (!container) throw new Error("Expected a tool inside a group or chat viewport");
const frames: { top: number; height: number; containerHeight: number }[] = [];
(node as HTMLElement & { disclosureFrames: typeof frames }).disclosureFrames = frames;
node.querySelector("button")!.addEventListener(
"click",
() => {
const measure = () => {
const bounds = node.getBoundingClientRect();
frames.push({
top: bounds.top,
height: bounds.height,
containerHeight: container.getBoundingClientRect().height,
});
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; containerHeight: 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,
);
// A fresh mount of a finished conversation starts with its disclosures closed.
await page.reload();
await expectComposerVisible(page);
if (display === "collapseToolCalls") {
await scrollChatAwayFromBottom(page, { deltaY: -10_000, minDistanceFromBottom: 1 });
}
const group = page.getByTestId("tool-call-group").first();
if (display === "collapseTurns") {
await expect(page.getByTestId("tool-call-badge")).toHaveCount(0);
await openToolDetails(page.getByTestId("turn-summary").first());
} else {
await expect(group).toBeVisible();
await expect(group.getByTestId("tool-call-badge")).toHaveCount(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.containerHeight);
expect(
Math.max(...expandedFrames.map((frame) => frame.top)) -
Math.min(...expandedFrames.map((frame) => frame.top)),
).toBeLessThanOrEqual(1);
await expect(badge).toContainText("Long output");
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();
}
});
}
test("keeps tool headers reachable in an overflowing group", async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(page, "Overflowing overview tool group");
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Inspect a large tool group.");
await gate.waitForFirstCompleted();
gate.appendCompletedCalls(15);
const group = page.getByTestId("tool-call-group").first();
await openToolDetails(group);
const badges = group.getByTestId("tool-call-badge");
await expect(badges).toHaveCount(16);
await expect.poll(() => group.evaluate(hasScrolledToLatest)).toBe(true);
const first = badges.first();
for (const action of ["expand", "collapse"]) {
await test.step(`${action} the first tool without moving its header`, async () => {
await first.scrollIntoViewIfNeeded();
const headerTop = await topOf(first);
const frames = await toggleAndMeasureTool(first);
for (const frame of frames) expect(Math.abs(frame.top - headerTop)).toBeLessThanOrEqual(1);
});
}
await test.step("follow the next appended tool", async () => {
gate.appendCompletedCalls(1);
await expect(badges).toHaveCount(17);
await expect.poll(() => group.evaluate(hasScrolledToLatest)).toBe(true);
});
await test.step("reopen the group at its latest tool", async () => {
await openToolDetails(group);
await expect(badges).toHaveCount(0);
await openToolDetails(group);
await expect(badges).toHaveCount(17);
await expect.poll(() => group.evaluate(hasScrolledToLatest)).toBe(true);
});
} finally {
await agent.cleanup();
}
});
test("keeps expanded reasoning within the group viewport", async ({ page }) => {
test.setTimeout(120_000);
const agent = await createOverviewAgent(
page,
"Bounded grouped reasoning",
"collapseToolCalls",
false,
);
try {
// Seed before opening so the fixture transforms complete snapshots, not token deltas.
await agent.client.sendAgentMessage(agent.agentId, "Inspect grouped reasoning.");
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await provideLongToolDetails(page, provideLongReasoningDetail);
await openOverviewAgent(page, agent);
await scrollChatAwayFromBottom(page, { deltaY: -10_000, minDistanceFromBottom: 1 });
const group = page.getByTestId("tool-call-group").first();
await openToolDetails(group);
const thought = group.getByTestId("tool-call-badge").filter({ hasText: "Thinking" }).first();
const frames = await toggleAndMeasureTool(thought);
const viewportHeight = await group.evaluate((node) => {
const scroll = Array.from(node.querySelectorAll<HTMLElement>("*")).find((child) =>
["auto", "scroll"].includes(getComputedStyle(child).overflowY),
);
if (!scroll) throw new Error("Expected the group's scroll viewport");
return scroll.getBoundingClientRect().height;
});
for (const frame of frames) expect(frame.height).toBeLessThanOrEqual(viewportHeight);
await expect(thought).toContainText("Long reasoning 79");
await openToolDetails(thought);
await expect(thought).not.toContainText("Long reasoning 79");
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,28 @@
# Inline tool detail scrolling on iPad
Use a task-owned iPad Pro 11-inch (M5) simulator in portrait (834×1210 points),
with the development bundle already loaded and a connected isolated mock host.
Set Chat display to Collapse turns. Prepare a finished turn containing a tool
named `scroll_probe`, with unknown detail: `input` is 80 newline-separated
`input-line-N` lines (N=0…79), and `output` is `SCROLL_REACHED_OUTPUT`.
Expand the turn, scroll chat to its start, and expand Scroll probe. The tool's
header must be at y=613…644 and its 400-point body below it. The fixed pan path
starts inside that body and stays inside it. Output starts below the cap.
These are replay preconditions, not asserted pixel sizes. Close other expanded
tools so the Scroll probe selector identifies this disclosure.
Replay `native-inline-tool-scroll.ios.yaml --maestro` on that explicit simulator
through the existing mobile runner or agent-device. In Expo's development menu,
turn off Tools button, then dismiss the known notification entitlement toast
with its bottom-right close button. Their transparent accessibility windows
otherwise mark app text covered. Another overlay must fail the visibility
assertions. Pass `--platform ios` and the prepared session's `--state-dir` when
replaying in that session.
The flow first asserts Input is visible and the output marker is not. After
panning it requires Output and its marker to be visible, then requires closing
the tool to hide its contents and reopening it to show Input again. Presence in
the accessibility tree is insufficient. A negative control with the passive
wrapper retained and only the detail's scrolling disabled must fail at Output
visibility. The browser disclosure suite checks tab navigation.
@@ -0,0 +1,28 @@
appId: sh.paseo.debug
---
- assertVisible:
id: workspace-tabs-scroll
- assertVisible: Scroll probe
- assertVisible: Input
- assertNotVisible: SCROLL_REACHED_OUTPUT
- repeat:
times: 7
commands:
- swipe:
start: "600,850"
end: "600,670"
duration: 600
- swipe:
start: "600,850"
end: "600,700"
duration: 600
- assertVisible: Output
- assertVisible: SCROLL_REACHED_OUTPUT
- tapOn:
point: "600,628"
- assertNotVisible: Output
- assertNotVisible: Input
- tapOn:
point: "600,628"
- assertVisible: Input
- stopApp
@@ -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,87 @@ 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");
});
});
const longShellDetail = {
type: "shell" as const,
command: "echo command\n".repeat(80),
output: "output\n".repeat(80),
};
const longEditDetail = {
type: "edit" as const,
filePath: "example.txt",
oldString: "old line\n".repeat(80),
newString: "new line\n".repeat(80),
};
describe("capped tool scrolling", () => {
it.each([longShellDetail, longEditDetail])(
"gives a capped $type detail one vertical scroll owner",
(detail) => {
act(() =>
root.render(
<QueryClientProvider client={queryClient}>
<ToolCallDetailsContent detail={detail} maxHeight={200} />
</QueryClientProvider>,
),
);
const verticalScrollers = Array.from(container.querySelectorAll("*")).filter((node) =>
["auto", "scroll"].includes(getComputedStyle(node).overflowY),
);
expect(verticalScrollers).toHaveLength(1);
expect(container.getBoundingClientRect().height).toBeLessThanOrEqual(200);
},
);
});
function renderCopyOutput() {
return <button type="button">Copy output</button>;
}
describe("tool detail interaction", () => {
it("keeps detail controls in the tab order", async () => {
function Badge() {
const [expanded, setExpanded] = React.useState(true);
const toggle = React.useCallback(() => setExpanded(!expanded), [expanded]);
return (
<ExpandableBadge
label="Probe"
isExpanded={expanded}
onToggle={toggle}
renderDetails={renderCopyOutput}
/>
);
}
act(() => root.render(<Badge />));
const header = view().getByRole("button", { name: "Probe" });
act(() => header.focus());
await userEvent.keyboard("{Tab}");
expect(document.activeElement).toBe(view().getByRole("button", { name: "Copy output" }));
});
});
+4
View File
@@ -883,6 +883,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded={false}
maxDetailHeight={GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT}
standalone={false}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
@@ -1375,6 +1376,7 @@ interface ThoughtSlotProps {
isLastInSequence: boolean;
defaultExpanded: boolean;
standalone?: boolean;
maxDetailHeight?: number;
}
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
@@ -1386,6 +1388,7 @@ function ThoughtSlot({
isLastInSequence,
defaultExpanded,
standalone,
maxDetailHeight,
}: ThoughtSlotProps) {
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
return (
@@ -1398,6 +1401,7 @@ function ThoughtSlot({
isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded}
standalone={standalone}
maxDetailHeight={maxDetailHeight}
forceInline={defaultExpanded}
/>
);
+5 -8
View File
@@ -15,7 +15,6 @@ const ScrollView = isWeb ? RNScrollView : GHScrollView;
interface DiffViewerProps {
diffLines: DiffLine[];
maxHeight?: number;
emptyLabel?: string;
fillAvailableHeight?: boolean;
}
@@ -119,7 +118,6 @@ function DiffSegment({
export function DiffViewer({
diffLines,
maxHeight,
emptyLabel,
fillAvailableHeight = false,
}: DiffViewerProps) {
@@ -133,12 +131,8 @@ export function DiffViewer({
);
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && inlineUnistylesStyle({ maxHeight }),
fillAvailableHeight && styles.fillHeight,
],
[maxHeight, fillAvailableHeight],
() => [styles.verticalScroll, fillAvailableHeight && styles.fillHeight],
[fillAvailableHeight],
);
const linesContainerStyle = React.useMemo(
() => [
@@ -184,6 +178,9 @@ export function DiffViewer({
</ScrollView>
);
// Capped tool details own vertical scrolling outside the diff.
if (!fillAvailableHeight) return horizontalScroll;
const content = (
<ScrollView
style={outerScrollStyle}
+2 -25
View File
@@ -1562,7 +1562,6 @@ interface ExpandableBadgeProps {
style?: StyleProp<ViewStyle>;
onToggle?: () => void;
onOpenFile?: () => void;
onDetailHoverChange?: (hovered: boolean) => void;
renderDetails?: () => ReactNode;
isLoading?: boolean;
isError?: boolean;
@@ -1887,7 +1886,6 @@ export const ExpandableBadge = memo(function ExpandableBadge({
isExpanded,
onToggle,
onOpenFile,
onDetailHoverChange,
renderDetails,
isLoading = false,
isError = false,
@@ -1913,11 +1911,6 @@ export const ExpandableBadge = memo(function ExpandableBadge({
}, []);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
const handleDetailHoverIn = useCallback(() => onDetailHoverChange?.(true), [onDetailHoverChange]);
const handleDetailHoverOut = useCallback(
() => onDetailHoverChange?.(false),
[onDetailHoverChange],
);
const handleOpenFilePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation?.();
@@ -2207,14 +2200,9 @@ export const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{detailContent ? (
<Pressable
ref={detailWrapperRef}
style={detailWrapperStyle}
onHoverIn={handleDetailHoverIn}
onHoverOut={handleDetailHoverOut}
>
<View ref={detailWrapperRef} style={detailWrapperStyle}>
{detailContent}
</Pressable>
</View>
) : null}
</View>
);
@@ -2234,7 +2222,6 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
if (previous.testID !== next.testID) return false;
if (previous.onToggle !== next.onToggle) return false;
if (previous.onOpenFile !== next.onOpenFile) return false;
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
if (previous.renderDetails !== next.renderDetails) return false;
return true;
}
@@ -2252,7 +2239,6 @@ interface ToolCallProps {
metadata?: Record<string, unknown>;
isLastInSequence?: boolean;
disableOuterSpacing?: boolean;
onInlineDetailsHoverChange?: (hovered: boolean) => void;
onInlineDetailsExpandedChange?: (expanded: boolean) => void;
onOpenFilePath?: (filePath: string) => void;
defaultExpanded?: boolean;
@@ -2276,7 +2262,6 @@ export const ToolCall = memo(function ToolCall({
metadata,
isLastInSequence = false,
disableOuterSpacing,
onInlineDetailsHoverChange,
onInlineDetailsExpandedChange,
onOpenFilePath,
defaultExpanded,
@@ -2362,13 +2347,6 @@ export const ToolCall = memo(function ToolCall({
presentation.isLoadingDetails,
]);
useEffect(() => {
if (!onInlineDetailsHoverChange || !shouldRenderInline || isExpanded) {
return;
}
onInlineDetailsHoverChange(false);
}, [isExpanded, shouldRenderInline, onInlineDetailsHoverChange]);
useEffect(() => {
if (!onInlineDetailsExpandedChange) {
return;
@@ -2435,7 +2413,6 @@ export const ToolCall = memo(function ToolCall({
isError={status === "failed"}
isLastInSequence={isLastInSequence}
disableOuterSpacing={disableOuterSpacing}
onDetailHoverChange={onInlineDetailsHoverChange}
/>
);
}, areToolCallPropsEqual);
@@ -5,6 +5,7 @@ import {
ScrollView as RNScrollView,
type StyleProp,
type ViewStyle,
type ScrollViewProps,
} from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
@@ -28,6 +29,25 @@ import { isWeb } from "@/constants/platform";
const ScrollView = isWeb ? RNScrollView : GHScrollView;
// A capped detail scrolls as a whole. Only full-height sheets need section scrollers.
function DetailSectionBody({
fillAvailableHeight,
style,
contentContainerStyle,
children,
...scrollProps
}: ScrollViewProps & { fillAvailableHeight: boolean }) {
const bodyStyle = useMemo(() => [style, contentContainerStyle], [style, contentContainerStyle]);
if (fillAvailableHeight) {
return (
<ScrollView {...scrollProps} style={style} contentContainerStyle={contentContainerStyle}>
{children}
</ScrollView>
);
}
return <View style={bodyStyle}>{children}</View>;
}
// ---- Content Component ----
interface ToolCallDetailsContentProps {
@@ -40,6 +60,7 @@ interface ToolCallDetailsContentProps {
}
interface DetailStyles {
fillAvailableHeight: boolean;
sectionFillStyle: StyleProp<ViewStyle>;
codeBlockFillStyle: StyleProp<ViewStyle>;
codeVerticalScrollStyle: StyleProp<ViewStyle>;
@@ -49,7 +70,6 @@ interface DetailStyles {
jsonScrollErrorCombined: StyleProp<ViewStyle>;
fullBleedContainerStyle: StyleProp<ViewStyle>;
loadingContainerStyle: StyleProp<ViewStyle>;
resolvedMaxHeight: number | undefined;
shouldFill: boolean;
isFullBleed: boolean;
}
@@ -69,7 +89,6 @@ function resolveShouldFill(
function useDetailStyles(
detail: ToolCallDetail | undefined,
resolvedMaxHeight: number | undefined,
fillAvailableHeight: boolean,
): DetailStyles {
const isFullBleed = resolveIsFullBleed(detail);
@@ -85,28 +104,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(
@@ -122,6 +127,7 @@ function useDetailStyles(
);
return {
fillAvailableHeight,
sectionFillStyle,
codeBlockFillStyle,
codeVerticalScrollStyle,
@@ -131,7 +137,6 @@ function useDetailStyles(
jsonScrollErrorCombined,
fullBleedContainerStyle,
loadingContainerStyle,
resolvedMaxHeight,
shouldFill,
isFullBleed,
};
@@ -160,7 +165,8 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
return (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
<ScrollView
<DetailSectionBody
fillAvailableHeight={ds.fillAvailableHeight}
style={ds.codeVerticalScrollStyle}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
@@ -180,7 +186,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
</Text>
</View>
</ScrollView>
</ScrollView>
</DetailSectionBody>
</View>
</View>
);
@@ -196,11 +202,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>
@@ -228,7 +230,8 @@ function ScrollableTextSection({
[content, filePath],
);
const body = (
<ScrollView
<DetailSectionBody
fillAvailableHeight={ds.fillAvailableHeight}
style={ds.scrollAreaFillStyle}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
@@ -243,7 +246,7 @@ function ScrollableTextSection({
</Text>
)}
</ScrollView>
</ScrollView>
</DetailSectionBody>
);
if (!wrapInSectionFill) return body;
return <View style={ds.sectionFillStyle}>{body}</View>;
@@ -258,7 +261,8 @@ interface FetchDetailProps {
function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
return (
<View style={ds.sectionFillStyle}>
<ScrollView
<DetailSectionBody
fillAvailableHeight={ds.fillAvailableHeight}
style={ds.scrollAreaFillStyle}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
@@ -269,7 +273,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
{result ? `${url}\n\n${result}` : url}
</Text>
</ScrollView>
</ScrollView>
</DetailSectionBody>
</View>
);
}
@@ -277,7 +281,8 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyles }) {
return (
<View style={styles.section}>
<ScrollView
<DetailSectionBody
fillAvailableHeight={ds.fillAvailableHeight}
style={ds.scrollAreaStyle}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
@@ -286,7 +291,7 @@ function ScrollablePlainTextSection({ text, ds }: { text: string; ds: DetailStyl
<Text selectable style={styles.plainText}>
{text}
</Text>
</ScrollView>
</DetailSectionBody>
</View>
);
}
@@ -304,7 +309,8 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.content) {
out.push(
<View key="search-content" style={styles.section}>
<ScrollView
<DetailSectionBody
fillAvailableHeight={ds.fillAvailableHeight}
style={ds.scrollAreaStyle}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
@@ -315,7 +321,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
{detail.content}
</Text>
</ScrollView>
</ScrollView>
</DetailSectionBody>
</View>,
);
}
@@ -548,7 +554,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 +571,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>;
}
@@ -27,12 +27,8 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
onExpandedChange,
children,
}: OverviewGroupProps) {
const scrollRef = useRef<ScrollView>(null);
const isCompact = useIsCompactFormFactor();
const aggregateSummary = useOverviewSummaryLabel(group.summary);
const scrollToLatest = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
}, []);
const hold = useDisclosureHold();
const toggle = useCallback(() => {
const apply = () => onExpandedChange(group.run.id, !expanded);
@@ -44,19 +40,8 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
onExpandedChange(group.run.id, false);
}, [group.run.id, onExpandedChange]);
const renderDetails = useCallback(
() => (
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.content}
nestedScrollEnabled
showsVerticalScrollIndicator
onContentSizeChange={scrollToLatest}
>
<IndentRail>{children}</IndentRail>
</ScrollView>
),
[children, scrollToLatest],
() => <InlineGroupDetails latestCallId={group.run.latest.id}>{children}</InlineGroupDetails>,
[children, group.run.latest.id],
);
if (isCompact) {
@@ -93,6 +78,35 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
);
});
function InlineGroupDetails({
latestCallId,
children,
}: {
latestCallId: string;
children: ReactNode;
}) {
const scrollRef = useRef<ScrollView>(null);
const lastRevealedCall = useRef<string | null>(null);
const revealLatestCall = useCallback(() => {
// A disclosure changes height without appending a call. Keep the reader's place then.
if (lastRevealedCall.current === latestCallId) return;
lastRevealedCall.current = latestCallId;
scrollRef.current?.scrollToEnd({ animated: false });
}, [latestCallId]);
return (
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={styles.content}
nestedScrollEnabled
showsVerticalScrollIndicator
onContentSizeChange={revealLatestCall}
>
<IndentRail>{children}</IndentRail>
</ScrollView>
);
}
const styles = StyleSheet.create((theme) => ({
scroll: {
maxHeight: TOOL_CALL_GROUP_MAX_HEIGHT,