Assistant conversation — spark-marked canvas replies, user bubbles, animated thinking indicator, starter-card welcome state, and an attach + send composer pinned to the keyboard
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { FlatList, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, Input, KeyboardAvoiding, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";
import { LumioBot, Mount, Press } from "../shared";
export interface AiMessage {
id: string;
role: "user" | "assistant";
text: string;
}
/** Rich welcome-state starter — a leading glyph, a title, and a hint of
* what the prompt produces. `prompt` defaults to `title` when omitted. */
export interface AiSuggestion {
icon: IconName;
title: string;
subtitle: string;
prompt?: string;
}
export interface AiChatScreenProps {
/** Assistant identity — shown in the header and welcome state. */
assistantName?: string;
/** Seed conversation — the welcome state shows when empty. */
messages?: AiMessage[];
/** Starter cards on the welcome state — tapping one sends its prompt.
* Plain strings get a default glyph + hint. */
suggestions?: (string | AiSuggestion)[];
/** Host reply hook — return the assistant's reply text. When omitted,
* the screen simulates a short thinking pause + canned reply so the
* preview stays alive. */
onSend?: (text: string) => void | Promise<string | void>;
onNewChat?: () => void;
onBack?: () => void;
/** Attach affordance on the left of the composer. */
onAttach?: () => void;
}
const DEFAULT_SUGGESTIONS: AiSuggestion[] = [
{
icon: "file-text",
title: "Summarize today's releases",
subtitle: "Changelog → three tight bullets",
prompt: "Summarize today's releases",
},
{
icon: "edit",
title: "Draft a standup update",
subtitle: "Yesterday, today, and blockers",
prompt: "Draft a standup update",
},
{
icon: "book-open",
title: "Explain bottom-sheet snap points",
subtitle: "How detents map to gestures",
prompt: "Explain bottom-sheet snap points",
},
{
icon: "zap",
title: "Write a product description",
subtitle: "Launch copy in the brand voice",
prompt: "Write a product description",
},
];
/** Real answers for the default starters — the demo should *answer*, not
* shrug. Keys are matched case-insensitively against the sent prompt. */
const CANNED_ANSWERS: Record<string, string> = {
"summarize today's releases":
"Three releases shipped today:\n\n• BottomSheet v2 — keyboard-aware snap points plus inertia dismissal\n• Dock gains the fused variant (gooey seam merge)\n• 12 new screen templates landed in the registry\n\nThe sheet change is the risky one — dismiss thresholds moved, so it's worth regression-testing gestures.",
"draft a standup update":
"Yesterday: shipped the fused dock variant and the raised-gloss pass on filled buttons.\nToday: reviewing the new screen templates and fixing preview regressions.\nBlockers: none.",
"explain bottom-sheet snap points":
"Snap points are detents — the heights a sheet rests at. Dragging past a detent springs it to the next one; releasing below the lowest point dismisses the sheet entirely. BottomSheet defaults to [0.4, 0.92] of screen height, and flings can skip detents. Content decides the rest — the sheet just honors the stops.",
"write a product description":
"Tonkotsu Shoyu — an 18-hour broth, hand-pulled noodles, and a smoked-chili oil finish. Rich without heaviness, heat that builds instead of spikes. $16.50, served till late. The bowl regulars reorder on sight.",
};
const CANNED_REPLIES = [
"Here's a first pass — say the word if you want it tighter, longer, or in a different tone.",
"Done. I kept it short so you can react fast — happy to expand any part of it.",
"On it. This version covers the basics; I can go deeper on whichever section matters most.",
];
/** Simulated thinking delay before a reply starts streaming. */
const THINK_MS = 1100;
/** Stream pacing — word groups per tick feel like real token emission. */
const STREAM_MS = 55;
/** Prompt-aware fallback — echoes the ask so the reply reads as a real
* answer, then offers the obvious next step. */
function fallbackReply(prompt: string) {
const q = prompt.length > 80 ? prompt.slice(0, 80) + "…" : prompt;
return `For "${q}" — I'd start with the smallest version that works, then layer the polish. Sketch:\n\n• Core path first — one clean pass, no edge cases yet\n• Then the states: loading, empty, error\n• Motion last — enter/exit only where it communicates\n\nWant me to draft the actual implementation?`;
}
/** One pulsing dot of the thinking indicator — staggered start keeps the
* three dots on a travelling wave. */
function ThinkingDot({ delay, dim }: { delay: number; dim: boolean }) {
const v = useSharedValue(0.3);
useEffect(() => {
if (dim) {
v.value = 0.5;
return;
}
v.value = withDelay(delay, withRepeat(withTiming(1, { duration: 550 }), -1, true));
}, [v, delay, dim]);
const style = useAnimatedStyle(() => ({
opacity: 0.35 + v.value * 0.65,
transform: [{ translateY: (1 - v.value) * 1.5 - 1.5 }],
}));
return (
<Animated.View
style={style}
className="h-1.5 w-1.5 rounded-full bg-muted-foreground"
/>
);
}
/** Entrance for each appended message — short rise, respects reduced
* motion. */
function MsgIn({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
const t = useSharedValue(reduce ? 1 : 0);
useEffect(() => {
if (reduce) {
t.value = 1;
return;
}
t.value = withTiming(1, { ...timing.normal, easing: Easing.bezier(...ease.out) });
}, [reduce, t]);
const style = useAnimatedStyle(() => ({
opacity: t.value,
transform: [{ translateY: (1 - t.value) * 10 }],
}));
return (
<Animated.View style={style} className={className}>
{children}
</Animated.View>
);
}
MsgIn.displayName = "MsgIn";
/** Plain-string starters get a default glyph + hint so the prop stays
* backward compatible. */
function normalizeSuggestion(s: string | AiSuggestion): AiSuggestion {
return typeof s === "string"
? { icon: "zap", title: s, subtitle: "Tap to ask", prompt: s }
: s;
}
/** Two-up starter tile for the welcome grid — icon disc over two-line
* prompt text; taller and squarer than the list card. */
function SuggestionTile({
item,
onPress,
}: {
item: AiSuggestion;
onPress: (prompt: string) => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.97, pressedOpacity: 0.75, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.selection();
onPress(item.prompt ?? item.title);
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={`Suggestion: ${item.title}`}
className="w-full gap-3 overflow-hidden rounded-3xl p-4"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-3xl" pointerEvents="none" />
<Icon name={item.icon} size="md" color="foreground" />
<View className="gap-1">
<Text variant="small" numberOfLines={2} className="font-medium leading-5">
{item.title}
</Text>
{item.subtitle ? (
<Text variant="muted" numberOfLines={1} className="text-xs">
{item.subtitle}
</Text>
) : null}
</View>
</AnimatedPressable>
);
}
SuggestionTile.displayName = "SuggestionTile";
/** AIChat — the ChatGPT/Claude idiom: assistant text sits on the canvas
* with a spark mark, the user's words bubble on the right, suggestion
* cards fill the empty state, and the composer rides the keyboard. */
export function AiChatScreen({
assistantName = "Lumio",
messages: seed = [],
suggestions = DEFAULT_SUGGESTIONS,
onSend,
onNewChat,
onBack,
onAttach,
}: AiChatScreenProps) {
const insets = useSafeAreaInsets();
const reduceMotion = useReducedMotion();
const haptics = useHaptic();
const [draft, setDraft] = useState("");
const [headerH, setHeaderH] = useState(0);
const [composerH, setComposerH] = useState(0);
const [messages, setMessages] = useState<AiMessage[]>(seed);
const [thinking, setThinking] = useState(false);
const replyIdx = useRef(0);
const listRef = useRef<FlatList<AiMessage>>(null);
const streamTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const thinkTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Streaming + think timers die with the screen (and on New chat).
useEffect(
() => () => {
if (streamTimer.current) clearInterval(streamTimer.current);
if (thinkTimer.current) clearTimeout(thinkTimer.current);
},
[]
);
/** Streams `full` into a fresh assistant bubble — words land in small
* bursts like real token emission. Reduced motion skips the type-on. */
const streamReply = (full: string) => {
setThinking(false);
const id = `a-${Date.now()}`;
if (reduceMotion) {
setMessages((m) => [...m, { id, role: "assistant", text: full }]);
return;
}
setMessages((m) => [...m, { id, role: "assistant", text: "" }]);
const words = full.split(" ");
let i = 0;
streamTimer.current = setInterval(() => {
i = Math.min(words.length, i + 2 + Math.floor(Math.random() * 3));
const partial = words.slice(0, i).join(" ");
setMessages((m) => m.map((x) => (x.id === id ? { ...x, text: partial } : x)));
if (i >= words.length && streamTimer.current) {
clearInterval(streamTimer.current);
streamTimer.current = null;
}
}, STREAM_MS);
};
const resolveReply = (prompt: string) =>
CANNED_ANSWERS[prompt.toLowerCase()] ??
CANNED_ANSWERS[prompt.toLowerCase().replace(/[.?!]+$/, "")] ??
(replyIdx.current++ === 0
? fallbackReply(prompt)
: CANNED_REPLIES[replyIdx.current % CANNED_REPLIES.length] ?? CANNED_REPLIES[0]!);
const send = (raw?: string) => {
const text = (raw ?? draft).trim();
if (!text || thinking) return;
setMessages((m) => [
...m,
{ id: `u-${Date.now()}`, role: "user", text },
]);
setDraft("");
setThinking(true);
const host = onSend?.(text);
if (host && typeof (host as Promise<unknown>).then === "function") {
(host as Promise<string | void>)
.then((r) => streamReply(typeof r === "string" ? r : resolveReply(text)))
.catch(() => setThinking(false));
} else {
thinkTimer.current = setTimeout(() => streamReply(resolveReply(text)), THINK_MS);
}
};
/** New chat — reset the conversation, kill any in-flight stream. */
const reset = () => {
if (streamTimer.current) clearInterval(streamTimer.current);
if (thinkTimer.current) clearTimeout(thinkTimer.current);
setThinking(false);
setDraft("");
setMessages(seed);
onNewChat?.();
};
const empty = messages.length === 0;
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Header — back disc, identity capsule, new-chat disc. */}
<View
pointerEvents="box-none"
onLayout={(e) => setHeaderH(e.nativeEvent.layout.height)}
className="absolute inset-x-4 top-0 z-10 flex-row items-center gap-2"
style={{ top: insets.top + 8 }}
>
{onBack ? (
<Press
haptic="light"
onPress={onBack}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
) : null}
<View className="h-11 flex-1 flex-row items-center gap-2.5 overflow-hidden rounded-full pl-1.5 pr-4">
<Glass effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<LumioBot size={32} />
<View className="flex-1">
<Text variant="small" className="font-semibold" accessibilityRole="header">
{assistantName}
</Text>
<Text variant="muted" className="text-xs">
{thinking ? "Thinking…" : "Online"}
</Text>
</View>
</View>
{/* New chat is always real — resets the conversation even when the
host doesn't wire onNewChat. */}
<Press
haptic="light"
onPress={reset}
accessibilityRole="button"
accessibilityLabel="New chat"
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="plus" size="md" color="foreground" />
</Press>
</View>
<KeyboardAvoiding className="flex-1">
{empty ? (
/* Welcome state — a Gemini-style tile grid, not the assistant's
centered orb + list: compact greeting up top, then tappable
prompt tiles filling the canvas. */
<View className="flex-1 justify-center px-4">
<Mount className="flex-row items-center gap-3 pb-5">
<LumioBot size={38} />
<View className="flex-1">
<Text variant="h3">Ask {assistantName}</Text>
<Text variant="muted" className="text-xs">
Streaming answers, with sources when it helps
</Text>
</View>
</Mount>
<View className="flex-row flex-wrap gap-2.5">
{suggestions.map((s, i) => {
const item = normalizeSuggestion(s);
return (
<Mount key={item.title} delay={80 + i * 50} className="w-[48%] flex-grow">
<SuggestionTile item={item} onPress={send} />
</Mount>
);
})}
</View>
</View>
) : (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item) => item.id}
className="flex-1"
contentContainerClassName="px-4 pb-6 pt-4"
contentContainerStyle={{
paddingTop: headerH + 8,
paddingBottom: composerH + 12,
}}
showsVerticalScrollIndicator={false}
onContentSizeChange={() =>
listRef.current?.scrollToEnd({ animated: false })
}
renderItem={({ item, index }) => {
const mine = item.role === "user";
const prev = messages[index - 1];
const firstOfRun = !prev || prev.role !== item.role;
return (
<MsgIn
className={cn(index > 0 && (firstOfRun ? "mt-3" : "mt-1"))}
>
{mine ? (
<View
accessible
accessibilityRole="text"
accessibilityLabel={`You: ${item.text}`}
className="ml-auto max-w-[80%] self-end rounded-2xl rounded-br-md bg-[#1B1B26] px-4 py-2.5"
>
<Text className="text-white">{item.text}</Text>
</View>
) : (
/* Assistant replies sit directly on the canvas — the
ChatGPT/Claude idiom, marked by the spark avatar. */
<View className="flex-row items-start gap-2.5">
<View className="w-7 items-center pt-0.5">
{firstOfRun ? <LumioBot size={24} /> : null}
</View>
<View className="flex-1">
<Text
accessible
accessibilityRole="text"
accessibilityLabel={`${assistantName}: ${item.text}`}
className="pt-1 text-foreground"
>
{item.text}
</Text>
{/* Action rail on the latest reply — copy/retry are
the two gestures every AI surface carries. */}
{index === messages.length - 1 && !thinking ? (
<View className="flex-row gap-1 pt-2">
<AnimatedPressable
onPress={() => haptics.light()}
accessibilityRole="button"
accessibilityLabel="Copy response"
className="h-8 w-8 items-center justify-center rounded-lg"
>
<Icon name="copy" size="sm" color="mutedForeground" />
</AnimatedPressable>
<AnimatedPressable
onPress={() => haptics.medium()}
accessibilityRole="button"
accessibilityLabel="Regenerate response"
className="h-8 w-8 items-center justify-center rounded-lg"
>
<Icon name="refresh" size="sm" color="mutedForeground" />
</AnimatedPressable>
</View>
) : null}
</View>
</View>
)}
</MsgIn>
);
}}
ListFooterComponent={
thinking ? (
<View className="mt-3 flex-row items-start gap-2.5">
<View className="w-7 items-center pt-0.5">
<LumioBot size={24} />
</View>
<View
className="flex-row items-center gap-1 overflow-hidden rounded-2xl px-4 py-3.5"
accessible
accessibilityRole="text"
accessibilityLabel={`${assistantName} is thinking`}
>
<Glass interactive effect="clear" sheen={0.45} className="absolute inset-0 rounded-2xl" pointerEvents="none" />
<ThinkingDot delay={0} dim={reduceMotion} />
<ThinkingDot delay={140} dim={reduceMotion} />
<ThinkingDot delay={280} dim={reduceMotion} />
</View>
</View>
) : null
}
/>
)}
{/* Composer — attach, growing field, send armed only with text,
then the small-print disclaimer every shipped assistant carries. */}
<View
pointerEvents="box-none"
onLayout={(e) => setComposerH(e.nativeEvent.layout.height)}
className="absolute inset-x-0 bottom-0 px-3 pt-3"
style={{ paddingBottom: insets.bottom + 4 }}
>
<View className="flex-row items-end gap-2">
<Button
variant="glass"
size="icon"
haptic="light"
onPress={onAttach}
accessibilityLabel="Add attachment"
>
<Icon name="plus" size="md" color="secondaryForeground" />
</Button>
<View className="flex-1">
<Input
placeholder="Ask anything…"
value={draft}
onChangeText={setDraft}
onSubmitEditing={() => send()}
returnKeyType="send"
className="rounded-full"
glass
rightIcon={
<Icon name="mic" size="sm" color="mutedForeground" />
}
accessibilityLabel="Message the assistant"
/>
</View>
<Button
size="icon"
className="bg-[#1B1B26]"
haptic="medium"
disabled={draft.trim().length === 0 || thinking}
onPress={() => send()}
accessibilityLabel={thinking ? `${assistantName} is replying` : "Send message"}
accessibilityState={{ disabled: draft.trim().length === 0 || thinking }}
>
<Icon name="arrow-up" size="sm" color="#FFFFFF" />
</Button>
</View>
<Text
variant="muted"
className="pt-2 text-center text-[11px] leading-4"
>
{assistantName} can make mistakes — check important info.
</Text>
</View>
</KeyboardAvoiding>
</SafeAreaView>
);
}
AiChatScreen.displayName = "AiChatScreen";
Recreate the "AI Chat Screen" screen in React Native using LumioUI.
Goal: Assistant conversation — spark-marked canvas replies, user bubbles, animated thinking indicator, starter-card welcome state, and an attach + send composer pinned to the keyboard Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add chat-ai
```
Or pull just the components: `npx lumioui add button icon input keyboard-avoiding text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-reanimated@*
- react-native-safe-area-context@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×8
### `input` — Labeled field with focus ring, error shake and success check.
- `<Input>` ×1
### `keyboard-avoiding` — View that lifts above the keyboard on both platforms.
- `<KeyboardAvoiding>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×9
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/chat/ai.tsx` exporting AiChatScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { FlatList, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Glass, Icon, Input, KeyboardAvoiding, Text, type IconName } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";
import { LumioBot, Mount, Press } from "../shared";
export interface AiMessage {
id: string;
role: "user" | "assistant";
text: string;
}
/** Rich welcome-state starter — a leading glyph, a title, and a hint of
* what the prompt produces. `prompt` defaults to `title` when omitted. */
export interface AiSuggestion {
icon: IconName;
title: string;
subtitle: string;
prompt?: string;
}
export interface AiChatScreenProps {
/** Assistant identity — shown in the header and welcome state. */
assistantName?: string;
/** Seed conversation — the welcome state shows when empty. */
messages?: AiMessage[];
/** Starter cards on the welcome state — tapping one sends its prompt.
* Plain strings get a default glyph + hint. */
suggestions?: (string | AiSuggestion)[];
/** Host reply hook — return the assistant's reply text. When omitted,
* the screen simulates a short thinking pause + canned reply so the
* preview stays alive. */
onSend?: (text: string) => void | Promise<string | void>;
onNewChat?: () => void;
onBack?: () => void;
/** Attach affordance on the left of the composer. */
onAttach?: () => void;
}
const DEFAULT_SUGGESTIONS: AiSuggestion[] = [
{
icon: "file-text",
title: "Summarize today's releases",
subtitle: "Changelog → three tight bullets",
prompt: "Summarize today's releases",
},
{
icon: "edit",
title: "Draft a standup update",
subtitle: "Yesterday, today, and blockers",
prompt: "Draft a standup update",
},
{
icon: "book-open",
title: "Explain bottom-sheet snap points",
subtitle: "How detents map to gestures",
prompt: "Explain bottom-sheet snap points",
},
{
icon: "zap",
title: "Write a product description",
subtitle: "Launch copy in the brand voice",
prompt: "Write a product description",
},
];
/** Real answers for the default starters — the demo should *answer*, not
* shrug. Keys are matched case-insensitively against the sent prompt. */
const CANNED_ANSWERS: Record<string, string> = {
"summarize today's releases":
"Three releases shipped today:\n\n• BottomSheet v2 — keyboard-aware snap points plus inertia dismissal\n• Dock gains the fused variant (gooey seam merge)\n• 12 new screen templates landed in the registry\n\nThe sheet change is the risky one — dismiss thresholds moved, so it's worth regression-testing gestures.",
"draft a standup update":
"Yesterday: shipped the fused dock variant and the raised-gloss pass on filled buttons.\nToday: reviewing the new screen templates and fixing preview regressions.\nBlockers: none.",
"explain bottom-sheet snap points":
"Snap points are detents — the heights a sheet rests at. Dragging past a detent springs it to the next one; releasing below the lowest point dismisses the sheet entirely. BottomSheet defaults to [0.4, 0.92] of screen height, and flings can skip detents. Content decides the rest — the sheet just honors the stops.",
"write a product description":
"Tonkotsu Shoyu — an 18-hour broth, hand-pulled noodles, and a smoked-chili oil finish. Rich without heaviness, heat that builds instead of spikes. $16.50, served till late. The bowl regulars reorder on sight.",
};
const CANNED_REPLIES = [
"Here's a first pass — say the word if you want it tighter, longer, or in a different tone.",
"Done. I kept it short so you can react fast — happy to expand any part of it.",
"On it. This version covers the basics; I can go deeper on whichever section matters most.",
];
/** Simulated thinking delay before a reply starts streaming. */
const THINK_MS = 1100;
/** Stream pacing — word groups per tick feel like real token emission. */
const STREAM_MS = 55;
/** Prompt-aware fallback — echoes the ask so the reply reads as a real
* answer, then offers the obvious next step. */
function fallbackReply(prompt: string) {
const q = prompt.length > 80 ? prompt.slice(0, 80) + "…" : prompt;
return `For "${q}" — I'd start with the smallest version that works, then layer the polish. Sketch:\n\n• Core path first — one clean pass, no edge cases yet\n• Then the states: loading, empty, error\n• Motion last — enter/exit only where it communicates\n\nWant me to draft the actual implementation?`;
}
/** One pulsing dot of the thinking indicator — staggered start keeps the
* three dots on a travelling wave. */
function ThinkingDot({ delay, dim }: { delay: number; dim: boolean }) {
const v = useSharedValue(0.3);
useEffect(() => {
if (dim) {
v.value = 0.5;
return;
}
v.value = withDelay(delay, withRepeat(withTiming(1, { duration: 550 }), -1, true));
}, [v, delay, dim]);
const style = useAnimatedStyle(() => ({
opacity: 0.35 + v.value * 0.65,
transform: [{ translateY: (1 - v.value) * 1.5 - 1.5 }],
}));
return (
<Animated.View
style={style}
className="h-1.5 w-1.5 rounded-full bg-muted-foreground"
/>
);
}
/** Entrance for each appended message — short rise, respects reduced
* motion. */
function MsgIn({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const reduce = useReducedMotion();
const t = useSharedValue(reduce ? 1 : 0);
useEffect(() => {
if (reduce) {
t.value = 1;
return;
}
t.value = withTiming(1, { ...timing.normal, easing: Easing.bezier(...ease.out) });
}, [reduce, t]);
const style = useAnimatedStyle(() => ({
opacity: t.value,
transform: [{ translateY: (1 - t.value) * 10 }],
}));
return (
<Animated.View style={style} className={className}>
{children}
</Animated.View>
);
}
MsgIn.displayName = "MsgIn";
/** Plain-string starters get a default glyph + hint so the prop stays
* backward compatible. */
function normalizeSuggestion(s: string | AiSuggestion): AiSuggestion {
return typeof s === "string"
? { icon: "zap", title: s, subtitle: "Tap to ask", prompt: s }
: s;
}
/** Two-up starter tile for the welcome grid — icon disc over two-line
* prompt text; taller and squarer than the list card. */
function SuggestionTile({
item,
onPress,
}: {
item: AiSuggestion;
onPress: (prompt: string) => void;
}) {
const haptics = useHaptic();
const press = usePressAnimation({ pressedScale: 0.97, pressedOpacity: 0.75, fast: true });
return (
<AnimatedPressable
onPress={() => {
haptics.selection();
onPress(item.prompt ?? item.title);
}}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
style={press.animatedStyle}
accessibilityRole="button"
accessibilityLabel={`Suggestion: ${item.title}`}
className="w-full gap-3 overflow-hidden rounded-3xl p-4"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-3xl" pointerEvents="none" />
<Icon name={item.icon} size="md" color="foreground" />
<View className="gap-1">
<Text variant="small" numberOfLines={2} className="font-medium leading-5">
{item.title}
</Text>
{item.subtitle ? (
<Text variant="muted" numberOfLines={1} className="text-xs">
{item.subtitle}
</Text>
) : null}
</View>
</AnimatedPressable>
);
}
SuggestionTile.displayName = "SuggestionTile";
/** AIChat — the ChatGPT/Claude idiom: assistant text sits on the canvas
* with a spark mark, the user's words bubble on the right, suggestion
* cards fill the empty state, and the composer rides the keyboard. */
export function AiChatScreen({
assistantName = "Lumio",
messages: seed = [],
suggestions = DEFAULT_SUGGESTIONS,
onSend,
onNewChat,
onBack,
onAttach,
}: AiChatScreenProps) {
const insets = useSafeAreaInsets();
const reduceMotion = useReducedMotion();
const haptics = useHaptic();
const [draft, setDraft] = useState("");
const [headerH, setHeaderH] = useState(0);
const [composerH, setComposerH] = useState(0);
const [messages, setMessages] = useState<AiMessage[]>(seed);
const [thinking, setThinking] = useState(false);
const replyIdx = useRef(0);
const listRef = useRef<FlatList<AiMessage>>(null);
const streamTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const thinkTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Streaming + think timers die with the screen (and on New chat).
useEffect(
() => () => {
if (streamTimer.current) clearInterval(streamTimer.current);
if (thinkTimer.current) clearTimeout(thinkTimer.current);
},
[]
);
/** Streams `full` into a fresh assistant bubble — words land in small
* bursts like real token emission. Reduced motion skips the type-on. */
const streamReply = (full: string) => {
setThinking(false);
const id = `a-${Date.now()}`;
if (reduceMotion) {
setMessages((m) => [...m, { id, role: "assistant", text: full }]);
return;
}
setMessages((m) => [...m, { id, role: "assistant", text: "" }]);
const words = full.split(" ");
let i = 0;
streamTimer.current = setInterval(() => {
i = Math.min(words.length, i + 2 + Math.floor(Math.random() * 3));
const partial = words.slice(0, i).join(" ");
setMessages((m) => m.map((x) => (x.id === id ? { ...x, text: partial } : x)));
if (i >= words.length && streamTimer.current) {
clearInterval(streamTimer.current);
streamTimer.current = null;
}
}, STREAM_MS);
};
const resolveReply = (prompt: string) =>
CANNED_ANSWERS[prompt.toLowerCase()] ??
CANNED_ANSWERS[prompt.toLowerCase().replace(/[.?!]+$/, "")] ??
(replyIdx.current++ === 0
? fallbackReply(prompt)
: CANNED_REPLIES[replyIdx.current % CANNED_REPLIES.length] ?? CANNED_REPLIES[0]!);
const send = (raw?: string) => {
const text = (raw ?? draft).trim();
if (!text || thinking) return;
setMessages((m) => [
...m,
{ id: `u-${Date.now()}`, role: "user", text },
]);
setDraft("");
setThinking(true);
const host = onSend?.(text);
if (host && typeof (host as Promise<unknown>).then === "function") {
(host as Promise<string | void>)
.then((r) => streamReply(typeof r === "string" ? r : resolveReply(text)))
.catch(() => setThinking(false));
} else {
thinkTimer.current = setTimeout(() => streamReply(resolveReply(text)), THINK_MS);
}
};
/** New chat — reset the conversation, kill any in-flight stream. */
const reset = () => {
if (streamTimer.current) clearInterval(streamTimer.current);
if (thinkTimer.current) clearTimeout(thinkTimer.current);
setThinking(false);
setDraft("");
setMessages(seed);
onNewChat?.();
};
const empty = messages.length === 0;
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Header — back disc, identity capsule, new-chat disc. */}
<View
pointerEvents="box-none"
onLayout={(e) => setHeaderH(e.nativeEvent.layout.height)}
className="absolute inset-x-4 top-0 z-10 flex-row items-center gap-2"
style={{ top: insets.top + 8 }}
>
{onBack ? (
<Press
haptic="light"
onPress={onBack}
accessibilityRole="button"
accessibilityLabel="Go back"
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
) : null}
<View className="h-11 flex-1 flex-row items-center gap-2.5 overflow-hidden rounded-full pl-1.5 pr-4">
<Glass effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<LumioBot size={32} />
<View className="flex-1">
<Text variant="small" className="font-semibold" accessibilityRole="header">
{assistantName}
</Text>
<Text variant="muted" className="text-xs">
{thinking ? "Thinking…" : "Online"}
</Text>
</View>
</View>
{/* New chat is always real — resets the conversation even when the
host doesn't wire onNewChat. */}
<Press
haptic="light"
onPress={reset}
accessibilityRole="button"
accessibilityLabel="New chat"
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="regular" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="plus" size="md" color="foreground" />
</Press>
</View>
<KeyboardAvoiding className="flex-1">
{empty ? (
/* Welcome state — a Gemini-style tile grid, not the assistant's
centered orb + list: compact greeting up top, then tappable
prompt tiles filling the canvas. */
<View className="flex-1 justify-center px-4">
<Mount className="flex-row items-center gap-3 pb-5">
<LumioBot size={38} />
<View className="flex-1">
<Text variant="h3">Ask {assistantName}</Text>
<Text variant="muted" className="text-xs">
Streaming answers, with sources when it helps
</Text>
</View>
</Mount>
<View className="flex-row flex-wrap gap-2.5">
{suggestions.map((s, i) => {
const item = normalizeSuggestion(s);
return (
<Mount key={item.title} delay={80 + i * 50} className="w-[48%] flex-grow">
<SuggestionTile item={item} onPress={send} />
</Mount>
);
})}
</View>
</View>
) : (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item) => item.id}
className="flex-1"
contentContainerClassName="px-4 pb-6 pt-4"
contentContainerStyle={{
paddingTop: headerH + 8,
paddingBottom: composerH + 12,
}}
showsVerticalScrollIndicator={false}
onContentSizeChange={() =>
listRef.current?.scrollToEnd({ animated: false })
}
renderItem={({ item, index }) => {
const mine = item.role === "user";
const prev = messages[index - 1];
const firstOfRun = !prev || prev.role !== item.role;
return (
<MsgIn
className={cn(index > 0 && (firstOfRun ? "mt-3" : "mt-1"))}
>
{mine ? (
<View
accessible
accessibilityRole="text"
accessibilityLabel={`You: ${item.text}`}
className="ml-auto max-w-[80%] self-end rounded-2xl rounded-br-md bg-[#1B1B26] px-4 py-2.5"
>
<Text className="text-white">{item.text}</Text>
</View>
) : (
/* Assistant replies sit directly on the canvas — the
ChatGPT/Claude idiom, marked by the spark avatar. */
<View className="flex-row items-start gap-2.5">
<View className="w-7 items-center pt-0.5">
{firstOfRun ? <LumioBot size={24} /> : null}
</View>
<View className="flex-1">
<Text
accessible
accessibilityRole="text"
accessibilityLabel={`${assistantName}: ${item.text}`}
className="pt-1 text-foreground"
>
{item.text}
</Text>
{/* Action rail on the latest reply — copy/retry are
the two gestures every AI surface carries. */}
{index === messages.length - 1 && !thinking ? (
<View className="flex-row gap-1 pt-2">
<AnimatedPressable
onPress={() => haptics.light()}
accessibilityRole="button"
accessibilityLabel="Copy response"
className="h-8 w-8 items-center justify-center rounded-lg"
>
<Icon name="copy" size="sm" color="mutedForeground" />
</AnimatedPressable>
<AnimatedPressable
onPress={() => haptics.medium()}
accessibilityRole="button"
accessibilityLabel="Regenerate response"
className="h-8 w-8 items-center justify-center rounded-lg"
>
<Icon name="refresh" size="sm" color="mutedForeground" />
</AnimatedPressable>
</View>
) : null}
</View>
</View>
)}
</MsgIn>
);
}}
ListFooterComponent={
thinking ? (
<View className="mt-3 flex-row items-start gap-2.5">
<View className="w-7 items-center pt-0.5">
<LumioBot size={24} />
</View>
<View
className="flex-row items-center gap-1 overflow-hidden rounded-2xl px-4 py-3.5"
accessible
accessibilityRole="text"
accessibilityLabel={`${assistantName} is thinking`}
>
<Glass interactive effect="clear" sheen={0.45} className="absolute inset-0 rounded-2xl" pointerEvents="none" />
<ThinkingDot delay={0} dim={reduceMotion} />
<ThinkingDot delay={140} dim={reduceMotion} />
<ThinkingDot delay={280} dim={reduceMotion} />
</View>
</View>
) : null
}
/>
)}
{/* Composer — attach, growing field, send armed only with text,
then the small-print disclaimer every shipped assistant carries. */}
<View
pointerEvents="box-none"
onLayout={(e) => setComposerH(e.nativeEvent.layout.height)}
className="absolute inset-x-0 bottom-0 px-3 pt-3"
style={{ paddingBottom: insets.bottom + 4 }}
>
<View className="flex-row items-end gap-2">
<Button
variant="glass"
size="icon"
haptic="light"
onPress={onAttach}
accessibilityLabel="Add attachment"
>
<Icon name="plus" size="md" color="secondaryForeground" />
</Button>
<View className="flex-1">
<Input
placeholder="Ask anything…"
value={draft}
onChangeText={setDraft}
onSubmitEditing={() => send()}
returnKeyType="send"
className="rounded-full"
glass
rightIcon={
<Icon name="mic" size="sm" color="mutedForeground" />
}
accessibilityLabel="Message the assistant"
/>
</View>
<Button
size="icon"
className="bg-[#1B1B26]"
haptic="medium"
disabled={draft.trim().length === 0 || thinking}
onPress={() => send()}
accessibilityLabel={thinking ? `${assistantName} is replying` : "Send message"}
accessibilityState={{ disabled: draft.trim().length === 0 || thinking }}
>
<Icon name="arrow-up" size="sm" color="#FFFFFF" />
</Button>
</View>
<Text
variant="muted"
className="pt-2 text-center text-[11px] leading-4"
>
{assistantName} can make mistakes — check important info.
</Text>
</View>
</KeyboardAvoiding>
</SafeAreaView>
);
}
AiChatScreen.displayName = "AiChatScreen";
```
Screen: https://lumioui.dev/screens/chat-ai