# Chat

A conversation thread where each turn holds a message, the agent's steps and its reply, inside a Message Scroller.

Source: https://suiss.app/ui/docs/ai/chat

**React**

**Example**

```tsx
"use client"

import {
  ArrowClockwiseIcon,
  ArrowUpIcon,
  BinocularsIcon,
  CheckCircleIcon,
  GlobeIcon,
  PaperclipIcon,
  PlusIcon,
  TerminalIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace"

type Step =
  | { kind: "reasoning"; text: string }
  | { kind: "command"; command: string; output: string }

type Turn = {
  id: string
  prompt: string
  steps: Step[]
  reply: React.ReactNode
}

const initialTurns: Turn[] = [
  {
    id: "1",
    prompt: "Add a dark mode toggle to the settings page.",
    steps: [
      {
        kind: "reasoning",
        text: "I need to know how themes are set up before I add a toggle. If next-themes is already in the layout, the toggle only has to call setTheme.",
      },
      {
        kind: "command",
        command: "rg -l ThemeProvider app",
        output: "app/layout.tsx",
      },
    ],
    reply: (
      <>
        <p>
          next-themes is already set up in <code>app/layout.tsx</code>, so the
          toggle only has to call <code>setTheme</code>.
        </p>
        <ul className="list-disc ps-5">
          <li>
            Added <code>components/mode-toggle.tsx</code> with a Light and Dark
            option.
          </li>
          <li>
            Put <code>{"<ModeToggle />"}</code> next to the Appearance heading
            in <code>app/settings/page.tsx</code>.
          </li>
        </ul>
      </>
    ),
  },
  {
    id: "2",
    prompt: "Make it follow the system setting by default.",
    steps: [
      {
        kind: "reasoning",
        text: "The default theme lives on the ThemeProvider. I should check what it is set to now.",
      },
      {
        kind: "command",
        command: "rg -n defaultTheme app/layout.tsx",
        output: '12:  <ThemeProvider attribute="class" defaultTheme="light">',
      },
    ],
    reply: (
      <>
        <p>
          It was set to <code>light</code>. I updated the{" "}
          <code>ThemeProvider</code> in <code>app/layout.tsx</code>:
        </p>
        <CodeBlock size="sm">
          <CodeBlockContent wrap>
            {
              '<ThemeProvider attribute="class" defaultTheme="system" enableSystem>'
            }
          </CodeBlockContent>
        </CodeBlock>
        <p>
          New visitors now get the theme from their system. The toggle also has
          a System option, so anyone who picked Light or Dark can switch back.
        </p>
      </>
    ),
  },
  {
    id: "3",
    prompt: "Run the build before I ship it.",
    steps: [
      {
        kind: "command",
        command: "pnpm build",
        output:
          "✓ Compiled successfully in 4.2s\n✓ Linting and checking validity of types\n✓ Generating static pages (12/12)",
      },
    ],
    reply: (
      <>
        <p>The build passes, so it is ready to ship.</p>
        <ul className="list-disc ps-5">
          <li>Compiled in 4.2s.</li>
          <li>No lint or type errors.</li>
          <li>Generated all 12 static pages.</li>
        </ul>
      </>
    ),
  },
]

function StepRow({ step }: { step: Step }) {
  if (step.kind === "reasoning") {
    return (
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <CheckCircleIcon />
            </TraceIcon>
            <TraceTitle>Thought</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>{step.text}</TraceCollapsibleContent>
      </TraceCollapsible>
    )
  }

  return (
    <TraceCollapsible>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>Ran {step.command}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <CodeBlock size="sm">
          <CodeBlockContent wrap className="max-h-64">
            {step.output}
          </CodeBlockContent>
        </CodeBlock>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  )
}

export default function ChatDemo() {
  const [turns, setTurns] = React.useState(initialTurns)
  const [text, setText] = React.useState("")
  const formRef = React.useRef<HTMLFormElement>(null)
  const canSend = text.trim().length > 0

  function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    if (!canSend) {
      return
    }

    setTurns((current) => [
      ...current,
      {
        id: String(current.length + 1),
        prompt: text.trim(),
        steps: [],
        reply: (
          <p>
            This demo only knows the scripted turns. Press reset to start over.
          </p>
        ),
      },
    ])
    setText("")
  }

  return (
    <MessageScrollerProvider>
      <div className="flex h-[36rem] w-full max-w-xl flex-col gap-4">
        <div className="flex items-start justify-between gap-4">
          <div className="flex flex-col gap-1">
            <div className="font-medium">New thread</div>
            <div className="text-sm text-muted-foreground">
              Ask the agent to change your code.
            </div>
          </div>
          <Tooltip>
            <TooltipTrigger
              render={
                <Button
                  variant="outline"
                  size="icon"
                  aria-label="Reset conversation"
                  onClick={() => {
                    setTurns(initialTurns)
                    setText("")
                  }}
                />
              }
            >
              <ArrowClockwiseIcon />
            </TooltipTrigger>
            <TooltipContent>Reset</TooltipContent>
          </Tooltip>
        </div>
        <div className="min-h-0 flex-1">
          <MessageScroller>
            <MessageScrollerViewport>
              <MessageScrollerContent className="gap-6 py-2 text-sm">
                {turns.map((turn) => (
                  <React.Fragment key={turn.id}>
                    <MessageScrollerItem
                      messageId={`${turn.id}-prompt`}
                      scrollAnchor
                    >
                      <Message align="end">
                        <MessageContent>
                          <Bubble align="end" variant="muted">
                            <BubbleContent>{turn.prompt}</BubbleContent>
                          </Bubble>
                        </MessageContent>
                      </Message>
                    </MessageScrollerItem>
                    <MessageScrollerItem messageId={`${turn.id}-reply`}>
                      <Message>
                        <MessageContent className="gap-3 text-sm [&_code]:font-mono [&_code]:text-[0.9em]">
                          {turn.steps.length > 0 && (
                            <Trace>
                              {turn.steps.map((step) => (
                                <StepRow
                                  key={
                                    step.kind === "command"
                                      ? step.command
                                      : step.text
                                  }
                                  step={step}
                                />
                              ))}
                            </Trace>
                          )}
                          {turn.reply}
                        </MessageContent>
                      </Message>
                    </MessageScrollerItem>
                  </React.Fragment>
                ))}
              </MessageScrollerContent>
            </MessageScrollerViewport>
            <MessageScrollerButton />
          </MessageScroller>
        </div>
        <form ref={formRef} onSubmit={onSubmit} className="w-full">
          <InputGroup>
            <InputGroupTextarea
              value={text}
              onChange={(event) => setText(event.target.value)}
              placeholder="Ask the agent..."
              aria-label="Message"
              className="min-h-14"
              onKeyDown={(event) => {
                if (event.key === "Enter" && !event.shiftKey) {
                  event.preventDefault()
                  formRef.current?.requestSubmit()
                }
              }}
            />
            <InputGroupAddon align="block-end">
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <InputGroupButton
                      aria-label="Add context"
                      size="icon-sm"
                      variant="outline"
                    />
                  }
                >
                  <PlusIcon />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="start" side="top" className="w-48">
                  <DropdownMenuItem>
                    <PaperclipIcon />
                    Add photos and files
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem>
                    <BinocularsIcon />
                    Deep research
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <GlobeIcon />
                    Web search
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
              <InputGroupButton
                type="submit"
                variant="default"
                size="icon-sm"
                disabled={!canSend}
                aria-label="Send"
                className="ms-auto"
              >
                <ArrowUpIcon />
              </InputGroupButton>
            </InputGroupAddon>
          </InputGroup>
        </form>
      </div>
    </MessageScrollerProvider>
  )
}
```

Chat is the thread of a conversation with an agent. Each turn is a [Message](https://suiss.app/ui/docs/ai/message) inside a [Message Scroller](https://suiss.app/ui/docs/components/message-scroller). The steps the agent took sit above its reply as [Trace](https://suiss.app/ui/docs/components/trace) rows, and the field at the bottom is a [Prompt Input](https://suiss.app/ui/docs/ai/prompt-input).

The person's message is the scroll anchor, so a new question settles near the top and the reply streams in under it.

## Installation

Chat is built with Message Scroller, Message, Bubble, Trace, Code Block, Input Group, Dropdown Menu and Tooltip.

```bash title="Terminal"
npx shadcn@latest add @esuiss/message-scroller @esuiss/message @esuiss/bubble @esuiss/trace @esuiss/code-block @esuiss/input-group @esuiss/dropdown-menu @esuiss/tooltip
```

Or use the [suiss/ui CLI](https://suiss.app/ui/docs/cli):

```bash title="Terminal"
npx @esuiss/ui@latest add message-scroller message bubble trace code-block input-group dropdown-menu tooltip
```

## Usage

```tsx
<MessageScrollerProvider autoScroll>
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        <MessageScrollerItem messageId={id} scrollAnchor>
          <Message align="end">
            <MessageContent>
              <Bubble align="end" variant="muted">
                <BubbleContent>{text}</BubbleContent>
              </Bubble>
            </MessageContent>
          </Message>
        </MessageScrollerItem>
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>
```

Set `scrollAnchor` on the person's turn. `autoScroll` follows the reply while the person stays at the live edge, and `MessageScrollerButton` jumps back down when they scroll away.

## AI SDK

Render each message from `useChat`. The person's turn is a bubble. The agent's turn maps its reasoning and tool parts to Trace rows, then shows the text.

```tsx
const { messages, sendMessage, status } = useChat()

{messages.map((message) =>
  message.role === "user" ? (
    <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor>
      …
    </MessageScrollerItem>
  ) : (
    <MessageScrollerItem key={message.id} messageId={message.id}>
      …
    </MessageScrollerItem>
  )
)}
```

Set `aria-busy` on `MessageScrollerContent` while `status` is `submitted` or `streaming`.

## API Reference

See [Message Scroller](https://suiss.app/ui/docs/components/message-scroller#api-reference), [Message](https://suiss.app/ui/docs/components/message#api-reference), [Trace](https://suiss.app/ui/docs/components/trace#api-reference) and [Input Group](https://suiss.app/ui/docs/components/input-group#api-reference).

## Accessibility

- Give the textarea an accessible name such as "Message", since the placeholder is not a label.
- Give icon-only buttons, such as reset, add context and send, an `aria-label`.
- Enter sends the message and Shift+Enter adds a new line.

**React Native, SwiftUI, Jetpack Compose**

```tsx
import { ArrowUpIcon, CheckCircleIcon, PlusIcon, TerminalIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block"
import { Icon } from "@/components/ui/icon"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import { Text } from "@/components/ui/text"
import { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"

export function AiChatExample() {
  return (
    <View className="w-full gap-5">
      <Message align="end">
        <MessageContent>
          <Bubble variant="muted">
            <BubbleContent>
              Default the theme to the system setting.
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <View className="gap-3">
        <Trace>
          <TraceLine>
            <TraceIcon>
              <Icon as={TerminalIcon} />
            </TraceIcon>
            <TraceTitle>Ran rg -n defaultTheme app/layout.tsx</TraceTitle>
          </TraceLine>
        </Trace>
        <Text>It was set to light. I updated the ThemeProvider:</Text>
        <CodeBlock size="sm">
          <CodeBlockContent language="tsx">
            {'<ThemeProvider defaultTheme="system" enableSystem>'}
          </CodeBlockContent>
        </CodeBlock>
      </View>
      <Message align="end">
        <MessageContent>
          <Bubble variant="muted">
            <BubbleContent>Run the build before I ship it.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <View className="gap-3">
        <Trace>
          <TraceLine>
            <TraceIcon>
              <Icon as={CheckCircleIcon} />
            </TraceIcon>
            <TraceTitle>Ran pnpm build</TraceTitle>
          </TraceLine>
        </Trace>
        <Text>The build passes, so it is ready to ship.</Text>
      </View>
      <InputGroup>
        <InputGroupTextarea placeholder="Ask the agent..." numberOfLines={2} />
        <InputGroupAddon align="block-end" className="justify-between">
          <InputGroupButton size="icon-xs" accessibilityLabel="Attach a file">
            <Icon as={PlusIcon} />
          </InputGroupButton>
          <InputGroupButton
            variant="default"
            size="icon-xs"
            accessibilityLabel="Send"
          >
            <Icon as={ArrowUpIcon} />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct ChatExample: View {
  @State private var message = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 20) {
      Message(align: .end) {
        MessageContent {
          Bubble(variant: .muted, align: .end) { BubbleContent("Default the theme to the system setting.") }
        }
      }
      VStack(alignment: .leading, spacing: 12) {
        Trace {
          TraceLine {
            TraceIcon { Ph.terminal.regular }
            TraceTitle(verbatim: "Ran rg -n defaultTheme app/layout.tsx")
          }
        }
        Text("It was set to light. I updated the ThemeProvider:").textStyle(.base).foregroundStyle(EsuissColor.foreground)
        CodeBlock(size: .sm) {
          CodeBlockContent("<ThemeProvider defaultTheme=\"system\" enableSystem>", language: "tsx")
        }
      }
      Message(align: .end) {
        MessageContent {
          Bubble(variant: .muted, align: .end) { BubbleContent("Run the build before I ship it.") }
        }
      }
      VStack(alignment: .leading, spacing: 12) {
        Trace {
          TraceLine {
            TraceIcon { Ph.checkCircle.regular }
            TraceTitle(verbatim: "Ran pnpm build")
          }
        }
        Text("The build passes, so it is ready to ship.").textStyle(.base).foregroundStyle(EsuissColor.foreground)
      }
      InputGroup {
        InputGroupTextarea("Ask the agent...", text: $message, lineLimit: 2...6)
        InputGroupAddon(align: .blockEnd) {
          InputGroupButton(size: .iconXs) {} label: {
            Ph.plus.regular.frame(width: 16, height: 16)
          }
          .accessibilityLabel(Text("Attach a file"))
          Spacer(minLength: 0)
          InputGroupButton(variant: .default, size: .iconXs) {} label: {
            Ph.arrowUp.regular.frame(width: 16, height: 16)
          }
          .accessibilityLabel(Text("Send"))
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowUp
import com.adamglin.phosphoricons.regular.CheckCircle
import com.adamglin.phosphoricons.regular.Plus
import com.adamglin.phosphoricons.regular.Terminal
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.CodeBlock
import com.esuiss.ui.components.CodeBlockContent
import com.esuiss.ui.components.CodeBlockSize
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupAddonAlign
import com.esuiss.ui.components.InputGroupButton
import com.esuiss.ui.components.InputGroupButtonSize
import com.esuiss.ui.components.InputGroupTextarea
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Trace
import com.esuiss.ui.components.TraceIcon
import com.esuiss.ui.components.TraceLine
import com.esuiss.ui.components.TraceTitle

@Composable
fun ChatAIDefault() {
    var message by rememberSaveable { mutableStateOf("") }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(20.dp)) {
        Message(Modifier.fillMaxWidth(), align = MessageAlign.End) {
            MessageContent {
                Bubble(variant = BubbleVariant.Muted) { BubbleContent("Default the theme to the system setting.") }
            }
        }
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            Trace {
                TraceLine {
                    TraceIcon { Icon(PhosphorIcons.Regular.Terminal, contentDescription = null) }
                    TraceTitle("Ran rg -n defaultTheme app/layout.tsx")
                }
            }
            Text("It was set to light. I updated the ThemeProvider:")
            CodeBlock(size = CodeBlockSize.Sm) {
                CodeBlockContent("<ThemeProvider defaultTheme=\"system\" enableSystem>", language = "tsx")
            }
        }
        Message(Modifier.fillMaxWidth(), align = MessageAlign.End) {
            MessageContent {
                Bubble(variant = BubbleVariant.Muted) { BubbleContent("Run the build before I ship it.") }
            }
        }
        Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
            Trace {
                TraceLine {
                    TraceIcon { Icon(PhosphorIcons.Regular.CheckCircle, contentDescription = null) }
                    TraceTitle("Ran pnpm build")
                }
            }
            Text("The build passes, so it is ready to ship.")
        }
        InputGroup {
            InputGroupTextarea(message, { message = it }, placeholder = "Ask the agent...", minLines = 2)
            InputGroupAddon(Modifier.fillMaxWidth(), align = InputGroupAddonAlign.BlockEnd) {
                InputGroupButton(onClick = {}, size = InputGroupButtonSize.IconXs) {
                    Icon(PhosphorIcons.Regular.Plus, contentDescription = "Attach a file")
                }
                Spacer(Modifier.weight(1f))
                InputGroupButton(onClick = {}, size = InputGroupButtonSize.IconXs, variant = ButtonVariant.Default) {
                    Icon(PhosphorIcons.Regular.ArrowUp, contentDescription = "Send")
                }
            }
        }
    }
}
```

Chat on mobile is built from the same parts as on the web: [Message](https://suiss.app/ui/docs/components/message), [Bubble](https://suiss.app/ui/docs/components/bubble), [Trace](https://suiss.app/ui/docs/components/trace), [Code Block](https://suiss.app/ui/docs/components/code-block), [Input Group](https://suiss.app/ui/docs/components/input-group). The example above is a static demo; connect it to your agent by rendering the same parts from your messages.

## Installation

**React Native**

```bash title="Terminal"
npx @esuiss/ui@latest add message bubble trace code-block input-group
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add message bubble trace code-block input-group --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add message bubble trace code-block input-group --platform kotlin
```

## API Reference

See [Message](https://suiss.app/ui/docs/components/message), [Bubble](https://suiss.app/ui/docs/components/bubble), [Trace](https://suiss.app/ui/docs/components/trace), [Code Block](https://suiss.app/ui/docs/components/code-block), [Input Group](https://suiss.app/ui/docs/components/input-group).

