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