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
This commit is contained in:
Mohamed Boudra
2026-11-11 08:24:49 +07:00
committed by GitHub
parent 33c818cc6b
commit de1b85ecfb
2 changed files with 99 additions and 59 deletions
@@ -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 <div>File contents</div>;
}
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 (
<ExpandableBadge
label="Read"
secondaryLabel="src/index.ts"
isExpanded={expanded}
onToggle={toggle}
onOpenFile={hasFile ? openFile : undefined}
renderDetails={renderFileDetails}
/>
);
}
container.style.width = "300px";
container.style.marginLeft = "20px";
act(() => root.render(<Badge hasFile={false} />));
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(<Badge hasFile />));
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();
});
});
+37 -59
View File
@@ -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<TextStyle>;
shimmerSecondaryTextStyle: StyleProp<TextStyle>;
showOpenFileButton: boolean;
}
function ExpandableBadgeWebShimmerOverlay({
@@ -1606,7 +1608,6 @@ function ExpandableBadgeWebShimmerOverlay({
secondaryLabel,
shimmerLabelTextStyle,
shimmerSecondaryTextStyle,
showOpenFileButton,
}: ExpandableBadgeWebShimmerOverlayProps) {
return (
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
@@ -1618,14 +1619,7 @@ function ExpandableBadgeWebShimmerOverlay({
{secondaryLabel}
</Text>
) : null}
{showOpenFileButton ? (
<View style={expandableBadgeStylesheet.openFileButton}>
<View style={expandableBadgeStylesheet.openFileButtonPlaceholderIcon} />
</View>
) : null}
{!secondaryLabel && !showOpenFileButton ? (
<View style={expandableBadgeStylesheet.spacer} />
) : null}
{!secondaryLabel ? <View style={expandableBadgeStylesheet.spacer} /> : null}
</View>
);
}
@@ -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 (
<View
style={expandableBadgeStylesheet.labelRow}
@@ -1700,30 +1683,12 @@ function ExpandableBadgeLabelRow({
shouldMeasureWebShimmer={shouldMeasureWebShimmer}
onSecondaryLayout={onSecondaryLayout}
/>
{showOpenFileButton ? (
<Pressable
onPress={onOpenFilePress}
onHoverIn={onOpenFileHoverIn}
onHoverOut={onOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
{isWebShimmer ? (
<ExpandableBadgeWebShimmerOverlay
label={label}
secondaryLabel={secondaryLabel}
shimmerLabelTextStyle={shimmerLabelTextStyle}
shimmerSecondaryTextStyle={shimmerSecondaryTextStyle}
showOpenFileButton={showOpenFileButton}
/>
) : 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}
>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
accessibilityState={accessibilityState}
style={pressableStyle}
>
<View style={expandableBadgeStylesheet.headerRow}>
<View style={[pressableStyle, expandableBadgeStylesheet.headerRow]}>
<Pressable
{...pressHandlers}
disabled={!isInteractive}
accessibilityState={accessibilityState}
style={expandableBadgeStylesheet.disclosureButton}
>
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
<ExpandableBadgeLabelRow
label={label}
@@ -2217,14 +2183,26 @@ export const ExpandableBadge = memo(function ExpandableBadge({
onLabelRowLayout={handleLabelRowLayout}
onLabelLayout={handleLabelLayout}
onSecondaryLayout={handleSecondaryLayout}
showOpenFileButton={Boolean(onOpenFile && isHovered)}
isOpenFileHovered={isOpenFileHovered}
onOpenFilePress={handleOpenFilePress}
onOpenFileHoverIn={handleOpenFileHoverIn}
onOpenFileHoverOut={handleOpenFileHoverOut}
/>
</View>
</Pressable>
</Pressable>
{onOpenFile && isHovered ? (
<Pressable
onPress={handleOpenFilePress}
onHoverIn={handleOpenFileHoverIn}
onHoverOut={handleOpenFileHoverOut}
accessibilityRole="button"
accessibilityLabel={t("message.actions.openFile")}
testID="tool-call-open-file"
style={expandableBadgeStylesheet.openFileButton}
hitSlop={6}
>
<ThemedFileSymlinkIcon
size={14}
uniProps={isOpenFileHovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</Pressable>
) : null}
</View>
{detailContent ? (
<Pressable
ref={detailWrapperRef}