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",