fix(app): let composer pills scroll sideways on Android (#9162)

The strip of pills above the composer started a drag on Android but never
moved. Its ScrollView had pointerEvents="box-none", carried over from the
plain View it replaced. React Native's Android horizontal scroll view
accepts the drag in onInterceptTouchEvent and then drops every onTouchEvent
unless the view itself can be a touch target, which box-none rules out.

box-none was there so the empty part of the row passes touches to the
transcript. The scroller now sizes to its pills instead, capped at the bar
because web would otherwise let it grow past the bar, so the empty part of
the row is outside it.

Adds the edge fades the workspace tab row uses, so pills cut off at the
edge read as scrollable. The fades reach the bottom edge here because each
pill's own border does.
This commit is contained in:
Mohamed Boudra
2026-11-09 12:12:18 +07:00
committed by GitHub
parent 1fff127f45
commit 180f07587a
3 changed files with 126 additions and 11 deletions
@@ -74,12 +74,15 @@ export function HorizontalScrollBoundaryShades({
leftStyle,
rightStyle,
testIDPrefix = "horizontal-scroll-boundary",
bottomInset = 1,
}: {
visible: boolean;
backdrop: "surface" | "sidebar";
leftStyle: AnimatedStyle<{ opacity: number }>;
rightStyle: AnimatedStyle<{ opacity: number }>;
testIDPrefix?: string;
/** Set to 0 when the scrolled content's own border reaches the bottom edge and must fade too. */
bottomInset?: number;
}) {
if (!visible) return null;
const colorMapping = backdrop === "sidebar" ? sidebarColorMapping : surfaceColorMapping;
@@ -88,14 +91,14 @@ export function HorizontalScrollBoundaryShades({
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-left`}
style={[styles.shade, styles.left, leftStyle]}
style={[styles.shade(bottomInset), styles.left, leftStyle]}
>
<ThemedScrollBoundaryShadeSvg side="left" uniProps={colorMapping} />
</Animated.View>
<Animated.View
pointerEvents="none"
testID={`${testIDPrefix}-right`}
style={[styles.shade, styles.right, rightStyle]}
style={[styles.shade(bottomInset), styles.right, rightStyle]}
>
<ThemedScrollBoundaryShadeSvg side="right" uniProps={colorMapping} />
</Animated.View>
@@ -104,12 +107,12 @@ export function HorizontalScrollBoundaryShades({
}
const styles = StyleSheet.create(() => ({
shade: {
shade: (bottomInset: number) => ({
position: "absolute",
top: 0,
bottom: 1,
bottom: bottomInset,
width: SHADE_WIDTH,
},
}),
left: { left: 0 },
right: { right: 0 },
}));
@@ -1,9 +1,15 @@
import React, { act, useCallback, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Pressable, Text } from "react-native";
import { Pressable, Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
import { MobilePanelsProvider } from "@/mobile-panels/provider";
import {
ComposerTrackBar,
ComposerTrackPill,
ComposerTrackRow,
type ComposerTrackPillSegment,
} from "./tracks";
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
@@ -192,6 +198,87 @@ describe("composer track panel", () => {
});
});
const BAR_PILL_STYLE = { width: 120, height: 32 };
const TRANSCRIPT_STYLE = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } as const;
const NARROW_HOST = { position: "relative", width: 300, height: 120 } as const;
const WIDE_HOST = { position: "relative", width: 400, height: 120 } as const;
function mountTrackBar({
host,
pills,
}: {
host: typeof NARROW_HOST | typeof WIDE_HOST;
pills: number;
}): void {
mount(
<MobilePanelsProvider>
<View style={host}>
<View testID="transcript" style={TRANSCRIPT_STYLE} />
<ComposerTrackBar>
{Array.from({ length: pills }, (_, index) => (
<View key={index} testID={`bar-pill-${index}`} style={BAR_PILL_STYLE} />
))}
</ComposerTrackBar>
</View>
</MobilePanelsProvider>,
);
}
function byTestID(testID: string): HTMLElement {
const element = document.querySelector(`[data-testid="${testID}"]`);
if (!(element instanceof HTMLElement)) {
throw new Error(`${testID} did not render`);
}
return element;
}
/** The element that receives a touch landing at this point of the page. */
function hitTest(x: number, y: number): Element | null {
return document.elementFromPoint(x, y);
}
describe("composer track bar", () => {
// Android's horizontal ScrollView ignores the drag unless the scroller itself can be touched.
// Browsers scroll either way, so this is the only part of that bug a browser can see.
it("keeps the scroller touchable, so Android can drag the row", () => {
mountTrackBar({ host: NARROW_HOST, pills: 4 });
expect(getComputedStyle(byTestID("composer-track-scroll")).pointerEvents).toBe("auto");
});
it("stays inside the bar when the pills overflow it", () => {
mountTrackBar({ host: NARROW_HOST, pills: 4 });
const scroller = byTestID("composer-track-scroll");
expect(scroller.getBoundingClientRect().right).toBeLessThanOrEqual(
byTestID("transcript").getBoundingClientRect().right,
);
expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
});
it("leaves the empty part of the row to the transcript", () => {
mountTrackBar({ host: WIDE_HOST, pills: 1 });
const pillRect = byTestID("bar-pill-0").getBoundingClientRect();
const transcript = byTestID("transcript");
const emptyX = transcript.getBoundingClientRect().right - 40;
expect(emptyX).toBeGreaterThan(pillRect.right);
expect(hitTest(emptyX, pillRect.top + pillRect.height / 2)).toBe(transcript);
});
it("fades the full height of the row without taking its touches", () => {
mountTrackBar({ host: NARROW_HOST, pills: 4 });
const scroller = byTestID("composer-track-scroll").getBoundingClientRect();
const shade = byTestID("composer-track-scroll-shade-right").getBoundingClientRect();
expect(shade.bottom).toBe(scroller.bottom);
const underShade = hitTest(shade.left + shade.width / 2, shade.top + shade.height / 2);
expect(byTestID("composer-track-scroll").contains(underShade)).toBe(true);
});
});
describe("composer track pill status mark", () => {
/** The mark is the glyph itself: the dot for a still state, the ring's carrier for running. */
function mountMark(bucket: "running" | "failed"): { mark: HTMLElement; body: HTMLElement } {
+29 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import {
MENU_ITEM_HEIGHT,
@@ -15,6 +16,10 @@ import {
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { bySizing } from "@/components/ui/control-geometry";
import {
HorizontalScrollBoundaryShades,
useHorizontalScrollBoundary,
} from "@/components/ui/horizontal-scroll-boundary";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
import { isWeb } from "@/constants/platform";
import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures";
@@ -38,22 +43,35 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
*/
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
const scrollGesture = usePanelAwareHorizontalScrollGesture();
const scrollBoundary = useHorizontalScrollBoundary();
return (
<View style={styles.bar} pointerEvents="box-none">
<View style={styles.trackViewport} pointerEvents="box-none">
<GestureDetector gesture={scrollGesture}>
<ScrollView
<Animated.ScrollView
testID="composer-track-scroll"
style={styles.trackScroller}
horizontal
keyboardShouldPersistTaps="handled"
showsHorizontalScrollIndicator={false}
pointerEvents="box-none"
onLayout={scrollBoundary.onLayout}
onContentSizeChange={scrollBoundary.onContentSizeChange}
onScroll={scrollBoundary.onScroll}
scrollEventThrottle={16}
>
<View style={styles.track} pointerEvents="box-none">
{children}
</View>
</ScrollView>
</Animated.ScrollView>
</GestureDetector>
<HorizontalScrollBoundaryShades
visible
backdrop="surface"
testIDPrefix="composer-track-scroll-shade"
bottomInset={0}
leftStyle={scrollBoundary.leftShadeStyle}
rightStyle={scrollBoundary.rightShadeStyle}
/>
</View>
</View>
);
@@ -393,6 +411,13 @@ const styles = StyleSheet.create((theme) => {
flexGrow: 0,
flexShrink: 1,
},
// Only as wide as its pills, so the rest of the row passes touches to the transcript. The
// scroller can't use box-none for that: Android's horizontal ScrollView won't scroll with it.
trackScroller: {
alignSelf: "flex-start",
// Web sizes a flex-start scroller to its content, so without a cap it overflows the bar.
maxWidth: "100%",
},
track: {
flexDirection: "row",
alignItems: "center",