From 32d6b18dbfa08c4b25b8eb2f0b02a87640ac1923 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 21 Aug 2026 08:53:42 +0200 Subject: [PATCH] Open summarized tool calls in mobile sheets (#3619) * feat(app): open summarized tool calls in mobile sheets Keep compact timelines readable by moving summarized tool-call groups into a live sheet while preserving the existing inline desktop interaction. Individual calls reuse the current detail sheet and stack above the group. * fix(app): keep mobile tool call sheets current Restore live group-sheet auto-follow, replace the mocked component coverage with real compact and desktop browser journeys, and remove the touched theme subscriptions. --- .../browser/tool-call-overview-sheet.spec.ts | 280 ++++++++++++++++++ .../app/src/components/tool-call-sheet.tsx | 79 +++-- .../detail-level/overview/sheet.tsx | 138 +++++++++ .../tool-calls/detail-level/overview/view.tsx | 27 +- 4 files changed, 494 insertions(+), 30 deletions(-) create mode 100644 packages/app/e2e/browser/tool-call-overview-sheet.spec.ts create mode 100644 packages/app/src/tool-calls/detail-level/overview/sheet.tsx diff --git a/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts new file mode 100644 index 000000000..dcb538945 --- /dev/null +++ b/packages/app/e2e/browser/tool-call-overview-sheet.spec.ts @@ -0,0 +1,280 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +type WebSocketMessage = string | Buffer; + +const COMPACT_VIEWPORT = { width: 390, height: 520 }; + +function parseSessionMessage(message: WebSocketMessage): Record | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; + return envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? (envelope.message as Record) + : null; + } catch { + return null; + } +} + +function getToolCallStatus( + message: WebSocketMessage, + agentId: string, +): { callId: string; status: string } | null { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type !== "agent_stream") { + return null; + } + const payload = sessionMessage.payload as Record | undefined; + if (payload?.agentId !== agentId) { + return null; + } + const event = payload.event as Record | undefined; + const item = event?.item as Record | undefined; + return event?.type === "timeline" && + item?.type === "tool_call" && + typeof item.callId === "string" && + typeof item.status === "string" + ? { callId: item.callId, status: item.status } + : null; +} + +function cloneToolCallWithId( + message: WebSocketMessage, + callId: string, + sequenceOffset: number, +): string { + const raw = typeof message === "string" ? message : message.toString("utf8"); + const envelope = JSON.parse(raw) as { + message?: { payload?: { seq?: number; event?: { item?: { callId?: string } } } }; + }; + const payload = envelope.message?.payload; + const item = payload?.event?.item; + if (!payload || !item?.callId || typeof payload.seq !== "number") { + throw new Error("Expected a tool-call session message"); + } + item.callId = callId; + payload.seq += sequenceOffset; + return JSON.stringify(envelope); +} + +function shiftTimelineSequence( + message: WebSocketMessage, + sequenceOffset: number, +): WebSocketMessage { + if (sequenceOffset === 0) { + return message; + } + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { message?: { payload?: { seq?: number } } }; + const payload = envelope.message?.payload; + if (typeof payload?.seq !== "number") { + return message; + } + payload.seq += sequenceOffset; + return JSON.stringify(envelope); + } catch { + return message; + } +} + +async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string) { + let firstCallId: string | null = null; + let firstCompletedMessage: WebSocketMessage | null = null; + let isHolding = false; + let sequenceOffset = 0; + const heldMessages: WebSocketMessage[] = []; + let sendToPage: ((message: WebSocketMessage) => void) | null = null; + let resolveFirstCompleted!: () => void; + const firstCompleted = new Promise((resolve) => { + resolveFirstCompleted = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + sendToPage = (message) => ws.send(message); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (isHolding) { + heldMessages.push(message); + return; + } + + ws.send(shiftTimelineSequence(message, sequenceOffset)); + const toolCall = getToolCallStatus(message, agentId); + firstCallId ??= toolCall?.callId ?? null; + if (toolCall?.callId === firstCallId && toolCall.status === "completed") { + firstCompletedMessage = message; + isHolding = true; + resolveFirstCompleted(); + } + }); + }); + + return { + waitForFirstCompleted: () => firstCompleted, + release(extraCompletedCalls = 0) { + isHolding = false; + if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) { + throw new Error("Expected the first completed tool call before release"); + } + for (let index = 0; index < extraCompletedCalls; index += 1) { + sendToPage?.( + cloneToolCallWithId( + firstCompletedMessage as WebSocketMessage, + `${firstCallId}:${index}`, + index + 1, + ), + ); + } + sequenceOffset = extraCompletedCalls; + for (const message of heldMessages.splice(0)) { + sendToPage?.(shiftTimelineSequence(message, sequenceOffset)); + } + }, + }; +} + +async function configureOverviewToolCalls(page: Page): Promise { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ toolCallDetailLevel: "overview" }), + ); + }); +} + +async function createOverviewAgent(page: Page, title: string) { + await configureOverviewToolCalls(page); + return seedMockAgentWorkspace({ + repoPrefix: "tool-call-overview-sheet-", + title, + model: "ten-second-stream", + }); +} + +async function openOverviewAgent( + page: Page, + agent: Awaited>, +): Promise { + await openAgentRoute(page, { + workspaceId: agent.workspaceId, + agentId: agent.agentId, + }); + await expectComposerVisible(page); +} + +function hasScrolledToLatest(root: HTMLElement): boolean { + for (const node of root.querySelectorAll("*")) { + if (node.scrollHeight <= node.clientHeight) { + continue; + } + const maximumScrollTop = node.scrollHeight - node.clientHeight; + return maximumScrollTop > 0 && Math.abs(node.scrollTop - maximumScrollTop) <= 1; + } + return false; +} + +function readScrollMetrics(root: HTMLElement) { + const badgeElements = root.querySelectorAll("[data-testid=tool-call-badge]"); + const latestBadge = badgeElements.item(badgeElements.length - 1); + const scrollables = []; + for (const node of root.querySelectorAll("*")) { + if (node.scrollHeight <= node.clientHeight) { + continue; + } + scrollables.push({ + className: node.className, + clientHeight: node.clientHeight, + rect: node.getBoundingClientRect().toJSON(), + scrollHeight: node.scrollHeight, + scrollTop: node.scrollTop, + tagName: node.tagName, + }); + } + return { + latestBadgeRect: latestBadge?.getBoundingClientRect().toJSON(), + rootRect: root.getBoundingClientRect().toJSON(), + scrollables, + viewport: { height: window.innerHeight, width: window.innerWidth }, + }; +} + +test.describe("compact overview tool calls", () => { + test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true }); + + test("keeps a live group sheet current and stacks individual details above it", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + const agent = await createOverviewAgent(page, "Compact overview tool calls"); + + try { + const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId); + await openOverviewAgent(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet."); + await gate.waitForFirstCompleted(); + + const group = page.getByTestId("tool-call-group").first(); + await expect(group).toBeVisible(); + await group.click(); + + const sheet = page.getByTestId("tool-call-group-sheet"); + const summary = page.getByTestId("tool-call-group-sheet-summary"); + await expect(sheet).toBeVisible(); + const initialSummary = await summary.innerText(); + + gate.release(10); + const badges = sheet.getByTestId("tool-call-badge"); + await expect.poll(() => badges.count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(11); + await expect(summary).not.toHaveText(initialSummary); + await expect.poll(() => sheet.evaluate(hasScrolledToLatest)).toBe(true); + const scrollMetrics = await sheet.evaluate(readScrollMetrics); + await testInfo.attach("compact-overview-scroll-metrics", { + body: JSON.stringify(scrollMetrics, null, 2), + contentType: "application/json", + }); + + await badges.last().click(); + const detailClose = page.getByTestId("tool-call-sheet-close"); + await expect(detailClose).toBeVisible(); + + await detailClose.click(); + await expect(detailClose).toBeHidden(); + await expect(sheet).toBeVisible(); + + await testInfo.attach("compact-overview-tool-call-group", { + body: await page.screenshot(), + contentType: "image/png", + }); + + await page.getByTestId("tool-call-group-sheet-close").click(); + await expect(sheet).toBeHidden(); + await expect(group).toBeVisible(); + } finally { + await agent.cleanup(); + } + }); +}); + +test("keeps overview tool calls inline on desktop", async ({ page }) => { + test.setTimeout(120_000); + const agent = await createOverviewAgent(page, "Desktop overview tool calls"); + + try { + await openOverviewAgent(page, agent); + await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls."); + const group = page.getByTestId("tool-call-group").first(); + await expect(group).toBeVisible(); + + await group.click(); + await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0); + await expect(group.getByTestId("tool-call-badge").first()).toBeVisible(); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index dc746f663..4c7a16e22 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -1,17 +1,14 @@ import React, { createContext, useContext, useCallback, useMemo } from "react"; import type { ReactNode } from "react"; import { View, Text, Pressable } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import Animated from "react-native-reanimated"; -import { - BottomSheetScrollView, - BottomSheetBackdrop, - BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { IsolatedBottomSheetModal, + type ContextBridge, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; import type { ToolCallIconComponent } from "@/utils/tool-call-icon"; @@ -45,17 +42,43 @@ export function useToolCallSheet(): ToolCallSheetContextValue { return context; } -// ----- Custom Background Component ----- - -function CustomSheetBackground({ style }: BottomSheetBackgroundProps) { - const { theme } = useUnistyles(); - const containerStyle = useMemo( - () => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }], - [style, theme.colors.surface2], +export function useToolCallSheetContextBridge(): ContextBridge { + const context = useToolCallSheet(); + return useCallback( + (children) => ( + {children} + ), + [context], ); - return ; } +interface ToolCallHeaderIconProps { + icon: ToolCallIconComponent; + size: number; + color?: string; +} + +function ToolCallHeaderIcon({ icon: Icon, size, color }: ToolCallHeaderIconProps) { + return ; +} + +const ThemedToolCallHeaderIcon = withUnistyles(ToolCallHeaderIcon, (theme) => ({ + color: theme.colors.foreground, +})); +const ThemedCloseIcon = withUnistyles(X, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +export const ToolCallSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ + backgroundStyle: { + backgroundColor: theme.colors.surface2, + borderRadius: 16, + }, + handleIndicatorStyle: { + backgroundColor: theme.colors.palette.zinc[600], + }, +})); + // ----- Provider Component ----- interface ToolCallSheetProviderProps { @@ -63,7 +86,6 @@ interface ToolCallSheetProviderProps { } export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { - const { theme } = useUnistyles(); const [sheetData, setSheetData] = React.useState(null); const [isSheetOpen, setIsSheetOpen] = React.useState(false); @@ -104,15 +126,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) [openToolCall, closeToolCall], ); - const handleIndicatorStyle = useMemo( - () => ({ backgroundColor: theme.colors.palette.zinc[600] }), - [theme.colors.palette.zinc], - ); - return ( {children} - {sheetData && } - + ); } @@ -139,7 +154,7 @@ interface ToolCallSheetContentProps { } function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { - const { theme } = useUnistyles(); + const { t } = useTranslation(); const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data; return ( @@ -147,13 +162,19 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { {/* Header */} - + {displayName} - - + + diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx new file mode 100644 index 000000000..77990b334 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useRef, type ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; +import { + BottomSheetBackdrop, + BottomSheetScrollView, + type BottomSheetScrollViewMethods, +} from "@gorhom/bottom-sheet"; +import { Wrench, X } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { useIsolatedBottomSheetVisibility } from "@/components/ui/isolated-bottom-sheet-modal"; +import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet"; +import type { Theme } from "@/styles/theme"; + +interface OverviewToolCallGroupSheetProps { + visible: boolean; + summary: string; + children: ReactNode; + onClose: () => void; +} + +const SNAP_POINTS = ["60%", "95%"]; +const ThemedWrench = withUnistyles(Wrench); +const ThemedX = withUnistyles(X); +const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export function OverviewToolCallGroupSheet({ + visible, + summary, + children, + onClose, +}: OverviewToolCallGroupSheetProps) { + const { t } = useTranslation(); + const scrollRef = useRef(null); + const contextBridge = useToolCallSheetContextBridge(); + const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ + visible, + onClose, + }); + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + const scrollToLatest = useCallback(() => { + scrollRef.current?.scrollToEnd({ animated: false }); + }, []); + useEffect(() => { + if (!visible) { + return; + } + const frame = requestAnimationFrame(scrollToLatest); + return () => cancelAnimationFrame(frame); + }, [children, scrollToLatest, visible]); + + return ( + + + + + + + {summary} + + + + + + + + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface2, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + headerLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + }, + headerTitle: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + flex: 1, + }, + closeButton: { + padding: theme.spacing[2], + }, + content: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.surface2, + }, + contentContainer: { + paddingHorizontal: 13, + paddingVertical: 8, + }, +})); diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index a878163a9..326cc172b 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -1,10 +1,12 @@ -import { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; import { ScrollView } from "react-native"; import { useTranslation } from "react-i18next"; import { Wrench } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import { ExpandableBadge } from "@/components/message"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { type OverviewSummary, type OverviewToolCallGroup } from "./model"; +import { OverviewToolCallGroupSheet } from "./sheet"; interface OverviewGroupProps { group: OverviewToolCallGroup; @@ -59,6 +61,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView children, }: OverviewGroupProps) { const scrollRef = useRef(null); + const isCompact = useIsCompactFormFactor(); const aggregateSummary = useOverviewSummary(group.summary); const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); @@ -66,6 +69,9 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView const toggle = useCallback(() => { onExpandedChange(group.run.id, !expanded); }, [expanded, group.run.id, onExpandedChange]); + const close = useCallback(() => { + onExpandedChange(group.run.id, false); + }, [group.run.id, onExpandedChange]); const renderDetails = useCallback( () => ( + + + {children} + + + ); + } + return (