{"slug":"citations","name":"Citations","description":"Tap a number and its source springs up.","group":"AI agents","detail_url":"https://beui.dev/native/r/citations","page_url":"https://beui.dev/react-native/citations","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/citations?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/citations","libraries":["react","react-native","react-native-reanimated","react-native-svg","react-native-worklets","uniwind"],"install":["npx expo install react-native-reanimated react-native-svg react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/citations/citations.tsx","content":"import { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport {\n  Image,\n  Linking,\n  Modal,\n  Pressable,\n  StyleSheet,\n  Text,\n  useWindowDimensions,\n  View,\n  type ViewProps,\n} from \"react-native\";\nimport Animated, {\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\nimport {\n  BookGlyph,\n  ChevronDownGlyph,\n  ExternalGlyph,\n  GlobeGlyph,\n} from \"../../primitives/agent-icons\";\nimport { Disclosure } from \"../../primitives/disclosure\";\nimport { RiseIn } from \"../../primitives/rise-in\";\nimport { EASE_OUT, SPRING_SWAP } from \"../../styles/motion\";\n\nexport type CitationItem = {\n  id: string;\n  title: string;\n  domain?: string;\n  /** Opens on tap, and supplies the favicon. */\n  url?: string;\n  /** Replaces the favicon, such as a document icon for files. */\n  icon?: ReactNode;\n};\n\nconst SPRING_POP = {\n  stiffness: 420,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n};\nconst CARD_EXIT = { duration: 140, easing: EASE_OUT };\nconst CARD_WIDTH = 288;\nconst EDGE = 16;\nconst GAP = 8;\n\nfunction faviconFor(url?: string) {\n  if (!url) return null;\n  try {\n    return new URL(\"/favicon.ico\", url).toString();\n  } catch {\n    return null;\n  }\n}\n\nexport type CitationFaviconProps = {\n  url?: string;\n  icon?: ReactNode;\n  size?: number;\n};\n\n/** A source's favicon, its custom icon, or a globe when neither loads. */\nexport function CitationFavicon({\n  url,\n  icon,\n  size = 16,\n}: CitationFaviconProps) {\n  const [failed, setFailed] = useState<string | null>(null);\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const src = faviconFor(url);\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      style={[styles.favicon, { width: size + 4, height: size + 4 }]}\n    >\n      {icon ? (\n        icon\n      ) : src && src !== failed ? (\n        <Image\n          source={{ uri: src }}\n          onError={() => setFailed(src)}\n          style={{ width: size, height: size, borderRadius: 3 }}\n        />\n      ) : (\n        <GlobeGlyph size={size - 2} color={muted} />\n      )}\n    </View>\n  );\n}\n\nexport type CitationStackProps = { citations: CitationItem[]; limit?: number };\n\n/** Up to `limit` (3) overlapping favicons, as a compact hint of the sources. */\nexport function CitationStack({ citations, limit = 3 }: CitationStackProps) {\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no-hide-descendants\"\n      style={styles.stack}\n    >\n      {citations.slice(0, limit).map((item, index) => (\n        <View\n          key={item.id}\n          style={[styles.stackItem, index > 0 && styles.overlap]}\n          className=\"border-2 border-background bg-background\"\n        >\n          <CitationFavicon url={item.url} icon={item.icon} size={14} />\n        </View>\n      ))}\n    </View>\n  );\n}\n\nfunction open(item: CitationItem, onOpenSource?: (item: CitationItem) => void) {\n  if (onOpenSource) onOpenSource(item);\n  else if (item.url) void Linking.openURL(item.url);\n}\n\ntype Anchor = { x: number; y: number; width: number; height: number };\n\n/** The card a tapped marker springs up into. */\nfunction SourceCard({\n  item,\n  index,\n  anchor,\n  visible,\n  reduce,\n  onOpenSource,\n  onClose,\n}: {\n  item: CitationItem;\n  index: number;\n  anchor: Anchor | null;\n  visible: boolean;\n  reduce: boolean;\n  onOpenSource?: (item: CitationItem) => void;\n  onClose: () => void;\n}) {\n  const window = useWindowDimensions();\n  const [present, setPresent] = useState(visible);\n  const [height, setHeight] = useState(0);\n  const progress = useSharedValue(0);\n  if (visible && !present) setPresent(true);\n  const hide = () => setPresent(false);\n\n  useEffect(() => {\n    if (!present) return;\n    if (visible)\n      progress.value = reduce\n        ? withTiming(1, { duration: 120 })\n        : withSpring(1, SPRING_POP);\n    else\n      progress.value = withTiming(\n        0,\n        reduce ? { duration: 120 } : CARD_EXIT,\n        (done) => {\n          if (done) scheduleOnRN(hide);\n        },\n      );\n  }, [present, progress, reduce, visible]);\n\n  const style = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      opacity: Math.min(1, p * 1.5),\n      transform: reduce ? [] : [{ scale: 0.6 + p * 0.4 }],\n    };\n  }, [reduce]);\n\n  if (!present || !anchor) return null;\n  const width = Math.min(CARD_WIDTH, window.width - EDGE * 2);\n  const left = Math.max(\n    EDGE,\n    Math.min(\n      anchor.x + anchor.width / 2 - width / 2,\n      window.width - width - EDGE,\n    ),\n  );\n  // Above the marker when there's room, otherwise below it.\n  const above = anchor.y - GAP - height > EDGE + 40;\n  const top = above ? anchor.y - GAP - height : anchor.y + anchor.height + GAP;\n  const originX = anchor.x + anchor.width / 2 - left;\n\n  return (\n    <Modal\n      transparent\n      visible\n      animationType=\"none\"\n      statusBarTranslucent\n      navigationBarTranslucent\n      onRequestClose={onClose}\n    >\n      <Pressable\n        accessibilityLabel=\"Close source\"\n        style={StyleSheet.absoluteFill}\n        onPress={onClose}\n      />\n      <Animated.View\n        accessibilityViewIsModal\n        onLayout={({ nativeEvent }) => setHeight(nativeEvent.layout.height)}\n        style={[\n          styles.card,\n          {\n            left,\n            top,\n            width,\n            transformOrigin: [originX, above ? \"100%\" : \"0%\", 0],\n          },\n          style,\n        ]}\n        className=\"gap-3 rounded-2xl border border-border bg-background p-3\"\n      >\n        <View style={styles.cardRow}>\n          <CitationFavicon url={item.url} icon={item.icon} size={20} />\n          <View style={styles.flex}>\n            <Text\n              numberOfLines={2}\n              className=\"text-sm font-medium text-foreground\"\n            >\n              {item.title}\n            </Text>\n            {item.domain ? (\n              <Text numberOfLines={1} className=\"text-xs text-muted\">\n                {item.domain}\n              </Text>\n            ) : null}\n          </View>\n          <Text\n            className=\"text-[10px] font-semibold text-muted\"\n            style={styles.tabular}\n          >\n            {index}\n          </Text>\n        </View>\n        {item.url || onOpenSource ? (\n          <Pressable\n            accessibilityRole=\"link\"\n            onPress={() => {\n              onClose();\n              open(item, onOpenSource);\n            }}\n            style={styles.openButton}\n            className=\"rounded-xl bg-primary active:opacity-80\"\n          >\n            <Text className=\"text-sm font-medium text-on-primary\">\n              Open source\n            </Text>\n          </Pressable>\n        ) : null}\n      </Animated.View>\n    </Modal>\n  );\n}\n\nexport type CitationProps = {\n  item: CitationItem;\n  /** The number shown, usually the source's position starting at 1. */\n  index: number;\n  /** Replaces opening the URL. */\n  onOpenSource?: (item: CitationItem) => void;\n  /** Defaults to \"Citation {index}: {title}\". */\n  label?: string;\n  disableAnimation?: boolean;\n};\n\n/**\n * An inline citation number. Tapping it springs a card up out of the marker\n * with the source and a button to open it, so you never lose your place.\n */\nexport function Citation({\n  item,\n  index,\n  onOpenSource,\n  label,\n  disableAnimation = false,\n}: CitationProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const ref = useRef<View>(null);\n  const [anchor, setAnchor] = useState<Anchor | null>(null);\n  const [visible, setVisible] = useState(false);\n\n  return (\n    // One inline view: the card's modal lives inside the marker so it adds\n    // nothing to the line of text around it.\n    <Pressable\n      ref={ref}\n      accessibilityRole=\"button\"\n      accessibilityLabel={label ?? `Citation ${index}: ${item.title}`}\n      hitSlop={10}\n      onPress={() =>\n        ref.current?.measureInWindow((x, y, width, height) => {\n          setAnchor({ x, y, width, height });\n          setVisible(true);\n        })\n      }\n      style={styles.marker}\n      className=\"rounded-md bg-surface active:bg-border-strong\"\n    >\n      <Text\n        className=\"text-[10px] font-semibold text-muted\"\n        style={styles.tabular}\n      >\n        {index}\n      </Text>\n      <SourceCard\n        item={item}\n        index={index}\n        anchor={anchor}\n        visible={visible}\n        reduce={reduce}\n        onOpenSource={onOpenSource}\n        onClose={() => setVisible(false)}\n      />\n    </Pressable>\n  );\n}\n\nfunction CitationRow({\n  item,\n  index,\n  reduce,\n  onOpenSource,\n}: {\n  item: CitationItem;\n  index: number;\n  reduce: boolean;\n  onOpenSource?: (item: CitationItem) => void;\n}) {\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const tappable = Boolean(item.url || onOpenSource);\n  return (\n    <RiseIn reduce={reduce}>\n      <Pressable\n        accessibilityRole={tappable ? \"link\" : undefined}\n        accessibilityLabel={`${index}. ${item.title}${item.domain ? `, ${item.domain}` : \"\"}`}\n        disabled={!tappable}\n        onPress={() => open(item, onOpenSource)}\n        style={styles.row}\n        className=\"rounded-lg px-1.5 py-1.5 active:bg-surface\"\n      >\n        <CitationFavicon url={item.url} icon={item.icon} />\n        <View style={styles.flex}>\n          <Text\n            numberOfLines={1}\n            className=\"text-sm font-medium text-foreground/80\"\n          >\n            {item.title}\n          </Text>\n          {item.domain ? (\n            <Text numberOfLines={1} className=\"text-xs text-muted/70\">\n              {item.domain}\n            </Text>\n          ) : null}\n        </View>\n        <View style={styles.badge} className=\"rounded-md bg-foreground/5\">\n          <Text\n            className=\"text-[10px] font-semibold text-muted\"\n            style={styles.tabular}\n          >\n            {index}\n          </Text>\n        </View>\n        {tappable ? <ExternalGlyph size={14} color={muted} /> : null}\n      </Pressable>\n    </RiseIn>\n  );\n}\n\nexport type CitationListProps = {\n  citations: CitationItem[];\n  onOpenSource?: (item: CitationItem) => void;\n  disableAnimation?: boolean;\n};\n\n/** The sources as rows that rise in as they arrive. Tap one to open it. */\nexport function CitationList({\n  citations,\n  onOpenSource,\n  disableAnimation = false,\n}: CitationListProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  return (\n    <View className=\"gap-0.5\">\n      {citations.map((item, index) => (\n        <CitationRow\n          key={item.id}\n          item={item}\n          index={index + 1}\n          reduce={reduce}\n          onOpenSource={onOpenSource}\n        />\n      ))}\n    </View>\n  );\n}\n\nexport type CitationsProps = Omit<ViewProps, \"children\"> & {\n  citations: CitationItem[];\n  /** Defaults to \"Sources\". */\n  title?: string;\n  open?: boolean;\n  /** Defaults to false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  onOpenSource?: (item: CitationItem) => void;\n  className?: string;\n  disableAnimation?: boolean;\n};\n\n/** A \"Sources\" toggle with a count that folds open into the source list. */\nexport function Citations({\n  citations,\n  title = \"Sources\",\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  onOpenSource,\n  className = \"\",\n  disableAnimation = false,\n  ...props\n}: CitationsProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const isOpen = controlledOpen ?? internalOpen;\n  const turn = useSharedValue(isOpen ? 1 : 0);\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n\n  useEffect(() => {\n    turn.value = reduce\n      ? isOpen\n        ? 1\n        : 0\n      : withSpring(isOpen ? 1 : 0, SPRING_SWAP);\n  }, [isOpen, reduce, turn]);\n  const chevronStyle = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${turn.value * 180}deg` }],\n  }));\n\n  const toggle = () => {\n    if (controlledOpen === undefined) setInternalOpen(!isOpen);\n    onOpenChange?.(!isOpen);\n  };\n\n  return (\n    <View className={`w-full ${className}`} {...props}>\n      <Pressable\n        accessibilityRole=\"button\"\n        accessibilityState={{ expanded: isOpen }}\n        accessibilityLabel={`${title}, ${citations.length}`}\n        onPress={toggle}\n        hitSlop={6}\n        style={styles.trigger}\n      >\n        <BookGlyph size={16} color={muted} />\n        <Text className=\"text-sm font-medium text-muted\">{title}</Text>\n        <View className=\"rounded-full bg-surface px-1.5 py-0.5\">\n          <Text\n            className=\"text-[10px] font-semibold text-muted\"\n            style={styles.tabular}\n          >\n            {citations.length}\n          </Text>\n        </View>\n        <Animated.View style={chevronStyle}>\n          <ChevronDownGlyph size={14} color={muted} />\n        </Animated.View>\n      </Pressable>\n      <Disclosure open={isOpen} reduce={reduce}>\n        <View className=\"mt-1\">\n          <CitationList\n            citations={citations}\n            onOpenSource={onOpenSource}\n            disableAnimation={disableAnimation}\n          />\n        </View>\n      </Disclosure>\n    </View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  flex: { flex: 1, minWidth: 0 },\n  favicon: { alignItems: \"center\", justifyContent: \"center\" },\n  stack: { flexDirection: \"row\" },\n  stackItem: {\n    width: 24,\n    height: 24,\n    borderRadius: 12,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    overflow: \"hidden\",\n  },\n  overlap: { marginLeft: -6 },\n  marker: {\n    minWidth: 18,\n    height: 18,\n    paddingHorizontal: 4,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    transform: [{ translateY: -2 }],\n  },\n  card: {\n    position: \"absolute\",\n    shadowColor: \"#000\",\n    shadowOpacity: 0.12,\n    shadowRadius: 24,\n    shadowOffset: { width: 0, height: 10 },\n    elevation: 8,\n  },\n  cardRow: { flexDirection: \"row\", alignItems: \"flex-start\", gap: 10 },\n  openButton: { minHeight: 40, alignItems: \"center\", justifyContent: \"center\" },\n  row: { flexDirection: \"row\", alignItems: \"center\", gap: 8, minHeight: 44 },\n  badge: {\n    width: 20,\n    height: 20,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  trigger: {\n    flexDirection: \"row\",\n    alignItems: \"center\",\n    gap: 8,\n    minHeight: 36,\n    alignSelf: \"flex-start\",\n  },\n  tabular: { fontVariant: [\"tabular-nums\"] },\n});\n","type":"component"},{"path":"src/components/citations/index.ts","content":"export type {\n  CitationFaviconProps,\n  CitationItem,\n  CitationListProps,\n  CitationProps,\n  CitationStackProps,\n  CitationsProps,\n} from \"./citations\";\nexport {\n  Citation,\n  CitationFavicon,\n  CitationList,\n  CitationStack,\n  Citations,\n} from \"./citations\";\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/primitives/agent-icons.tsx","content":"import type { ReactNode } from \"react\";\nimport type { ColorValue } from \"react-native\";\nimport Svg, { Circle, Path, Rect } from \"react-native-svg\";\n\ntype GlyphProps = { size?: number; color?: ColorValue; strokeWidth?: number };\n\nfunction Glyph({\n  size = 16,\n  color = \"currentColor\",\n  strokeWidth = 2,\n  children,\n}: GlyphProps & { children: ReactNode }) {\n  return (\n    <Svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke={color}\n      strokeWidth={strokeWidth}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      {children}\n    </Svg>\n  );\n}\n\n// Simple 24-unit stroke glyphs shared by the agent components.\n\nexport function ChevronDownGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M6 9l6 6 6-6\" />\n    </Glyph>\n  );\n}\n\nexport function ArrowUpGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 19V5M5.5 11.5L12 5l6.5 6.5\" />\n    </Glyph>\n  );\n}\n\nexport function PlusGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 5v14M5 12h14\" />\n    </Glyph>\n  );\n}\n\nexport function StopGlyph({ size = 12, color }: GlyphProps) {\n  return (\n    <Svg width={size} height={size} viewBox=\"0 0 12 12\">\n      <Rect x={1} y={1} width={10} height={10} rx={2.5} fill={color} />\n    </Svg>\n  );\n}\n\nexport function CheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M5 12.5l4.5 4.5L19 7\" />\n    </Glyph>\n  );\n}\n\nexport function CopyGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={8.5} y={8.5} width={12} height={12} rx={2.5} />\n      <Path d=\"M15.5 5.5v-.5A2 2 0 0 0 13.5 3h-8.5A2 2 0 0 0 3 5v8.5a2 2 0 0 0 2 2h.5\" />\n    </Glyph>\n  );\n}\n\nexport function RetryGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M3.5 12a8.5 8.5 0 1 0 2.6-6.1L3.5 8.5\" />\n      <Path d=\"M3.5 3.5v5h5\" />\n    </Glyph>\n  );\n}\n\nexport function ThumbGlyph({\n  down = false,\n  ...props\n}: GlyphProps & { down?: boolean }) {\n  return (\n    <Glyph {...props}>\n      <Path\n        d=\"M7.5 10.5v10M7.5 10.5L11 3.5a2.5 2.5 0 0 1 2.5 2.5v3.5h5a2 2 0 0 1 2 2.3l-1.2 7A2 2 0 0 1 17.3 20.5H4.5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z\"\n        transform={down ? \"rotate(180 12 12)\" : undefined}\n      />\n    </Glyph>\n  );\n}\n\nexport function SearchGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={11} cy={11} r={6.5} />\n      <Path d=\"M16 16l4 4\" />\n    </Glyph>\n  );\n}\n\nexport function GlobeGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M3.5 12h17M12 3.5c2.4 2.5 3.5 5.3 3.5 8.5s-1.1 6-3.5 8.5c-2.4-2.5-3.5-5.3-3.5-8.5s1.1-6 3.5-8.5z\" />\n    </Glyph>\n  );\n}\n\nexport function FileGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14 3.5H7.5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8z\" />\n      <Path d=\"M14 3.5V8h4.5M9 13h6M9 16.5h4\" />\n    </Glyph>\n  );\n}\n\nexport function PencilGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 20h4L19 9a2.1 2.1 0 0 0-4-4L4 16z\" />\n      <Path d=\"M13.5 6.5l4 4M14 20h6\" />\n    </Glyph>\n  );\n}\n\nexport function TerminalGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />\n      <Path d=\"M7.5 10l2.5 2-2.5 2M12.5 15h4\" />\n    </Glyph>\n  );\n}\n\nexport function WrenchGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14.5 6.5a4 4 0 0 0 5 5l-8.8 8.8a2.1 2.1 0 0 1-3-3l8.8-8.8a4 4 0 0 1-2-2z\" />\n    </Glyph>\n  );\n}\n\nexport function SparkleGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 4l1.8 4.6L18.5 10.5l-4.7 1.9L12 17l-1.8-4.6-4.7-1.9 4.7-1.9z\" />\n      <Path d=\"M18.5 16.5v3M17 18h3\" />\n    </Glyph>\n  );\n}\n\nexport function MessageGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M20 14.5a2 2 0 0 1-2 2H8.5L4 20V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z\" />\n    </Glyph>\n  );\n}\n\nexport function ImageGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={3.5} y={4.5} width={17} height={15} rx={2.5} />\n      <Circle cx={9} cy={10} r={1.5} />\n      <Path d=\"M20.5 15.5l-4.5-4.5-9 8.5\" />\n    </Glyph>\n  );\n}\n\nexport function ShieldGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 3.5l7 2.5v5.5c0 4.4-3 7.7-7 9-4-1.3-7-4.6-7-9V6z\" />\n      <Path d=\"M9 12l2 2 4-4\" />\n    </Glyph>\n  );\n}\n\nexport function AlertGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M12 8v4.5M12 16h.01\" />\n    </Glyph>\n  );\n}\n\nexport function CrossGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M6.5 6.5l11 11M17.5 6.5l-11 11\" />\n    </Glyph>\n  );\n}\n\nexport function HelpGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M9.6 9.5a2.5 2.5 0 0 1 4.8 1c0 1.7-2.4 2.2-2.4 3.5M12 16.8h.01\" />\n    </Glyph>\n  );\n}\n\nexport function ArrowGlyph({\n  back = false,\n  ...props\n}: GlyphProps & { back?: boolean }) {\n  return (\n    <Glyph {...props}>\n      <Path\n        d=\"M5 12h14M13 6l6 6-6 6\"\n        transform={back ? \"rotate(180 12 12)\" : undefined}\n      />\n    </Glyph>\n  );\n}\n\nexport function BracesGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M8.5 4H8a2 2 0 0 0-2 2v3.5A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5V18a2 2 0 0 0 2 2h.5M15.5 4h.5a2 2 0 0 1 2 2v3.5a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5V18a2 2 0 0 1-2 2h-.5\" />\n    </Glyph>\n  );\n}\n\nexport function BanGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M6 6l12 12\" />\n    </Glyph>\n  );\n}\n\nexport function CircleCheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M8.5 12.2l2.4 2.4 4.6-4.8\" />\n    </Glyph>\n  );\n}\n\nexport function CircleCrossGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M9.5 9.5l5 5M14.5 9.5l-5 5\" />\n    </Glyph>\n  );\n}\n\nexport function ListGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 6.5l1.5 1.5L8 5.5M4 13l1.5 1.5L8 12M11.5 7h8.5M11.5 13.5h8.5M11.5 19h8.5M4.5 19h2\" />\n    </Glyph>\n  );\n}\n\n/** An open ring, spun by SpinGlyph. */\nexport function RingGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M20.5 12a8.5 8.5 0 1 1-6-8.1\" />\n    </Glyph>\n  );\n}\n\nexport function ReplyGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M9.5 7L4.5 12l5 5M4.5 12h10a5 5 0 0 1 5 5v1\" />\n    </Glyph>\n  );\n}\n\nexport function WrapGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M4 6.5h16M4 12h12.5a3.25 3.25 0 0 1 0 6.5H12M4 18.5h4.5M14 16.5l-2 2 2 2\" />\n    </Glyph>\n  );\n}\n\nexport function BookGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M12 6.5c-1.6-1.3-4-2-7.5-2v13c3.5 0 5.9.7 7.5 2 1.6-1.3 4-2 7.5-2v-13c-3.5 0-5.9.7-7.5 2zM12 6.5v13\" />\n    </Glyph>\n  );\n}\n\nexport function ExternalGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M14 4.5h5.5V10M19.5 4.5L11 13M17 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h4\" />\n    </Glyph>\n  );\n}\n\nexport function DoubleCheckGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Path d=\"M2.5 12.5l4.5 4.5 9-9.5M12 16.5l.5.5 9-9.5\" />\n    </Glyph>\n  );\n}\n\nexport function ClockGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Circle cx={12} cy={12} r={8.5} />\n      <Path d=\"M12 7.5V12l3 2\" />\n    </Glyph>\n  );\n}\n\nexport function MicGlyph(props: GlyphProps) {\n  return (\n    <Glyph {...props}>\n      <Rect x={9} y={3.5} width={6} height={11} rx={3} />\n      <Path d=\"M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5v3\" />\n    </Glyph>\n  );\n}\n","type":"shared"},{"path":"src/primitives/disclosure.tsx","content":"import { type ReactNode, useEffect, useRef } from \"react\";\nimport { StyleSheet, View, type ViewStyle } from \"react-native\";\nimport Animated, {\n  measure,\n  useAnimatedRef,\n  useAnimatedStyle,\n  useSharedValue,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnUI } from \"react-native-worklets\";\nimport { EASE_OUT } from \"../styles/motion\";\n\nconst OPEN = { duration: 220, easing: EASE_OUT };\nconst CLOSE = { duration: 140, easing: EASE_OUT };\nconst DROP = 4;\n// The height while settled open: unconstrained, so growing content just flows.\nconst NATURAL = -1;\n\nexport type DisclosureProps = {\n  open: boolean;\n  /** Snaps open and closed, keeping only the fade. */\n  reduce: boolean;\n  /** Caps the open height; content clips beyond it. */\n  maxHeight?: number;\n  style?: ViewStyle;\n  children: ReactNode;\n};\n\n/**\n * Collapsible content shared by the agent components: the height eases\n * between closed and the content's size while the content fades and drops 4\n * points into place. Settled open, the height is left alone, so content that\n * grows (streamed rows, new sources) is never clipped. Closed content is\n * hidden from screen readers and touches.\n */\nexport function Disclosure({\n  open,\n  reduce,\n  maxHeight,\n  style,\n  children,\n}: DisclosureProps) {\n  // The content's height, kept current by layout events; measuring on demand\n  // can read 0 for content inside a scroll view.\n  const contentHeight = useSharedValue(0);\n  const clipRef = useAnimatedRef<Animated.View>();\n  const height = useSharedValue(open ? NATURAL : 0);\n  const shown = useSharedValue(open ? 1 : 0);\n  const mounted = useRef(false);\n\n  useEffect(() => {\n    // The first render is already in place.\n    if (!mounted.current) {\n      mounted.current = true;\n      return;\n    }\n    const cap = maxHeight ?? Number.POSITIVE_INFINITY;\n    scheduleOnUI(() => {\n      \"worklet\";\n      // Real sizes, read on the UI thread at the moment of the change.\n      const full = Math.min(contentHeight.value, cap);\n      if (open) {\n        const from = height.value === NATURAL ? full : height.value;\n        if (reduce) {\n          height.value = NATURAL;\n        } else {\n          height.value = from;\n          height.value = withTiming(full, OPEN, (done) => {\n            if (done) height.value = NATURAL;\n          });\n        }\n      } else {\n        const clip = measure(clipRef);\n        const from =\n          height.value === NATURAL ? (clip?.height ?? full) : height.value;\n        height.value = from;\n        height.value = reduce ? 0 : withTiming(0, CLOSE);\n      }\n    });\n    shown.value = withTiming(\n      open ? 1 : 0,\n      reduce ? { duration: 0 } : open ? OPEN : CLOSE,\n    );\n  }, [clipRef, contentHeight, height, maxHeight, open, reduce, shown]);\n\n  const clipStyle = useAnimatedStyle(() =>\n    // Settled open, the clip follows the content's measured height.\n    height.value === NATURAL\n      ? {\n          height: Math.min(\n            contentHeight.value,\n            maxHeight ?? Number.POSITIVE_INFINITY,\n          ),\n        }\n      : { height: height.value },\n  );\n  const contentStyle = useAnimatedStyle(\n    () => ({\n      opacity: shown.value,\n      transform: [{ translateY: reduce ? 0 : (1 - shown.value) * -DROP }],\n    }),\n    [reduce],\n  );\n\n  return (\n    <Animated.View\n      ref={clipRef}\n      pointerEvents={open ? \"auto\" : \"none\"}\n      accessibilityElementsHidden={!open}\n      importantForAccessibility={open ? \"auto\" : \"no-hide-descendants\"}\n      style={[styles.clip, clipStyle, style]}\n    >\n      {/* Absolutely placed, so the content keeps its natural height while the\n          clip around it is short, and can be measured at any time. */}\n      <Animated.View style={[styles.content, contentStyle]}>\n        <View\n          style={styles.measure}\n          onLayout={({ nativeEvent }) => {\n            contentHeight.value = nativeEvent.layout.height;\n          }}\n        >\n          {children}\n        </View>\n      </Animated.View>\n    </Animated.View>\n  );\n}\n\nconst styles = StyleSheet.create({\n  clip: { overflow: \"hidden\" },\n  content: { position: \"absolute\", left: 0, right: 0, top: 0 },\n  measure: { flexShrink: 0 },\n});\n","type":"shared"},{"path":"src/primitives/rise-in.tsx","content":"import { type ReactNode, useEffect } from \"react\";\nimport type { ViewProps } from \"react-native\";\nimport Animated, {\n  LinearTransition,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"../styles/motion\";\n\nconst FADE = { duration: 180, easing: EASE_OUT };\nconst RISE = 6;\nconst EXIT_DURATION = 140;\n\n/** Leaving rows reverse their entrance: fade and lift 3 points away. */\nfunction riseOut() {\n  \"worklet\";\n  return {\n    initialValues: { opacity: 1, transform: [{ translateY: 0 }] },\n    animations: {\n      opacity: withTiming(0, { duration: EXIT_DURATION, easing: EASE_OUT }),\n      transform: [\n        {\n          translateY: withTiming(-3, {\n            duration: EXIT_DURATION,\n            easing: EASE_OUT,\n          }),\n        },\n      ],\n    },\n  };\n}\n\nconst GLIDE = LinearTransition.springify()\n  .stiffness(SPRING_LAYOUT.stiffness)\n  .damping(SPRING_LAYOUT.damping)\n  .mass(SPRING_LAYOUT.mass);\n\n/**\n * A row that fades and rises 6 points into place when it mounts, and lifts\n * away as it leaves, while its neighbours glide to make room. Shared by the agent activity lists.\n */\nexport function RiseIn({\n  reduce,\n  appear = true,\n  children,\n  style,\n  ...props\n}: ViewProps & { reduce: boolean; appear?: boolean; children: ReactNode }) {\n  const animate = appear && !reduce;\n  const opacity = useSharedValue(animate ? 0 : 1);\n  const lift = useSharedValue(animate ? RISE : 0);\n\n  // Plays once, on mount.\n  useEffect(() => {\n    if (!animate) return;\n    opacity.value = withTiming(1, FADE);\n    lift.value = withSpring(0, SPRING_LAYOUT);\n  }, []);\n\n  const rise = useAnimatedStyle(() => ({\n    opacity: opacity.value,\n    transform: [{ translateY: lift.value }],\n  }));\n\n  return (\n    <Animated.View\n      layout={reduce ? undefined : GLIDE}\n      exiting={reduce ? undefined : riseOut}\n      style={[rise, style]}\n      {...props}\n    >\n      {children}\n    </Animated.View>\n  );\n}\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n\n  /* Tailwind's default monospace stack isn't a font React Native can load. */\n  :root {\n    @variant ios {\n      --font-mono: Menlo;\n    }\n  }\n\n  :root {\n    @variant android {\n      --font-mono: monospace;\n    }\n  }\n}\n","type":"theme"}]}