From 180f07587a967a22d53c5ba198c88ee61a731ba5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 9 Nov 2026 12:12:18 +0700 Subject: [PATCH] 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. --- .../ui/horizontal-scroll-boundary.tsx | 13 ++- .../composer/tracks-panel.browser.test.tsx | 91 ++++++++++++++++++- packages/app/src/composer/tracks.tsx | 33 ++++++- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/ui/horizontal-scroll-boundary.tsx b/packages/app/src/components/ui/horizontal-scroll-boundary.tsx index 046552787b..09c14d3e45 100644 --- a/packages/app/src/components/ui/horizontal-scroll-boundary.tsx +++ b/packages/app/src/components/ui/horizontal-scroll-boundary.tsx @@ -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({ @@ -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 }, })); diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index b02a1e5e28..8c523454bb 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -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( + + + + + {Array.from({ length: pills }, (_, index) => ( + + ))} + + + , + ); +} + +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 } { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 74a0b7f434..44906333d8 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -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 ( - {children} - + + ); @@ -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",