From de1b85ecfb8554843bb4a3db39a70a68e26f9ed5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 11 Nov 2026 08:24:49 +0700 Subject: [PATCH] Fix nested-button errors when hovering tool-call file actions (#9306) * fix(app): keep tool badge file actions separate from disclosure buttons Render the disclosure and open-file actions as sibling buttons so hovering a file-bearing badge does not produce invalid nested HTML buttons. Preserve independent mouse and keyboard activation. * fix(app): preserve the full tool badge disclosure hit area --- .../agent-stream/disclosure.browser.test.tsx | 62 ++++++++++++ packages/app/src/components/message.tsx | 96 +++++++------------ 2 files changed, 99 insertions(+), 59 deletions(-) diff --git a/packages/app/src/agent-stream/disclosure.browser.test.tsx b/packages/app/src/agent-stream/disclosure.browser.test.tsx index 74c61c4988..d953130a1c 100644 --- a/packages/app/src/agent-stream/disclosure.browser.test.tsx +++ b/packages/app/src/agent-stream/disclosure.browser.test.tsx @@ -4,6 +4,8 @@ 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 { ExpandableBadge } from "@/components/message"; +import { page, userEvent } from "vitest/browser"; import { PlanCard } from "@/components/plan-card"; import { MessagePreview } from "@/components/user-message"; import { DisclosureRow } from "./disclosure"; @@ -145,3 +147,63 @@ describe("timeline disclosures hold their row", () => { expect(isExpanded()).toBe(true); }); }); + +function renderFileDetails() { + return
File contents
; +} + +describe("tool badge file action", () => { + it("keeps file opening separate from the accessible disclosure button", async () => { + let opened = 0; + function Badge({ hasFile }: { hasFile: boolean }) { + const [expanded, setExpanded] = React.useState(false); + const toggle = React.useCallback(() => setExpanded(!expanded), [expanded]); + const openFile = React.useCallback(() => { + opened += 1; + }, []); + return ( + + ); + } + container.style.width = "300px"; + container.style.marginLeft = "20px"; + act(() => root.render()); + const header = page.getByRole("button", { name: "Read src/index.ts", exact: true }); + const headerElement = view().getByRole("button", { name: /^Read src\/index\.ts$/ }); + expect(headerElement.getBoundingClientRect().width).toBeGreaterThan(260); + await header.click({ position: { x: 220, y: 10 } }); + await expect.element(page.getByText("File contents")).toBeVisible(); + await header.click({ position: { x: 2, y: 2 } }); + await expect.element(page.getByText("File contents")).not.toBeInTheDocument(); + await header.click({ position: { x: 2, y: 2 } }); + await expect.element(page.getByText("File contents")).toBeVisible(); + await header.click({ position: { x: 220, y: 10 } }); + await expect.element(page.getByText("File contents")).not.toBeInTheDocument(); + act(() => root.render()); + await page.getByRole("button", { name: "Read src/index.ts", exact: true }).hover(); + const file = view().getByRole("button", { name: /^Open file$/ }); + expect(file.parentElement?.closest("button")).toBeNull(); + await page.getByRole("button", { name: "Open file", exact: true }).click(); + expect(opened).toBe(1); + const disclosure = view().getByRole("button", { name: /^Read src\/index\.ts$/ }); + expect(view().queryByText("File contents")).toBeNull(); + await header.click({ position: { x: 220, y: 10 } }); + + await expect.element(page.getByText("File contents")).toBeVisible(); + expect(opened).toBe(1); + act(() => file.focus()); + await userEvent.keyboard("{Enter}"); + expect(opened).toBe(2); + expect(view().getByText("File contents")).toBeTruthy(); + act(() => disclosure.focus()); + await userEvent.keyboard("{Enter}"); + expect(view().queryByText("File contents")).toBeNull(); + }); +}); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 2bc96f6b97..087555e8a0 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -922,13 +922,19 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: "transparent", - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], overflow: "hidden", }, pressablePressed: { opacity: 0.9, }, + disclosureButton: { + flex: 1, + minWidth: 0, + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + flexDirection: "row", + alignItems: "center", + }, headerRow: { flexDirection: "row", alignItems: "center", @@ -984,15 +990,12 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ flexShrink: 0, }, openFileButton: { + marginRight: theme.spacing[2], marginLeft: theme.spacing[1], padding: theme.spacing[1], borderRadius: theme.borderRadius.md, flexShrink: 0, }, - openFileButtonPlaceholderIcon: { - width: 14, - height: 14, - }, detailWrapper: { borderBottomLeftRadius: theme.borderRadius.lg, borderBottomRightRadius: theme.borderRadius.lg, @@ -1598,7 +1601,6 @@ interface ExpandableBadgeWebShimmerOverlayProps { secondaryLabel?: string; shimmerLabelTextStyle: StyleProp; shimmerSecondaryTextStyle: StyleProp; - showOpenFileButton: boolean; } function ExpandableBadgeWebShimmerOverlay({ @@ -1606,7 +1608,6 @@ function ExpandableBadgeWebShimmerOverlay({ secondaryLabel, shimmerLabelTextStyle, shimmerSecondaryTextStyle, - showOpenFileButton, }: ExpandableBadgeWebShimmerOverlayProps) { return ( @@ -1618,14 +1619,7 @@ function ExpandableBadgeWebShimmerOverlay({ {secondaryLabel} ) : null} - {showOpenFileButton ? ( - - - - ) : null} - {!secondaryLabel && !showOpenFileButton ? ( - - ) : null} + {!secondaryLabel ? : null} ); } @@ -1649,11 +1643,6 @@ interface ExpandableBadgeLabelRowProps { onLabelRowLayout: (event: LayoutChangeEvent) => void; onLabelLayout: (event: LayoutChangeEvent) => void; onSecondaryLayout: (event: LayoutChangeEvent) => void; - showOpenFileButton: boolean; - isOpenFileHovered: boolean; - onOpenFilePress: (event: GestureResponderEvent) => void; - onOpenFileHoverIn: () => void; - onOpenFileHoverOut: () => void; } function ExpandableBadgeLabelRow({ @@ -1675,13 +1664,7 @@ function ExpandableBadgeLabelRow({ onLabelRowLayout, onLabelLayout, onSecondaryLayout, - showOpenFileButton, - isOpenFileHovered, - onOpenFilePress, - onOpenFileHoverIn, - onOpenFileHoverOut, }: ExpandableBadgeLabelRowProps) { - const { t } = useTranslation(); return ( - {showOpenFileButton ? ( - - - - ) : null} {isWebShimmer ? ( ) : null} {isNativeShimmer ? ( @@ -1928,6 +1893,7 @@ export const ExpandableBadge = memo(function ExpandableBadge({ borderlessWhenExpanded = false, testID, }: ExpandableBadgeProps) { + const { t } = useTranslation(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isHovered, setIsHovered] = useState(false); const [isOpenFileHovered, setIsOpenFileHovered] = useState(false); @@ -2190,13 +2156,13 @@ export const ExpandableBadge = memo(function ExpandableBadge({ onPointerEnter={isWeb ? handleHoverIn : undefined} onPointerLeave={isWeb ? handleHoverOut : undefined} > - - + + {iconSlotNode} - - + + {onOpenFile && isHovered ? ( + + + + ) : null} + {detailContent ? (