Chat UI

A transcript and composer built from PanelUI components.

A chat screen is three problems: rendering a growing list without re-rendering all of it, keeping the newest turn visible, and keeping the composer above the keyboard. This page solves all three with components already in the library.

The whole screen

app/chat.tsx
import { useCallback, useState } from 'react';
import { FlatList, View } from 'react-native';
import { fetch as expoFetch } from 'expo/fetch';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import {
  Alert,
  Avatar,
  Button,
  Input,
  CodeBlock,
  Message,
  Reasoning,
  SendIcon,
  Shimmer,
  Sources,
  Task,
  Text,
} from 'panelui-native';
import { apiBaseUrl } from '../lib/api';

export default function Chat() {
  const [input, setInput] = useState('');

  const { messages, sendMessage, status, error, stop } = useChat({
    transport: new DefaultChatTransport({
      fetch: expoFetch as unknown as typeof globalThis.fetch,
      api: `${apiBaseUrl()}/api/chat`,
    }),
  });

  const isBusy = status === 'submitted' || status === 'streaming';

  const send = useCallback(() => {
    const text = input.trim();
    if (!text || isBusy) return;
    setInput('');
    sendMessage({ text });
  }, [input, isBusy, sendMessage]);

  const lastId = messages.at(-1)?.id;
  const renderItem = useCallback(
    ({ item }: { item: UIMessage }) => (
      <Turn
        message={item}
        // Only the turn actually arriving is told it is streaming, so the
        // rest of the transcript is not re-rendered by the same flag.
        isStreaming={item.id === lastId && status === 'streaming'}
      />
    ),
    [lastId, status]
  );

  return (
    <View className="flex-1 bg-background">
      <FlatList
        data={messages}
        inverted
        keyExtractor={(m) => m.id}
        renderItem={renderItem}
        contentContainerClassName="gap-3 px-4 py-3"
        keyboardDismissMode="interactive"
        removeClippedSubviews
      />

      {status === 'submitted' ? <Thinking /> : null}

      {error ? (
        <Alert variant="destructive" className="mx-4 mb-2">
          <Alert.Indicator />
          <Alert.Content>
            <Alert.Title>Could not get a response</Alert.Title>
            <Alert.Description>{error.message}</Alert.Description>
          </Alert.Content>
        </Alert>
      ) : null}

      <Composer
        value={input}
        onChangeText={setInput}
        onSend={send}
        onStop={stop}
        isBusy={isBusy}
      />
    </View>
  );
}

Why the list is inverted

An inverted FlatList renders bottom-up, so the newest turn is pinned to the bottom for free. The alternative — a ScrollView with scrollToEnd on every token — measures content on every frame of a stream, which is exactly the work you are trying to avoid.

inverted flips the list, so pass the messages newest first. useChat gives them oldest first, so either reverse the array or render the data as-is and drop inverted — but then you are back to scrolling manually.

<FlatList data={[...messages].reverse()} inverted />

Reversing allocates a new array each render. For long transcripts, keep the reversed copy in a useMemo keyed on messages.

One turn

Each message's content lives in parts, an ordered array — the model may produce text, then a tool call, then more text, and the order is meaningful. A turn is a switch over that array.

Two of the part kinds are gathered rather than mapped. Reasoning parts are joined into one block, because some models emit a run of them for a single thought and one component per part is a column of "Thinking…" rows. Source parts are collected into one list, because the trigger has to know how many there are before the list is drawn.

import { memo } from 'react';
import {
  CodeBlock,
  Message,
  Reasoning,
  Sources,
  Task,
} from 'panelui-native';

/** A tool part's `state`, as the status a Task draws. */
const TASK_STATUS = {
  'input-streaming': 'pending',
  'input-available': 'running',
  'output-available': 'complete',
  'output-error': 'error',
} as const;

const Turn = memo(function Turn({
  message,
  isStreaming,
}: {
  message: UIMessage;
  isStreaming: boolean;
}) {
  const isUser = message.role === 'user';

  // Gathered, not mapped — see above.
  const reasoning = message.parts.filter((part) => part.type === 'reasoning');
  const sources = message.parts.filter((part) => part.type === 'source-url');
  const trace = reasoning.map((part) => part.text).join('\n\n');

  // Still arriving only if the *last* part is the reasoning one; otherwise the
  // trace finished before the answer started and should not shimmer.
  const tracing =
    isStreaming && message.parts.at(-1)?.type === 'reasoning';

  return (
    <Message align={isUser ? 'end' : 'start'}>
      {!isUser && (
        <Message.Avatar>
          <Avatar size="sm" fallback="AI" />
        </Message.Avatar>
      )}
      <Message.Content>
        {reasoning.length > 0 && (
          <Reasoning isStreaming={tracing}>
            <Reasoning.Trigger />
            <Reasoning.Content>{trace}</Reasoning.Content>
          </Reasoning>
        )}

        {message.parts.map((part, index) => {
          const key = `${message.id}-${index}`;

          if (part.type === 'text') {
            return (
              <Message.Bubble key={key}>
                <Message.BubbleContent>{part.text}</Message.BubbleContent>
              </Message.Bubble>
            );
          }

          if (part.type.startsWith('tool-')) {
            return (
              <Task key={key} status={TASK_STATUS[part.state]}>
                <Task.Trigger title={part.type.replace('tool-', '')} />
                <Task.Content>
                  <Task.Item>{JSON.stringify(part.input)}</Task.Item>
                </Task.Content>
              </Task>
            );
          }

          // Reasoning and sources are handled above; everything else is
          // deliberately not rendered rather than rendered badly.
          return null;
        })}

        {sources.length > 0 && (
          <Sources>
            <Sources.Trigger count={sources.length} />
            <Sources.Content>
              {sources.map((part) => (
                <Sources.Source
                  key={part.sourceId}
                  href={part.url}
                  title={part.title}
                />
              ))}
            </Sources.Content>
          </Sources>
        )}
      </Message.Content>
    </Message>
  );
});

Where each piece goes is not arbitrary. The reasoning sits above the bubble because it is about the answer rather than part of it; the sources sit below because they are what the answer rests on and are read after it. A code block goes beside the bubble rather than inside it — a snippet in a coloured bubble inherits the bubble's foreground and stops being legible.

if (part.type === 'text' && part.text.startsWith('```')) {
  const [fence, ...rest] = part.text.split('\n');
  return (
    <CodeBlock
      key={key}
      language={fence.replace('```', '')}
      code={rest.join('\n').replace(/```$/, '')}
    >
      <CodeBlock.Header>
        <CodeBlock.Language>{fence.replace('```', '')}</CodeBlock.Language>
        <CodeBlock.Actions>
          <CodeBlock.CopyButton />
        </CodeBlock.Actions>
      </CodeBlock.Header>
    </CodeBlock>
  );
}

Pass isStreaming as message.id === messages.at(-1)?.id && status === 'streaming' so only the turn that is actually arriving is told it is.

memo matters here. Without it, every message in the transcript re-renders on every token of the one still streaming. With it, only the streaming turn does — see Streaming performance.

The thinking state

Between sending and the first token there is nothing to show. Shimmer masks the sweep into the text itself, which is the treatment this state has settled on across AI apps.

function Thinking() {
  return (
    <Message className="px-4 pb-2">
      <Message.Avatar>
        <Avatar size="sm" fallback="AI" />
      </Message.Avatar>
      <Message.Content>
        <Message.Bubble>
          <Shimmer textClassName="text-base">Thinking…</Shimmer>
        </Message.Bubble>
      </Message.Content>
    </Message>
  );
}

Drop it the moment status becomes streaming — once text is arriving, the text is the progress indicator.

For a reasoning model there is usually something better to show than a shimmer, because the wait is not empty: the trace is arriving. Reasoning carries its own shimmering "Thinking…" while it streams and settles into "Thought for 8 seconds" afterwards, so the gap before the answer becomes the model's working rather than a placeholder.

The composer

avoidKeyboard lifts the field by exactly the overlap with the keyboard, so a composer already above the fold does not jump.

function Composer({ value, onChangeText, onSend, onStop, isBusy }) {
  return (
    <View className="flex-row items-end gap-2 border-t border-border px-4 py-3">
      <Input
        avoidKeyboard
        containerClassName="flex-1"
        value={value}
        onChangeText={onChangeText}
        placeholder="Message"
        multiline
        submitBehavior="submit"
        onSubmitEditing={onSend}
      />
      {isBusy ? (
        <Button size="icon" variant="outline" onPress={onStop} accessibilityLabel="Stop">
          <Text>■</Text>
        </Button>
      ) : (
        <Button
          size="icon"
          onPress={onSend}
          disabled={!value.trim()}
          accessibilityLabel="Send"
        >
          <SendIcon size={18} />
        </Button>
      )}
    </View>
  );
}

Swapping send for stop while a response streams is worth the few lines. A user who asked the wrong question should not have to wait out the answer.

Softening the edges

Optional, and cheap — the transcript no longer ends in a hard cut under the composer.

<ScrollFade edges="start" size={32} className="flex-1">
  <FlatList inverted data={reversed} renderItem={renderItem} />
</ScrollFade>

On an inverted list start is the bottom of the screen, because the list's own axis is flipped.

Message actions

Message.Actions reverses its row on the outgoing side, so the controls stay on the message's own edge.

<Message.Content>
  <Message.Bubble>{/* … */}</Message.Bubble>
  {!isUser && status === 'ready' && (
    <Message.Actions>
      <Button size="sm" variant="ghost" onPress={() => copy(text)}>
        Copy
      </Button>
      <Button size="sm" variant="ghost" onPress={() => regenerate()}>
        Retry
      </Button>
    </Message.Actions>
  )}
</Message.Content>

regenerate comes from useChat and re-runs the last assistant turn.

On this page