| import type { ComponentMeta } from "../lib/meta"; | ||
| export const meta: ComponentMeta = { | ||
| slug: "chat", | ||
| title: "Chat", | ||
| category: "ai", | ||
| kind: "block", | ||
| description: | ||
| "The AI chat block — a complete, drop-in conversation: streaming assistant bubbles, a docked composer, reasoning, and starter prompts.", | ||
| anatomy: | ||
| "A full chat screen that composes StreamBubble (assistant turns), PromptInput (the composer), and ReasoningTrace (inline chain-of-thought) over a scrolling message list with user bubbles. Controlled by `messages` + `onSendMessage`. The header is a slot — default thin bar, `null` for immersive, or your own node. Reach for it when you want a finished AI chat instead of wiring the primitives by hand.", | ||
| delight: | ||
| "Magnetic stream-follow — while the assistant streams, the list eases to keep the newest line in view and releases the moment you scroll up; a scroll-to-bottom pill springs you back with a soft overshoot, and a success haptic fires the instant a reply settles.", | ||
| props: [ | ||
| { name: "messages", type: "ChatMessage[]", required: true, description: "The conversation. Each message has id, role, content, and (assistant-only) status/reasoning/tools." }, | ||
| { name: "onSendMessage", type: "(text: string) => void", required: true, description: "Called with the trimmed text when the user sends." }, | ||
| { name: "generating", type: "boolean", default: "false", description: "While true, the composer shows the stop control." }, | ||
| { name: "onStop", type: "() => void", description: "Called when the user taps stop while generating." }, | ||
| { name: "onRetry", type: "(id: string) => void", description: "Retry a failed (status: \"error\") assistant message." }, | ||
| { name: "onRegenerate", type: "(id: string) => void", description: "Regenerate an assistant message from its actions row." }, | ||
| { name: "starters", type: "string[]", description: "Empty-state suggested prompts; tapping one calls onSendMessage." }, | ||
| { name: "placeholder", type: "string", default: '"Message appCN…"', description: "Composer placeholder." }, | ||
| { name: "header", type: "React.ReactNode", description: "Header slot. undefined → default thin header; null → no header; node → custom." }, | ||
| { name: "title", type: "string", default: '"appCN"', description: "Title shown in the default header and empty state." }, | ||
| { name: "statusLabel", type: "string", description: "Status line under the title in the default header (e.g. \"Ready\")." }, | ||
| { name: "avatar", type: "React.ReactNode", description: "Avatar content for the default header disc." }, | ||
| { name: "onNewChat", type: "() => void", description: "When provided, the default header shows a + action that calls this." }, | ||
| { name: "emptyState", type: "React.ReactNode", description: "Override the default empty state entirely." }, | ||
| { name: "className", type: "string", description: "Extra NativeWind classes on the root container." }, | ||
| ], | ||
| examples: [ | ||
| { | ||
| title: "Basic", | ||
| description: "A controlled chat. You own the messages and append the assistant's reply.", | ||
| code: `const [messages, setMessages] = React.useState<ChatMessage[]>([]); | ||
| const [generating, setGenerating] = React.useState(false); | ||
| const send = (text: string) => { | ||
| setMessages((m) => [...m, { id: \`u\${Date.now()}\`, role: "user", content: text }]); | ||
| setGenerating(true); | ||
| const id = \`a\${Date.now()}\`; | ||
| setMessages((m) => [...m, { id, role: "assistant", content: "Sure — here's the answer.", status: "streaming" }]); | ||
| setTimeout(() => { | ||
| setMessages((m) => m.map((x) => (x.id === id ? { ...x, status: "done" } : x))); | ||
| setGenerating(false); | ||
| }, 1800); | ||
| }; | ||
| return ( | ||
| <Chat | ||
| messages={messages} | ||
| generating={generating} | ||
| onSendMessage={send} | ||
| starters={["What can you build?", "Show me a button", "Explain reanimated"]} | ||
| /> | ||
| );`, | ||
| }, | ||
| { | ||
| title: "Immersive (no header)", | ||
| description: "Pass header={null} for an edge-to-edge, chrome-free conversation.", | ||
| code: `<Chat header={null} messages={messages} onSendMessage={send} />`, | ||
| }, | ||
| ], | ||
| a11y: [ | ||
| "Every control (composer, send/stop, starter chips, scroll-to-bottom pill, copy/regenerate, retry) has an accessibilityRole and label, hitSlop, and visible press feedback.", | ||
| "Bubbles use accessibilityRole=\"text\" so each message is announced as one unit.", | ||
| "Status (thinking/streaming/done/error) is conveyed by labels and text, never color alone.", | ||
| "Honors useReducedMotion() — entrance animations and the magnetic follow fall back to instant positioning.", | ||
| "The composer rises above the keyboard (KeyboardAvoidingView) and respects the home indicator via safe-area insets.", | ||
| ], | ||
| addedAt: "2026-05-30", | ||
| }; |
+599
| import * as React from "react"; | ||
| import { | ||
| KeyboardAvoidingView, | ||
| Platform, | ||
| Pressable, | ||
| ScrollView, | ||
| Text, | ||
| View, | ||
| type NativeScrollEvent, | ||
| type NativeSyntheticEvent, | ||
| } from "react-native"; | ||
| import Animated, { | ||
| FadeIn, | ||
| FadeInDown, | ||
| useReducedMotion, | ||
| } from "react-native-reanimated"; | ||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| import * as Clipboard from "expo-clipboard"; | ||
| import { cn } from "../lib/cn"; | ||
| import { duration, easing, spring } from "../lib/motion"; | ||
| import { haptic } from "../lib/haptics"; | ||
| import { StreamBubble } from "./stream-bubble"; | ||
| import { PromptInput } from "./prompt-input"; | ||
| import { ReasoningTrace } from "./reasoning-trace"; | ||
| import { useColorScheme } from "nativewind"; | ||
| export type ChatRole = "user" | "assistant"; | ||
| export type ChatStatus = "thinking" | "streaming" | "done" | "error"; | ||
| export interface ChatMessage { | ||
| id: string; | ||
| role: ChatRole; | ||
| content: string; | ||
| /** assistant-only; default "done". */ | ||
| status?: ChatStatus; | ||
| /** assistant-only; collapsible chain-of-thought. */ | ||
| reasoning?: string; | ||
| /** assistant-only; tool chips above the message. */ | ||
| tools?: string[]; | ||
| } | ||
| export interface ChatProps { | ||
| messages: ChatMessage[]; | ||
| onSendMessage: (text: string) => void; | ||
| generating?: boolean; | ||
| onStop?: () => void; | ||
| onRetry?: (id: string) => void; | ||
| onRegenerate?: (id: string) => void; | ||
| starters?: string[]; | ||
| placeholder?: string; | ||
| /** Header slot. undefined → default header; null → none; node → custom. */ | ||
| header?: React.ReactNode; | ||
| title?: string; | ||
| statusLabel?: string; | ||
| avatar?: React.ReactNode; | ||
| onNewChat?: () => void; | ||
| emptyState?: React.ReactNode; | ||
| className?: string; | ||
| } | ||
| /** | ||
| * appCN Chat — the AI chat block. A complete, drop-in conversation: a scrolling | ||
| * message list (user bubbles + streaming assistant bubbles via StreamBubble), a | ||
| * docked PromptInput composer, thinking/streaming/error states, empty-state | ||
| * starter prompts, inline ReasoningTrace, and per-message actions. | ||
| * | ||
| * Delight detail: MAGNETIC STREAM-FOLLOW — while the assistant streams, the list | ||
| * eases to keep the newest line in view and releases the instant you scroll up; | ||
| * a scroll-to-bottom pill springs you back with a soft overshoot. | ||
| */ | ||
| export function Chat({ | ||
| messages, | ||
| onSendMessage, | ||
| generating = false, | ||
| onStop, | ||
| onRetry, | ||
| onRegenerate, | ||
| starters, | ||
| placeholder = "Message appCN…", | ||
| header, | ||
| title = "appCN", | ||
| statusLabel, | ||
| avatar, | ||
| onNewChat, | ||
| emptyState, | ||
| className, | ||
| }: ChatProps) { | ||
| const reduced = useReducedMotion(); | ||
| const insets = useSafeAreaInsets(); | ||
| const scrollRef = React.useRef<ScrollView>(null); | ||
| const atBottomRef = React.useRef(true); | ||
| const [showPill, setShowPill] = React.useState(false); | ||
| const isEmpty = messages.length === 0; | ||
| const lastAssistantId = React.useMemo(() => { | ||
| for (let i = messages.length - 1; i >= 0; i--) { | ||
| if (messages[i]!.role === "assistant") return messages[i]!.id; | ||
| } | ||
| return null; | ||
| }, [messages]); | ||
| const scrollToBottom = React.useCallback( | ||
| (animated = true) => { | ||
| scrollRef.current?.scrollToEnd({ animated: animated && !reduced }); | ||
| }, | ||
| [reduced] | ||
| ); | ||
| // Magnetic follow: only auto-scroll when the user is pinned to the bottom. | ||
| const onContentSizeChange = React.useCallback(() => { | ||
| if (atBottomRef.current) scrollToBottom(true); | ||
| }, [scrollToBottom]); | ||
| const onScroll = React.useCallback( | ||
| (e: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; | ||
| const distance = | ||
| contentSize.height - (contentOffset.y + layoutMeasurement.height); | ||
| const atBottom = distance < 24; | ||
| atBottomRef.current = atBottom; | ||
| setShowPill(!atBottom && contentSize.height > layoutMeasurement.height); | ||
| }, | ||
| [] | ||
| ); | ||
| // Completion haptic: fire success the moment the latest assistant settles. | ||
| const prevStatus = React.useRef<ChatStatus | undefined>(undefined); | ||
| React.useEffect(() => { | ||
| const last = messages[messages.length - 1]; | ||
| const status = last?.role === "assistant" ? last.status ?? "done" : undefined; | ||
| if (prevStatus.current === "streaming" && status === "done") { | ||
| haptic.success(); | ||
| } | ||
| prevStatus.current = status; | ||
| }, [messages]); | ||
| const headerEl = | ||
| header === undefined ? ( | ||
| <ChatHeader | ||
| title={title} | ||
| statusLabel={statusLabel ?? (generating ? "Thinking…" : "Ready")} | ||
| avatar={avatar} | ||
| onNewChat={onNewChat} | ||
| topInset={insets.top} | ||
| /> | ||
| ) : ( | ||
| header | ||
| ); | ||
| return ( | ||
| <KeyboardAvoidingView | ||
| behavior={Platform.OS === "ios" ? "padding" : undefined} | ||
| className={cn("flex-1 bg-background", className)} | ||
| > | ||
| {headerEl} | ||
| <View className="flex-1"> | ||
| {isEmpty ? ( | ||
| emptyState ?? ( | ||
| <EmptyState | ||
| title={title} | ||
| starters={starters} | ||
| onPick={onSendMessage} | ||
| /> | ||
| ) | ||
| ) : ( | ||
| <ScrollView | ||
| ref={scrollRef} | ||
| onScroll={onScroll} | ||
| onContentSizeChange={onContentSizeChange} | ||
| scrollEventThrottle={16} | ||
| keyboardShouldPersistTaps="handled" | ||
| style={{ flex: 1, minHeight: 0 }} | ||
| contentContainerStyle={{ | ||
| padding: 14, | ||
| gap: 12, | ||
| flexGrow: 1, | ||
| justifyContent: "flex-end", | ||
| }} | ||
| > | ||
| {messages.map((m) => | ||
| m.role === "user" ? ( | ||
| <UserBubble key={m.id} content={m.content} reduced={reduced} /> | ||
| ) : ( | ||
| <AssistantMessage | ||
| key={m.id} | ||
| message={m} | ||
| isLatest={m.id === lastAssistantId} | ||
| onRetry={onRetry} | ||
| onRegenerate={onRegenerate} | ||
| reduced={reduced} | ||
| /> | ||
| ) | ||
| )} | ||
| </ScrollView> | ||
| )} | ||
| {showPill ? ( | ||
| <ScrollToBottomPill onPress={() => scrollToBottom(true)} reduced={reduced} /> | ||
| ) : null} | ||
| </View> | ||
| <View | ||
| className="border-t border-border bg-background px-3 pt-2" | ||
| style={{ paddingBottom: Math.max(insets.bottom, 8) }} | ||
| > | ||
| <PromptInput | ||
| placeholder={placeholder} | ||
| generating={generating} | ||
| onSubmit={onSendMessage} | ||
| onStop={onStop} | ||
| /> | ||
| </View> | ||
| </KeyboardAvoidingView> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Header */ | ||
| /* ============================================================ */ | ||
| function ChatHeader({ | ||
| title, | ||
| statusLabel, | ||
| avatar, | ||
| onNewChat, | ||
| topInset, | ||
| }: { | ||
| title: string; | ||
| statusLabel: string; | ||
| avatar?: React.ReactNode; | ||
| onNewChat?: () => void; | ||
| topInset: number; | ||
| }) { | ||
| return ( | ||
| <View | ||
| className="flex-row items-center gap-3 border-b border-border bg-background px-4 pb-3" | ||
| style={{ paddingTop: topInset + 10 }} | ||
| > | ||
| <View className="h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary"> | ||
| {/* soft top tint gives the disc depth without a gradient dep */} | ||
| <View | ||
| aria-hidden | ||
| pointerEvents="none" | ||
| className="absolute inset-x-0 top-0 h-4 bg-white/15" | ||
| /> | ||
| {avatar ?? null} | ||
| </View> | ||
| <View className="flex-1"> | ||
| <Text className="text-[15px] font-semibold text-foreground">{title}</Text> | ||
| <Text className="text-[11px] text-muted-foreground">{statusLabel}</Text> | ||
| </View> | ||
| {onNewChat ? ( | ||
| <IconButton accessibilityLabel="New chat" onPress={onNewChat}> | ||
| <PlusGlyph /> | ||
| </IconButton> | ||
| ) : null} | ||
| </View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* User bubble */ | ||
| /* ============================================================ */ | ||
| function UserBubble({ | ||
| content, | ||
| reduced, | ||
| }: { | ||
| content: string; | ||
| reduced: boolean; | ||
| }) { | ||
| return ( | ||
| <Animated.View | ||
| entering={reduced ? undefined : FadeInDown.duration(duration.base).easing(easing.enter)} | ||
| style={{ alignSelf: "stretch", alignItems: "flex-end" }} | ||
| > | ||
| <View | ||
| accessibilityRole="text" | ||
| className="max-w-[82%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2.5" | ||
| > | ||
| <Text className="text-[15px] leading-5 text-primary-foreground">{content}</Text> | ||
| </View> | ||
| </Animated.View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Assistant message: reasoning + bubble/error + actions */ | ||
| /* ============================================================ */ | ||
| function AssistantMessage({ | ||
| message, | ||
| isLatest, | ||
| onRetry, | ||
| onRegenerate, | ||
| reduced, | ||
| }: { | ||
| message: ChatMessage; | ||
| isLatest: boolean; | ||
| onRetry?: (id: string) => void; | ||
| onRegenerate?: (id: string) => void; | ||
| reduced: boolean; | ||
| }) { | ||
| const status = message.status ?? "done"; | ||
| const active = isLatest && status !== "error"; | ||
| return ( | ||
| <Animated.View | ||
| entering={reduced ? undefined : FadeInDown.duration(duration.base).easing(easing.enter)} | ||
| className="w-full gap-2" | ||
| > | ||
| {message.reasoning ? ( | ||
| <ReasoningTrace | ||
| reasoning={message.reasoning} | ||
| thinking={status === "thinking" || status === "streaming"} | ||
| /> | ||
| ) : null} | ||
| {status === "error" ? ( | ||
| <ErrorBubble onRetry={onRetry ? () => onRetry(message.id) : undefined} /> | ||
| ) : ( | ||
| <View className="gap-1.5"> | ||
| <StreamBubble | ||
| content={message.content} | ||
| tools={message.tools} | ||
| replayKey={message.id} | ||
| animate={active} | ||
| /> | ||
| {status === "done" ? ( | ||
| <MessageActions | ||
| content={message.content} | ||
| onRegenerate={onRegenerate ? () => onRegenerate(message.id) : undefined} | ||
| /> | ||
| ) : null} | ||
| </View> | ||
| )} | ||
| </Animated.View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Error bubble + retry */ | ||
| /* ============================================================ */ | ||
| function ErrorBubble({ onRetry }: { onRetry?: () => void }) { | ||
| return ( | ||
| <View | ||
| accessibilityRole="text" | ||
| className="max-w-[85%] self-start rounded-2xl rounded-bl-md border border-destructive/40 bg-destructive/10 px-3.5 py-3" | ||
| > | ||
| <Text className="text-[14px] leading-5 text-foreground"> | ||
| Couldn't generate a reply. | ||
| </Text> | ||
| {onRetry ? ( | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel="Retry" | ||
| hitSlop={8} | ||
| onPress={() => { | ||
| haptic.medium(); | ||
| onRetry(); | ||
| }} | ||
| className="mt-1.5 self-start" | ||
| > | ||
| <Text className="text-[13px] font-semibold text-destructive">Retry</Text> | ||
| </Pressable> | ||
| ) : null} | ||
| </View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Message actions: copy (with "Copied" feedback) + regenerate */ | ||
| /* ============================================================ */ | ||
| function MessageActions({ | ||
| content, | ||
| onRegenerate, | ||
| }: { | ||
| content: string; | ||
| onRegenerate?: () => void; | ||
| }) { | ||
| const [copied, setCopied] = React.useState(false); | ||
| const copy = async () => { | ||
| haptic.selection(); | ||
| await Clipboard.setStringAsync(content); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 1400); | ||
| }; | ||
| return ( | ||
| <View className="flex-row items-center gap-3 pl-1"> | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel={copied ? "Copied" : "Copy message"} | ||
| hitSlop={8} | ||
| onPress={copy} | ||
| > | ||
| <Text className="text-[12px] font-medium text-muted-foreground"> | ||
| {copied ? "Copied" : "Copy"} | ||
| </Text> | ||
| </Pressable> | ||
| {onRegenerate ? ( | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel="Regenerate" | ||
| hitSlop={8} | ||
| onPress={() => { | ||
| haptic.selection(); | ||
| onRegenerate(); | ||
| }} | ||
| > | ||
| <Text className="text-[12px] font-medium text-muted-foreground"> | ||
| Regenerate | ||
| </Text> | ||
| </Pressable> | ||
| ) : null} | ||
| </View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Empty state + starter prompts */ | ||
| /* ============================================================ */ | ||
| function EmptyState({ | ||
| title, | ||
| starters, | ||
| onPick, | ||
| }: { | ||
| title: string; | ||
| starters?: string[]; | ||
| onPick: (text: string) => void; | ||
| }) { | ||
| return ( | ||
| <Animated.View | ||
| entering={FadeIn.duration(duration.slow)} | ||
| className="flex-1 items-center justify-center gap-5 px-6" | ||
| > | ||
| <View className="items-center gap-2"> | ||
| <View className="h-12 w-12 items-center justify-center overflow-hidden rounded-2xl bg-primary"> | ||
| <View aria-hidden pointerEvents="none" className="absolute inset-x-0 top-0 h-6 bg-white/15" /> | ||
| </View> | ||
| <Text className="text-lg font-semibold text-foreground">How can I help?</Text> | ||
| <Text className="text-center text-[13px] text-muted-foreground"> | ||
| Ask {title} anything to get started. | ||
| </Text> | ||
| </View> | ||
| {starters && starters.length > 0 ? ( | ||
| <View className="w-full gap-2"> | ||
| {starters.map((s, i) => ( | ||
| <StarterChip key={s} label={s} index={i} onPress={() => onPick(s)} /> | ||
| ))} | ||
| </View> | ||
| ) : null} | ||
| </Animated.View> | ||
| ); | ||
| } | ||
| function StarterChip({ | ||
| label, | ||
| index, | ||
| onPress, | ||
| }: { | ||
| label: string; | ||
| index: number; | ||
| onPress: () => void; | ||
| }) { | ||
| return ( | ||
| <Animated.View entering={FadeInDown.delay(index * 60).duration(duration.base)}> | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel={label} | ||
| onPress={() => { | ||
| haptic.selection(); | ||
| onPress(); | ||
| }} | ||
| className="rounded-2xl border border-border bg-card px-4 py-3 active:opacity-80" | ||
| > | ||
| <Text className="text-[14px] text-foreground">{label}</Text> | ||
| </Pressable> | ||
| </Animated.View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Scroll-to-bottom pill — springs in with a soft overshoot */ | ||
| /* ============================================================ */ | ||
| function ScrollToBottomPill({ | ||
| onPress, | ||
| reduced, | ||
| }: { | ||
| onPress: () => void; | ||
| reduced: boolean; | ||
| }) { | ||
| return ( | ||
| <Animated.View | ||
| entering={ | ||
| reduced | ||
| ? undefined | ||
| : FadeInDown.springify().damping(spring.bouncy.damping).mass(spring.bouncy.mass).stiffness(spring.bouncy.stiffness) | ||
| } | ||
| pointerEvents="box-none" | ||
| className="absolute inset-x-0 bottom-2 items-center" | ||
| > | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel="Scroll to latest" | ||
| hitSlop={10} | ||
| onPress={() => { | ||
| haptic.selection(); | ||
| onPress(); | ||
| }} | ||
| className="h-9 flex-row items-center gap-1.5 rounded-full border border-border bg-card px-3.5 shadow-lg active:opacity-80" | ||
| > | ||
| <ChevronDownGlyph /> | ||
| <Text className="text-[12px] font-medium text-foreground">Latest</Text> | ||
| </Pressable> | ||
| </Animated.View> | ||
| ); | ||
| } | ||
| /* ============================================================ */ | ||
| /* Shared: icon button + glyphs (drawn from Views, no icon dep) */ | ||
| /* ============================================================ */ | ||
| function IconButton({ | ||
| children, | ||
| onPress, | ||
| accessibilityLabel, | ||
| }: { | ||
| children: React.ReactNode; | ||
| onPress: () => void; | ||
| accessibilityLabel: string; | ||
| }) { | ||
| return ( | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel={accessibilityLabel} | ||
| hitSlop={8} | ||
| onPress={() => { | ||
| haptic.selection(); | ||
| onPress(); | ||
| }} | ||
| className="h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary active:opacity-80" | ||
| > | ||
| {children} | ||
| </Pressable> | ||
| ); | ||
| } | ||
| /** Plus — two centred rounded bars. */ | ||
| function PlusGlyph() { | ||
| const { colorScheme } = useColorScheme(); | ||
| const color = | ||
| colorScheme === "light" | ||
| ? "rgba(20, 20, 25, 0.8)" | ||
| : "rgba(244, 244, 250, 0.85)"; | ||
| return ( | ||
| <View style={{ width: 16, height: 16 }}> | ||
| <View | ||
| style={{ position: "absolute", top: 7, left: 1, width: 14, height: 2.5, borderRadius: 9, backgroundColor: color }} | ||
| /> | ||
| <View | ||
| style={{ position: "absolute", top: 1, left: 7, width: 2.5, height: 14, borderRadius: 9, backgroundColor: color }} | ||
| /> | ||
| </View> | ||
| ); | ||
| } | ||
| /** Chevron-down — a triangle via the borderWidth trick. */ | ||
| function ChevronDownGlyph() { | ||
| const { colorScheme } = useColorScheme(); | ||
| const color = | ||
| colorScheme === "light" | ||
| ? "rgba(20, 20, 25, 0.8)" | ||
| : "rgba(244, 244, 250, 0.85)"; | ||
| return ( | ||
| <View style={{ width: 12, height: 12, alignItems: "center", justifyContent: "center" }}> | ||
| <View | ||
| style={{ | ||
| width: 0, | ||
| height: 0, | ||
| borderLeftWidth: 5, | ||
| borderRightWidth: 5, | ||
| borderTopWidth: 6, | ||
| borderLeftColor: "transparent", | ||
| borderRightColor: "transparent", | ||
| borderTopColor: color, | ||
| }} | ||
| /> | ||
| </View> | ||
| ); | ||
| } |
+18
-0
| # Changelog — @app-cn/ui | ||
| ## 0.2.0 | ||
| ### Minor Changes | ||
| - [#13](https://github.com/Salah-XD/appCN/pull/13) [`9ac3433`](https://github.com/Salah-XD/appCN/commit/9ac3433e52a50a0b8d72e652aae7afdcc33e5818) Thanks [@Salah-XD](https://github.com/Salah-XD)! - Add the **Chat** component — the AI chat block. A complete, drop-in conversation that composes StreamBubble, PromptInput, and ReasoningTrace: controlled `messages` + `onSendMessage`, header-as-slot, starter prompts, scroll-to-bottom pill, inline reasoning, message actions, error/retry, keyboard + safe-area handling, and a magnetic stream-follow delight. Also adds a backward-compatible `animate` prop to StreamBubble for rendering settled/historical messages. | ||
| ### Patch Changes | ||
| - [#13](https://github.com/Salah-XD/appCN/pull/13) [`67029de`](https://github.com/Salah-XD/appCN/commit/67029ded5b5b4938c80391fa839e012a1dc5ff0a) Thanks [@Salah-XD](https://github.com/Salah-XD)! - PromptInput: theme-aware glyph + placeholder colours. The send arrow, attachment "+", close "×", and the placeholder were hardcoded near-white and vanished on light backgrounds; they now switch with the colour scheme. Dark mode is unchanged. | ||
| - [#13](https://github.com/Salah-XD/appCN/pull/13) [`d5b0625`](https://github.com/Salah-XD/appCN/commit/d5b0625a63c1e056fbcbb8c98afb155be92dd56b) Thanks [@Salah-XD](https://github.com/Salah-XD)! - fix(reasoning-trace): expand reliably on Android. The chain-of-thought body measured its content height via `onLayout` on a child nested inside the collapsed (`height: 0` + `overflow: hidden`) container, which Android clamps to 0 — so the panel never gained height. The content is now measured at its natural height via absolute positioning, decoupled from the animated collapse. | ||
| - [#13](https://github.com/Salah-XD/appCN/pull/13) [`babe0f1`](https://github.com/Salah-XD/appCN/commit/babe0f141404c8051da619008f3b18a4a1d7d1ec) Thanks [@Salah-XD](https://github.com/Salah-XD)! - StreamBubble & Chat: light-mode support. StreamBubble's bubble, avatar, tool chips, caret, thinking dots and text were hardcoded dark; they now switch with the colour scheme via a light palette (dark values are byte-identical, so dark mode is unchanged). Chat's plus/chevron glyphs are likewise theme-aware. Both read correctly on light backgrounds now. | ||
| - [#13](https://github.com/Salah-XD/appCN/pull/13) [`bf222bd`](https://github.com/Salah-XD/appCN/commit/bf222bd66c4ae65fef864eb8fabc015743c539dc) Thanks [@Salah-XD](https://github.com/Salah-XD)! - VoiceSphere: add a `background` prop (default `#0A0A14`) so the sphere can blend into any surface — pass a page colour to match seamlessly, or `"transparent"` to float it on whatever is behind. Also tightened the default camera (`z: 4.2 → 3.4`) so the sphere fills more of its canvas, and sped up the active wave motion for a fiercer ripple. The additive-blending particles still need a dark backdrop, so the default colour is unchanged. Adds a `glow` prop (default `true`): set `false` for normal (non-additive) blending so the sphere reads as solid dots on a light or transparent background instead of needing a dark panel. | ||
| See the root [CHANGELOG.md](../../CHANGELOG.md) for the project-wide log; | ||
@@ -12,2 +28,3 @@ this file tracks only changes to the published `@app-cn/ui` package surface | ||
| ### Added | ||
| - **Components:** `Button` (base) + `StreamBubble`, `PromptInput`, | ||
@@ -30,2 +47,3 @@ `ReasoningTrace` (AI collection). | ||
| ### Notes | ||
| - Ships TS source only (no `dist/`). Metro / Next / Vite transpile TS | ||
@@ -32,0 +50,0 @@ themselves — no build step required. |
+1
-1
| { | ||
| "name": "@app-cn/ui", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "Copy-paste mobile components for React Native + Expo. Motion-first, dark-by-default, with a featured AI-native collection.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -133,2 +133,3 @@ import type { ComponentMeta } from "../lib/meta"; | ||
| ], | ||
| addedAt: "2026-05-28", | ||
| }; |
@@ -26,2 +26,3 @@ import * as React from "react"; | ||
| import { haptic } from "../lib/haptics"; | ||
| import { useColorScheme } from "nativewind"; | ||
@@ -85,2 +86,3 @@ export interface PromptAttachment { | ||
| }: PromptInputProps) { | ||
| const { colorScheme } = useColorScheme(); | ||
| const isControlled = valueProp !== undefined; | ||
@@ -197,3 +199,7 @@ const [internal, setInternal] = React.useState(defaultValue ?? ""); | ||
| placeholder={placeholder} | ||
| placeholderTextColor="rgba(255,255,255,0.35)" | ||
| placeholderTextColor={ | ||
| colorScheme === "light" | ||
| ? "rgba(0,0,0,0.35)" | ||
| : "rgba(255,255,255,0.35)" | ||
| } | ||
| multiline | ||
@@ -513,3 +519,8 @@ editable={!disabled} | ||
| // resolve on a 0×0 View on web). | ||
| const color = filled ? "#FFFFFF" : "rgba(244, 244, 250, 0.55)"; | ||
| const { colorScheme } = useColorScheme(); | ||
| const color = filled | ||
| ? "#FFFFFF" | ||
| : colorScheme === "light" | ||
| ? "rgba(20, 20, 25, 0.5)" | ||
| : "rgba(244, 244, 250, 0.55)"; | ||
@@ -570,3 +581,7 @@ return ( | ||
| function PlusGlyph() { | ||
| const color = "rgba(244, 244, 250, 0.85)"; | ||
| const { colorScheme } = useColorScheme(); | ||
| const color = | ||
| colorScheme === "light" | ||
| ? "rgba(20, 20, 25, 0.8)" | ||
| : "rgba(244, 244, 250, 0.85)"; | ||
| return ( | ||
@@ -602,3 +617,7 @@ <View style={{ width: 16, height: 16 }}> | ||
| function CloseGlyph() { | ||
| const color = "rgba(244, 244, 250, 0.85)"; | ||
| const { colorScheme } = useColorScheme(); | ||
| const color = | ||
| colorScheme === "light" | ||
| ? "rgba(20, 20, 25, 0.8)" | ||
| : "rgba(244, 244, 250, 0.85)"; | ||
| return ( | ||
@@ -605,0 +624,0 @@ <View style={{ width: 12, height: 12 }}> |
@@ -105,2 +105,3 @@ import type { ComponentMeta } from "../lib/meta"; | ||
| ], | ||
| addedAt: "2026-05-28", | ||
| }; |
@@ -143,3 +143,11 @@ import * as React from "react"; | ||
| <Animated.View style={bodyStyle} className="overflow-hidden"> | ||
| <View onLayout={onContentLayout}> | ||
| {/* Measured at its natural height via absolute positioning. The body | ||
| collapses to height:0 + overflow:hidden, which on Android clamps a | ||
| nested in-flow child's onLayout to 0 — so contentH stayed 0 and the | ||
| panel could never expand. Absolute layout decouples the measurement | ||
| from the animated height. */} | ||
| <View | ||
| onLayout={onContentLayout} | ||
| style={{ position: "absolute", left: 0, right: 0, top: 0 }} | ||
| > | ||
| <View className="px-3.5 pb-3.5 pt-0.5"> | ||
@@ -146,0 +154,0 @@ <Text className="text-[13px] leading-5 text-muted-foreground"> |
@@ -49,2 +49,9 @@ import type { ComponentMeta } from "../lib/meta"; | ||
| { | ||
| name: "animate", | ||
| type: "boolean", | ||
| default: "true", | ||
| description: | ||
| "When false, the bubble renders its settled final state immediately — no thinking, stream, or settle. Use for already-finished messages so they don't re-animate.", | ||
| }, | ||
| { | ||
| name: "className", | ||
@@ -87,2 +94,4 @@ type: "string", | ||
| ], | ||
| addedAt: "2026-05-28", | ||
| updatedAt: "2026-05-30", | ||
| }; |
@@ -19,2 +19,3 @@ import * as React from "react"; | ||
| import { duration, easing, spring } from "../lib/motion"; | ||
| import { useColorScheme } from "nativewind"; | ||
@@ -28,3 +29,19 @@ type Phase = "thinking" | "streaming" | "done"; | ||
| /* ============================================================ */ | ||
| const COLOR = { | ||
| type StreamColors = { | ||
| primary: string; | ||
| primarySoft: string; | ||
| primaryGlow: string; | ||
| accent: string; | ||
| bubbleBg: string; | ||
| bubbleBgTop: string; | ||
| bubbleBorder: string; | ||
| text: string; | ||
| textMuted: string; | ||
| chipBg: string; | ||
| chipBorder: string; | ||
| avatarBg: string; | ||
| avatarBorder: string; | ||
| }; | ||
| const DARK_COLORS: StreamColors = { | ||
| primary: "hsl(250, 90%, 66%)", | ||
@@ -43,4 +60,27 @@ primarySoft: "hsla(250, 90%, 66%, 0.25)", | ||
| avatarBorder: "rgba(255, 255, 255, 0.10)", | ||
| } as const; | ||
| }; | ||
| /** Light-mode mirror (same keys) so the bubble reads on a light background. */ | ||
| const LIGHT_COLORS: StreamColors = { | ||
| primary: "hsl(250, 84%, 60%)", | ||
| primarySoft: "hsla(250, 84%, 60%, 0.22)", | ||
| primaryGlow: "hsla(250, 84%, 60%, 0.10)", | ||
| accent: "hsl(316, 80%, 56%)", | ||
| bubbleBg: "#F1F1F6", | ||
| bubbleBgTop: "rgba(0, 0, 0, 0.02)", | ||
| bubbleBorder: "rgba(0, 0, 0, 0.08)", | ||
| text: "#18181B", | ||
| textMuted: "rgba(24, 24, 27, 0.62)", | ||
| chipBg: "rgba(0, 0, 0, 0.05)", | ||
| chipBorder: "rgba(0, 0, 0, 0.10)", | ||
| avatarBg: "#ECECEF", | ||
| avatarBorder: "rgba(0, 0, 0, 0.10)", | ||
| }; | ||
| /** Pick the bubble palette for the active colour scheme. */ | ||
| function useStreamColors(): StreamColors { | ||
| const { colorScheme } = useColorScheme(); | ||
| return colorScheme === "light" ? LIGHT_COLORS : DARK_COLORS; | ||
| } | ||
| export interface StreamBubbleProps { | ||
@@ -65,2 +105,8 @@ /** Full assistant message that streams in token-by-token. */ | ||
| replayKey?: string | number; | ||
| /** | ||
| * When false, the bubble renders its settled "done" state immediately — no | ||
| * thinking dots, no token reveal, no settle pulse. Use for already-finished | ||
| * messages (e.g. chat history) so they don't re-animate on mount/scroll. | ||
| */ | ||
| animate?: boolean; | ||
| className?: string; | ||
@@ -84,2 +130,3 @@ } | ||
| replayKey, | ||
| animate = true, | ||
| className, | ||
@@ -92,3 +139,3 @@ }: StreamBubbleProps) { | ||
| React.useEffect(() => { | ||
| if (reduced) { | ||
| if (reduced || !animate) { | ||
| setPhase("done"); | ||
@@ -102,3 +149,3 @@ setShown(content.length); | ||
| return () => clearTimeout(t); | ||
| }, [content, thinkingDuration, replayKey, reduced]); | ||
| }, [content, thinkingDuration, replayKey, reduced, animate]); | ||
@@ -136,2 +183,3 @@ React.useEffect(() => { | ||
| shown={shown} | ||
| animate={animate} | ||
| className={className} | ||
@@ -152,2 +200,3 @@ /> | ||
| shown, | ||
| animate, | ||
| className, | ||
@@ -159,8 +208,10 @@ }: { | ||
| shown: number; | ||
| animate: boolean; | ||
| className?: string; | ||
| }) { | ||
| const COLOR = useStreamColors(); | ||
| // Settled-glow: when we transition to "done", briefly pulse a primary ring. | ||
| const settle = useSharedValue(0); | ||
| React.useEffect(() => { | ||
| if (phase === "done") { | ||
| if (phase === "done" && animate) { | ||
| settle.value = withSequence( | ||
@@ -173,3 +224,3 @@ withTiming(1, { duration: duration.base, easing: easing.enter }), | ||
| } | ||
| }, [phase, settle]); | ||
| }, [phase, animate, settle]); | ||
@@ -310,2 +361,3 @@ const glowStyle = useAnimatedStyle(() => ({ opacity: settle.value })); | ||
| }) { | ||
| const COLOR = useStreamColors(); | ||
| const reduced = useReducedMotion(); | ||
@@ -440,2 +492,3 @@ const rot = useSharedValue(0); | ||
| function ThinkingDot({ index }: { index: number }) { | ||
| const COLOR = useStreamColors(); | ||
| const progress = useSharedValue(0); | ||
@@ -485,2 +538,3 @@ | ||
| function StreamCaret() { | ||
| const COLOR = useStreamColors(); | ||
| const blink = useSharedValue(1); | ||
@@ -524,2 +578,3 @@ | ||
| function ToolChip({ label, index }: { label: string; index: number }) { | ||
| const COLOR = useStreamColors(); | ||
| const lift = useSharedValue(0); | ||
@@ -526,0 +581,0 @@ |
@@ -89,2 +89,3 @@ import type { ComponentMeta } from "../lib/meta"; | ||
| ], | ||
| addedAt: "2026-05-28", | ||
| }; |
+38
-13
@@ -28,2 +28,14 @@ import * as React from "react"; | ||
| colors?: { from: string; to: string }; | ||
| /** | ||
| * Panel colour behind the sphere. Defaults to the dark `#0A0A14` the additive | ||
| * particles need to glow against. Pass a page colour to blend seamlessly, or | ||
| * `"transparent"` to float the sphere on whatever is behind it. | ||
| */ | ||
| background?: string; | ||
| /** | ||
| * Particle blending. `true` (default) = additive glow — needs a dark backdrop. | ||
| * `false` = normal blending, so the sphere reads as solid dots on a light or | ||
| * transparent background (no dark panel required). | ||
| */ | ||
| glow?: boolean; | ||
| className?: string; | ||
@@ -61,4 +73,7 @@ } | ||
| colors, | ||
| background = "#0A0A14", | ||
| glow = true, | ||
| className, | ||
| }: VoiceSphereProps) { | ||
| const isTransparent = background === "transparent"; | ||
| const reduced = useReducedMotion(); | ||
@@ -88,3 +103,3 @@ const count = DENSITY[density]; | ||
| position: "relative", | ||
| backgroundColor: "#0A0A14", | ||
| backgroundColor: isTransparent ? "transparent" : background, | ||
| borderRadius: 24, | ||
@@ -97,3 +112,3 @@ }} | ||
| style={{ width: "100%", height: "100%" }} | ||
| camera={{ position: [0, 0, 4.2], fov: 45 }} | ||
| camera={{ position: [0, 0, 3.4], fov: 45 }} | ||
| frameloop="always" | ||
@@ -103,16 +118,23 @@ gl={{ | ||
| powerPreference: "high-performance", | ||
| alpha: isTransparent, | ||
| }} | ||
| onCreated={(state: any) => { | ||
| // Opaque dark clear — particles use additive blending, so the | ||
| // background must be dark for them to read as glowing dots. | ||
| state.gl.setClearColor("#0A0A14", 1); | ||
| // Particles use additive blending, so the clear must be dark (or | ||
| // fully transparent) for them to read as glowing dots. | ||
| if (isTransparent) { | ||
| state.gl.setClearColor("#000000", 0); | ||
| } else { | ||
| state.gl.setClearColor(background, 1); | ||
| } | ||
| }} | ||
| > | ||
| <ambientLight intensity={0.4} /> | ||
| <Halo | ||
| active={active} | ||
| amplitude={amplitude} | ||
| colorFrom={colorFrom} | ||
| reduced={!!reduced} | ||
| /> | ||
| {glow ? ( | ||
| <Halo | ||
| active={active} | ||
| amplitude={amplitude} | ||
| colorFrom={colorFrom} | ||
| reduced={!!reduced} | ||
| /> | ||
| ) : null} | ||
| <ParticleSphere | ||
@@ -125,2 +147,3 @@ count={count} | ||
| reduced={!!reduced} | ||
| glow={glow} | ||
| /> | ||
@@ -144,2 +167,3 @@ </Canvas> | ||
| reduced: boolean; | ||
| glow: boolean; | ||
| } | ||
@@ -154,2 +178,3 @@ | ||
| reduced, | ||
| glow, | ||
| }: SphereProps) { | ||
@@ -190,3 +215,3 @@ const meshRef = React.useRef<THREE.Points>(null!); | ||
| const energy = u.uAmp.value; | ||
| u.uTime.value += delta * (active ? 0.4 + energy * 1.6 : 0.2); | ||
| u.uTime.value += delta * (active ? 0.9 + energy * 2.8 : 0.25); | ||
@@ -218,3 +243,3 @@ const targetAmp = amplitude != null ? amplitude : active ? 0.18 : 0.06; | ||
| depthWrite={false} | ||
| blending={THREE.AdditiveBlending} | ||
| blending={glow ? THREE.AdditiveBlending : THREE.NormalBlending} | ||
| /> | ||
@@ -221,0 +246,0 @@ </points> |
@@ -86,2 +86,3 @@ import type { ComponentMeta } from "../lib/meta"; | ||
| ], | ||
| addedAt: "2026-05-28", | ||
| }; |
+4
-0
@@ -16,2 +16,5 @@ export { Button, buttonVariants, buttonTextVariants } from "./components/button"; | ||
| export { Chat } from "./ai/chat"; | ||
| export type { ChatProps, ChatMessage, ChatRole, ChatStatus } from "./ai/chat"; | ||
| export { cn } from "./lib/cn"; | ||
@@ -28,2 +31,3 @@ export { duration, easing, spring, PRESS_SCALE } from "./lib/motion"; | ||
| voiceSphereMeta, | ||
| chatMeta, | ||
| } from "./lib/meta"; |
+18
-0
@@ -42,2 +42,9 @@ /** | ||
| category: "base" | "ai"; | ||
| /** | ||
| * "primitive" (the default when omitted) for single-purpose components; | ||
| * "block" for composite, drop-in screens assembled from primitives (the | ||
| * marketed "blocks"). Blocks are surfaced in their own section, separate | ||
| * from the primitive component lists. | ||
| */ | ||
| kind?: "primitive" | "block"; | ||
| /** One-line description used in cards, headers, and meta tags. */ | ||
@@ -55,2 +62,12 @@ description: string; | ||
| a11y: string[]; | ||
| /** | ||
| * ISO date (yyyy-mm-dd) the component first shipped. Used by the docs site | ||
| * to render a `NEW` badge for the first 30 days after this date. | ||
| */ | ||
| addedAt?: string; | ||
| /** | ||
| * ISO date of the last meaningful update (api / motion / design overhaul, | ||
| * not a typo fix). Used to render an `UPDATED` badge for 14 days. | ||
| */ | ||
| updatedAt?: string; | ||
| } | ||
@@ -64,1 +81,2 @@ | ||
| export { meta as voiceSphereMeta } from "../ai/voice-sphere.meta"; | ||
| export { meta as chatMeta } from "../ai/chat.meta"; |
124053
31.95%26
8.33%3377
29.44%