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.
This commit is contained in:
@@ -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<string, unknown> | 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<string, unknown>)
|
||||||
|
: 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<string, unknown> | undefined;
|
||||||
|
if (payload?.agentId !== agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const event = payload.event as Record<string, unknown> | undefined;
|
||||||
|
const item = event?.item as Record<string, unknown> | 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<void>((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<void> {
|
||||||
|
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<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||||
|
): Promise<void> {
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: agent.workspaceId,
|
||||||
|
agentId: agent.agentId,
|
||||||
|
});
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScrolledToLatest(root: HTMLElement): boolean {
|
||||||
|
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||||
|
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<HTMLElement>("[data-testid=tool-call-badge]");
|
||||||
|
const latestBadge = badgeElements.item(badgeElements.length - 1);
|
||||||
|
const scrollables = [];
|
||||||
|
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import React, { createContext, useContext, useCallback, useMemo } from "react";
|
import React, { createContext, useContext, useCallback, useMemo } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import Animated from "react-native-reanimated";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet";
|
||||||
BottomSheetScrollView,
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetBackgroundProps,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||||
import {
|
import {
|
||||||
IsolatedBottomSheetModal,
|
IsolatedBottomSheetModal,
|
||||||
|
type ContextBridge,
|
||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
|
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
|
||||||
@@ -45,17 +42,43 @@ export function useToolCallSheet(): ToolCallSheetContextValue {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Custom Background Component -----
|
export function useToolCallSheetContextBridge(): ContextBridge {
|
||||||
|
const context = useToolCallSheet();
|
||||||
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
|
return useCallback(
|
||||||
const { theme } = useUnistyles();
|
(children) => (
|
||||||
const containerStyle = useMemo(
|
<ToolCallSheetContext.Provider value={context}>{children}</ToolCallSheetContext.Provider>
|
||||||
() => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
|
),
|
||||||
[style, theme.colors.surface2],
|
[context],
|
||||||
);
|
);
|
||||||
return <Animated.View pointerEvents="none" style={containerStyle} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToolCallHeaderIconProps {
|
||||||
|
icon: ToolCallIconComponent;
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallHeaderIcon({ icon: Icon, size, color }: ToolCallHeaderIconProps) {
|
||||||
|
return <Icon size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -----
|
// ----- Provider Component -----
|
||||||
|
|
||||||
interface ToolCallSheetProviderProps {
|
interface ToolCallSheetProviderProps {
|
||||||
@@ -63,7 +86,6 @@ interface ToolCallSheetProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
||||||
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
|
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -104,15 +126,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
|||||||
[openToolCall, closeToolCall],
|
[openToolCall, closeToolCall],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleIndicatorStyle = useMemo(
|
|
||||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
|
||||||
[theme.colors.palette.zinc],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallSheetContext.Provider value={contextValue}>
|
<ToolCallSheetContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
<IsolatedBottomSheetModal
|
<ToolCallSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={bottomSheetRef}
|
||||||
contextBridge={null}
|
contextBridge={null}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
@@ -122,11 +139,9 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
|||||||
onDismiss={handleToolCallSheetDismiss}
|
onDismiss={handleToolCallSheetDismiss}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={CustomSheetBackground}
|
|
||||||
handleIndicatorStyle={handleIndicatorStyle}
|
|
||||||
>
|
>
|
||||||
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
||||||
</IsolatedBottomSheetModal>
|
</ToolCallSheetModal>
|
||||||
</ToolCallSheetContext.Provider>
|
</ToolCallSheetContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,7 +154,7 @@ interface ToolCallSheetContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { t } = useTranslation();
|
||||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,13 +162,19 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.headerLeft}>
|
||||||
<IconComponent size={20} color={theme.colors.foreground} />
|
<ThemedToolCallHeaderIcon icon={IconComponent} size={20} />
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
<Pressable
|
||||||
<X size={20} color={theme.colors.foregroundMuted} />
|
onPress={onClose}
|
||||||
|
style={styles.closeButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("common.actions.close")}
|
||||||
|
testID="tool-call-sheet-close"
|
||||||
|
>
|
||||||
|
<ThemedCloseIcon size={20} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -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<BottomSheetScrollViewMethods>(null);
|
||||||
|
const contextBridge = useToolCallSheetContextBridge();
|
||||||
|
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const scrollToLatest = useCallback(() => {
|
||||||
|
scrollRef.current?.scrollToEnd({ animated: false });
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = requestAnimationFrame(scrollToLatest);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [children, scrollToLatest, visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolCallSheetModal
|
||||||
|
ref={sheetRef}
|
||||||
|
contextBridge={contextBridge}
|
||||||
|
snapPoints={SNAP_POINTS}
|
||||||
|
index={0}
|
||||||
|
enableDynamicSizing={false}
|
||||||
|
onChange={handleSheetChange}
|
||||||
|
onDismiss={handleSheetDismiss}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
enablePanDownToClose
|
||||||
|
>
|
||||||
|
<View style={styles.container} testID="tool-call-group-sheet">
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<ThemedWrench size={20} uniProps={foregroundMapping} />
|
||||||
|
<Text
|
||||||
|
style={styles.headerTitle}
|
||||||
|
numberOfLines={2}
|
||||||
|
testID="tool-call-group-sheet-summary"
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.closeButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t("common.actions.close")}
|
||||||
|
testID="tool-call-group-sheet-close"
|
||||||
|
>
|
||||||
|
<ThemedX size={20} uniProps={foregroundMutedMapping} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<BottomSheetScrollView ref={scrollRef} style={styles.content}>
|
||||||
|
<View style={styles.contentContainer}>{children}</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</View>
|
||||||
|
</ToolCallSheetModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 { ScrollView } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wrench } from "lucide-react-native";
|
import { Wrench } from "lucide-react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { ExpandableBadge } from "@/components/message";
|
import { ExpandableBadge } from "@/components/message";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { type OverviewSummary, type OverviewToolCallGroup } from "./model";
|
import { type OverviewSummary, type OverviewToolCallGroup } from "./model";
|
||||||
|
import { OverviewToolCallGroupSheet } from "./sheet";
|
||||||
|
|
||||||
interface OverviewGroupProps {
|
interface OverviewGroupProps {
|
||||||
group: OverviewToolCallGroup;
|
group: OverviewToolCallGroup;
|
||||||
@@ -59,6 +61,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
|||||||
children,
|
children,
|
||||||
}: OverviewGroupProps) {
|
}: OverviewGroupProps) {
|
||||||
const scrollRef = useRef<ScrollView>(null);
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
const aggregateSummary = useOverviewSummary(group.summary);
|
const aggregateSummary = useOverviewSummary(group.summary);
|
||||||
const scrollToLatest = useCallback(() => {
|
const scrollToLatest = useCallback(() => {
|
||||||
scrollRef.current?.scrollToEnd({ animated: false });
|
scrollRef.current?.scrollToEnd({ animated: false });
|
||||||
@@ -66,6 +69,9 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
|||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
onExpandedChange(group.run.id, !expanded);
|
onExpandedChange(group.run.id, !expanded);
|
||||||
}, [expanded, group.run.id, onExpandedChange]);
|
}, [expanded, group.run.id, onExpandedChange]);
|
||||||
|
const close = useCallback(() => {
|
||||||
|
onExpandedChange(group.run.id, false);
|
||||||
|
}, [group.run.id, onExpandedChange]);
|
||||||
const renderDetails = useCallback(
|
const renderDetails = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -82,6 +88,25 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
|||||||
[children, scrollToLatest],
|
[children, scrollToLatest],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isCompact) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExpandableBadge
|
||||||
|
testID="tool-call-group"
|
||||||
|
label={aggregateSummary}
|
||||||
|
icon={Wrench}
|
||||||
|
isLoading={group.isLoading}
|
||||||
|
isExpanded={false}
|
||||||
|
isLastInSequence={isLastInSequence}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
|
<OverviewToolCallGroupSheet visible={expanded} summary={aggregateSummary} onClose={close}>
|
||||||
|
{children}
|
||||||
|
</OverviewToolCallGroupSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandableBadge
|
<ExpandableBadge
|
||||||
testID="tool-call-group"
|
testID="tool-call-group"
|
||||||
|
|||||||
Reference in New Issue
Block a user