v0.1 public beta early access on GitHub
Home / Screens / chat-ai
Screen template

AI Chat Screen

Assistant conversation — spark-marked canvas replies, user bubbles, animated thinking indicator, starter-card welcome state, and an attach + send composer pinned to the keyboard

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection · medium
$ npx lumioui add chat-ai
import { AiChatScreen } from "@/screens/chat/ai";
Registry dependencies5
Native packages installed6
@lumioui/ui@latest@lumioui/core@latestreact-native-reanimated@*react-native-safe-area-context@*react-native-svg@*nativewind@*
chat/ai.tsxsource · yours to edit
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";
agent prompt — paste into Claude Code, Cursor, Devin…
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
iPhone 17 · iOS
9:41
Live preview — the real screen via Expo Web · open full ↗