mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da8288accf | ||
|
|
84965ef0ee | ||
|
|
d5718f1d40 | ||
|
|
ad59f025d2 | ||
|
|
c3e52c0612 | ||
|
|
ee8c6bbf91 | ||
|
|
c0c0e04a9d | ||
|
|
92e5b9b127 | ||
|
|
0ea11b814c | ||
|
|
3373a73d38 |
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user