1import type { ComponentProps, ReactNode } from "react";
2import { useCallback, useMemo, useState } from "react";
3import {
4 Platform,
5 Pressable,
6 ScrollView,
7 View,
8 type LayoutChangeEvent,
9 type NativeScrollEvent,
10 type NativeSyntheticEvent,
11 type PressableProps,
12 type StyleProp,
13 type ViewStyle,
14} from "react-native";
15
16import { cn } from "../lib/cn";
17import { AppText as Text } from "./AppText";
18import { SymbolView } from "./AppSymbol";
19import { useAndroidControlSizing } from "./useAndroidControlSizing";
20
21const COMPOSER_TOOLBAR_GAP = 8;
22const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
23const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
24
25/**
26 * Quiet inline composer control used inside cards and their context rows.
27 * Unlike ComposerToolbarButton, this does not draw another pill inside the
28 * composer surface, so model and workspace controls read as part of the card.
29 */
30export function ComposerInlineControl(props: {
31 readonly accessibilityHint?: string;
32 readonly accessibilityLabel?: string;
33 readonly disabled?: boolean;
34 readonly emphasized?: boolean;
35 readonly icon?: ComponentProps<typeof SymbolView>["name"];
36 readonly renderIcon?: (size: number) => ReactNode;
37 readonly label: string;
38 readonly maxWidth?: ViewStyle["maxWidth"];
39 readonly onPress?: () => void;
40 readonly selected?: boolean;
41 readonly static?: boolean;
42 readonly chevronDirection?: "down" | "right";
43 readonly showChevron?: boolean;
44}) {
45 const { scale, smallIconSize } = useAndroidControlSizing();
46 return (
47 <Pressable
48 accessibilityLabel={props.accessibilityLabel ?? props.label}
49 accessibilityHint={props.accessibilityHint}
50 accessibilityRole={props.static ? undefined : "button"}
51 accessibilityState={
52 props.static ? undefined : { disabled: props.disabled, selected: props.selected }
53 }
54 className="h-11 flex-row items-center gap-2 rounded-xl px-2 active:bg-subtle"
55 disabled={props.disabled || props.static}
56 onPress={props.onPress}
57 style={{ maxWidth: props.maxWidth ?? 190, opacity: props.disabled ? 0.45 : 1 }}
58 >
59 {props.renderIcon ? (
60 <View
61 className="size-4 shrink-0 items-center justify-center"
62 style={Platform.OS === "android" ? { width: 14 * scale, height: 14 * scale } : undefined}
63 >
64 {props.renderIcon(smallIconSize)}
65 </View>
66 ) : props.icon ? (
67 <SymbolView
68 name={props.icon}
69 size={smallIconSize}
70 tintColorClassName={
71 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
72 }
73 type="monochrome"
74 />
75 ) : null}
76 <Text
77 className={cn(
78 "shrink text-sm font-t3-medium",
79 props.emphasized || props.selected ? "text-foreground" : "text-foreground-muted",
80 )}
81 numberOfLines={1}
82 >
83 {props.label}
84 </Text>
85 {props.showChevron === false ? null : (
86 <SymbolView
87 name={props.chevronDirection === "right" ? "chevron.right" : "chevron.down"}
88 size={Math.round(10 * scale)}
89 tintColorClassName={
90 props.emphasized || props.selected ? "accent-icon" : "accent-icon-muted"
91 }
92 type="monochrome"
93 />
94 )}
95 </Pressable>
96 );
97}
98
99export function ComposerToolbarRow(props: {
100 readonly children: ReactNode;
101 readonly paddingBottom?: number;
102 readonly paddingHorizontal?: number;
103 readonly paddingTop?: number;
104 readonly style?: StyleProp<ViewStyle>;
105}) {
106 return (
107 <View
108 className="flex-row items-center gap-1.5"
109 style={[
110 {
111 paddingBottom: props.paddingBottom ?? 8,
112 paddingHorizontal: props.paddingHorizontal ?? 6,
113 paddingTop: props.paddingTop ?? 8,
114 },
115 props.style,
116 ]}
117 >
118 {props.children}
119 </View>
120 );
121}
122
123export function ComposerToolbarScroller(props: {
124 readonly children: ReactNode;
125 readonly align?: "start" | "end";
126 /** Only for non-Uniwind surfaces such as the native terminal palette. */
127 readonly fadeOpaque?: string;
128 /** Only for non-Uniwind surfaces such as the native terminal palette. */
129 readonly fadeTransparent?: string;
130 /** Semantic Uniwind surface behind the toolbar. Defaults to card. */
131 readonly fadeSurface?: "card" | "sheet";
132 readonly contentPaddingRight?: number;
133}) {
134 const [metrics, setMetrics] = useState({
135 contentWidth: 0,
136 offsetX: 0,
137 viewportWidth: 0,
138 });
139
140 const scrollEdges = useMemo(() => {
141 const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth);
142 return {
143 showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON,
144 showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON,
145 };
146 }, [metrics]);
147
148 const handleLayout = useCallback((event: LayoutChangeEvent) => {
149 const viewportWidth = event.nativeEvent.layout.width;
150 setMetrics((current) =>
151 current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth },
152 );
153 }, []);
154
155 const handleContentSizeChange = useCallback((contentWidth: number) => {
156 setMetrics((current) =>
157 current.contentWidth === contentWidth ? current : { ...current, contentWidth },
158 );
159 }, []);
160
161 const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
162 const offsetX = event.nativeEvent.contentOffset.x;
163 setMetrics((current) =>
164 Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX },
165 );
166 }, []);
167
168 return (
169 <View className="relative min-w-0 flex-1">
170 <ScrollView
171 horizontal
172 keyboardShouldPersistTaps="always"
173 onContentSizeChange={handleContentSizeChange}
174 onLayout={handleLayout}
175 onScroll={handleScroll}
176 scrollEventThrottle={16}
177 showsHorizontalScrollIndicator={false}
178 contentContainerStyle={{
179 alignItems: "center",
180 flexGrow: props.align === "end" ? 1 : undefined,
181 justifyContent: props.align === "end" ? "flex-end" : undefined,
182 gap: COMPOSER_TOOLBAR_GAP,
183 paddingLeft: 0,
184 paddingRight: props.contentPaddingRight ?? 1,
185 }}
186 >
187 {props.children}
188 </ScrollView>
189 {scrollEdges.showLeftFade ? (
190 <View
191 className={cn(
192 "absolute inset-y-0 left-0",
193 props.fadeOpaque === undefined && "bg-linear-to-r to-transparent",
194 props.fadeOpaque === undefined &&
195 (props.fadeSurface === "sheet" ? "from-sheet" : "from-card"),
196 )}
197 pointerEvents="none"
198 style={{
199 width: COMPOSER_TOOLBAR_FADE_WIDTH,
200 experimental_backgroundImage:
201 props.fadeOpaque === undefined
202 ? undefined
203 : `linear-gradient(to right, ${props.fadeOpaque} 0%, ${props.fadeTransparent ?? "transparent"} 100%)`,
204 }}
205 />
206 ) : null}
207 {scrollEdges.showRightFade ? (
208 <View
209 className={cn(
210 "absolute inset-y-0 right-0",
211 props.fadeOpaque === undefined && "bg-linear-to-r from-transparent",
212 props.fadeOpaque === undefined &&
213 (props.fadeSurface === "sheet" ? "to-sheet" : "to-card"),
214 )}
215 pointerEvents="none"
216 style={{
217 width: COMPOSER_TOOLBAR_FADE_WIDTH,
218 experimental_backgroundImage:
219 props.fadeOpaque === undefined
220 ? undefined
221 : `linear-gradient(to right, ${props.fadeTransparent ?? "transparent"} 0%, ${props.fadeOpaque} 100%)`,
222 }}
223 />
224 ) : null}
225 </View>
226 );
227}
228
229export function ComposerActionButton(props: {
230 readonly accessibilityLabel: string;
231 readonly disabled?: boolean;
232 readonly icon: ComponentProps<typeof SymbolView>["name"];
233 readonly onPress: () => void;
234 readonly variant?: "primary" | "danger";
235 // Forwarded so a ControlPillMenu can drive this button as its long-press
236 // anchor: Android injects onLongPress, iOS injects onTouchStart and onPress.
237 readonly onLongPress?: PressableProps["onLongPress"];
238 readonly onTouchStart?: PressableProps["onTouchStart"];
239}) {
240 const { scale, smallIconSize } = useAndroidControlSizing();
241 const circleSize = Math.round(30 * scale);
242 return (
243 <Pressable
244 accessibilityLabel={props.accessibilityLabel}
245 accessibilityRole="button"
246 accessibilityState={{ disabled: props.disabled }}
247 className="size-[44px] shrink-0 items-center justify-center active:opacity-70"
248 disabled={props.disabled}
249 onPress={props.onPress}
250 onLongPress={props.onLongPress}
251 onTouchStart={props.onTouchStart}
252 >
253 <View
254 style={{ width: circleSize, height: circleSize }}
255 className={cn(
256 "items-center justify-center rounded-full",
257 props.variant === "danger"
258 ? "bg-danger"
259 : props.disabled
260 ? "bg-primary/15"
261 : "bg-primary",
262 )}
263 >
264 <SymbolView
265 name={props.icon}
266 size={smallIconSize}
267 weight="semibold"
268 tintColorClassName={
269 props.variant === "danger" ? "accent-danger-foreground" : "accent-primary-foreground"
270 }
271 type="monochrome"
272 />
273 </View>
274 </Pressable>
275 );
276}
277
278export function ComposerToolbarButton(props: {
279 readonly icon?: ComponentProps<typeof SymbolView>["name"];
280 readonly iconNode?: ReactNode;
281 readonly label?: string;
282 readonly accessibilityLabel?: string;
283 readonly active?: boolean;
284 readonly disabled?: boolean;
285 readonly maxWidth?: number;
286 readonly minWidth?: number;
287 readonly onPress?: () => void;
288 readonly showChevron?: boolean;
289 readonly textTransform?: "none" | "uppercase";
290 readonly variant?: "default" | "primary" | "danger";
291 readonly className?: string;
292 readonly style?: StyleProp<ViewStyle>;
293}) {
294 const variant = props.variant ?? "default";
295 const isCircle = !props.label && props.showChevron === false;
296 const iconTintClassName =
297 variant === "primary"
298 ? props.disabled
299 ? "accent-icon-subtle"
300 : "accent-primary-foreground"
301 : variant === "danger"
302 ? "accent-danger-foreground"
303 : "accent-icon";
304
305 return (
306 <Pressable
307 accessibilityLabel={props.accessibilityLabel ?? props.label}
308 accessibilityRole="button"
309 disabled={props.disabled}
310 onPress={props.onPress}
311 className={cn(
312 // Default width cap lives in the class chain (not the inline style)
313 // so callers can lift it with max-w-full — flex-filling pills in the
314 // thread composer stretch to the row's edge. The numeric maxWidth
315 // prop still wins via the inline style below.
316 "h-11 max-w-[172px] flex-row items-center justify-center rounded-full border active:opacity-70",
317 variant === "primary" && "shadow-lg shadow-adaptive-black-a10-a25 disabled:shadow-none",
318 isCircle ? "w-11" : "gap-2 px-3.5",
319 variant === "primary"
320 ? props.disabled
321 ? "bg-subtle-strong"
322 : "bg-primary"
323 : variant === "danger"
324 ? "bg-danger"
325 : props.active
326 ? "bg-subtle-strong"
327 : "bg-subtle",
328 variant === "default"
329 ? props.active
330 ? "border-border"
331 : "border-border-subtle"
332 : variant === "danger"
333 ? "border-danger-border"
334 : props.disabled
335 ? "border-border-subtle"
336 : "border-primary-foreground/20",
337 props.className,
338 )}
339 style={({ pressed }) => [
340 {
341 maxWidth: props.maxWidth,
342 minWidth: props.minWidth,
343 opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1,
344 },
345 props.style,
346 ]}
347 >
348 {props.iconNode ? (
349 <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View>
350 ) : props.icon ? (
351 <SymbolView
352 name={props.icon}
353 size={16}
354 tintColorClassName={iconTintClassName}
355 type="monochrome"
356 />
357 ) : null}
358 {props.label ? (
359 <Text
360 className={cn(
361 "shrink text-center text-sm font-t3-bold",
362 variant === "primary"
363 ? props.disabled
364 ? "text-foreground-muted"
365 : "text-primary-foreground"
366 : "text-foreground",
367 )}
368 ellipsizeMode="tail"
369 numberOfLines={1}
370 style={{ textTransform: props.textTransform ?? "none" }}
371 >
372 {props.label}
373 </Text>
374 ) : null}
375 {props.showChevron === false ? null : (
376 <SymbolView
377 name="chevron.down"
378 size={11}
379 tintColorClassName={iconTintClassName}
380 type="monochrome"
381 />
382 )}
383 </Pressable>
384 );
385}