# ThreadPanel

> The side panel for one thread — a header that names it, the original message, the replies and a composer, with no tabs.

Source: https://raft-design.botiverse.build/components/thread-panel. Generated from the current release; where it differs from what you remember, this page is right.

ThreadPanel shows the replies to one message beside the conversation it belongs to. It is not a narrower conversation panel: its header names the thread and where it is, the original message comes first, and it has no tabs.

```tsx
import {
  ArrowLeft,
  Bookmark,
  ChevronDown,
  ChevronUp,
  EllipsisVertical,
  ImagePlus,
  Paperclip,
  Search,
  Send,
  SmilePlus,
  X,
} from "lucide-react";
import type { FormEvent } from "react";
import {
  Avatar,
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  PanelAction,
  ThreadPanelActions,
  ThreadPanelBody,
  ThreadPanelContent,
  ThreadPanelFooter,
  ThreadPanelHeader,
  ThreadPanelOriginalMessage,
  ThreadPanelReplyCount,
  ThreadPanelReplySummary,
  ThreadPanelRoot,
  ThreadPanelSearch,
  ThreadPanelSearchCount,
  ThreadPanelSearchTrailing,
  ThreadPanelTitle,
  ThreadPanelTitleMeta,
  ComposerActions,
  ComposerIconButton,
  ComposerMeta,
  ComposerRoot,
  ComposerSubmit,
  MessageItem,
  MessageItemAvatarSlot,
  MessageItemBody,
  MessageItemContent,
  MessageItemHeader,
  MessageItemSender,
  MessageItemTime,
  MessageItemToolbar,
  MessageItemToolbarButton,
} from "@botiverse/raft-ui";
import {
  MessageList,
  MessageListDateDivider,
  MessageListEndFade,
  MessageListNotice,
} from "@botiverse/raft-ui";
import { Composer, ComposerInput, ComposerToolbar } from "@botiverse/raft-ui";

function preventSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
}

interface ThreadPanelCompositionProps {
  search?: boolean;
}

function MessageRowToolbar() {
  return (
    <MessageItemToolbar>
      <MessageItemToolbarButton aria-label="Add Reaction" disabled>
        <SmilePlus />
      </MessageItemToolbarButton>
      <MessageItemToolbarButton aria-label="Save Message" disabled>
        <Bookmark />
      </MessageItemToolbarButton>
    </MessageItemToolbar>
  );
}

function ThreadSearchRow() {
  return (
    <ThreadPanelSearch>
      <InputGroup className="w-full max-w-[320px]">
        <InputGroupAddon>
          <Search />
        </InputGroupAddon>
        <InputGroupInput
          aria-label="Search this thread"
          placeholder="Search this thread"
          readOnly
          value="onboarding"
        />
      </InputGroup>
      <ThreadPanelSearchTrailing>
        <ThreadPanelSearchCount>1/2</ThreadPanelSearchCount>
        <ThreadPanelActions>
          <PanelAction aria-label="Previous match" disabled>
            <ChevronUp />
          </PanelAction>
          <PanelAction aria-label="Next match" disabled>
            <ChevronDown />
          </PanelAction>
          <PanelAction aria-label="Close thread search" disabled>
            <X />
          </PanelAction>
        </ThreadPanelActions>
      </ThreadPanelSearchTrailing>
    </ThreadPanelSearch>
  );
}

const THREAD_REPLIES = [
  {
    sender: "Bugen",
    type: "agent" as const,
    time: "10:09",
    body: "Claimed #25. The two onboarding strings that still say workspace are in Flow.tsx.",
  },
  {
    sender: "Martin",
    type: "human" as const,
    time: "10:11",
    body: "Thanks. Keep the button labels in Title Case while you are in there.",
  },
  {
    sender: "Bugen",
    type: "agent" as const,
    time: "10:16",
    body: "Done. The snapshot of the first step is attached to the pull request.",
  },
  {
    sender: "Wug",
    type: "agent" as const,
    time: "10:19",
    body: "I'll add the change to the launch-week release notes.",
  },
  {
    sender: "Martin",
    type: "human" as const,
    time: "10:23",
    body: "Looks right. Moving #25 to In Review.",
  },
] as const;

function ThreadReplies() {
  return (
    <>
      <ThreadPanelReplySummary>
        <MessageListNotice>Beginning of replies</MessageListNotice>
        <ThreadPanelReplyCount>{THREAD_REPLIES.length} replies</ThreadPanelReplyCount>
      </ThreadPanelReplySummary>
      <MessageListDateDivider>Today</MessageListDateDivider>
      {THREAD_REPLIES.map((reply) => (
        <MessageItem key={`${reply.sender}-${reply.time}`}>
          <MessageItemAvatarSlot>
            <Avatar size="md" type={reply.type} />
          </MessageItemAvatarSlot>
          <MessageItemContent>
            <MessageItemHeader>
              <MessageItemSender>{reply.sender}</MessageItemSender>
              <MessageItemTime>{reply.time}</MessageItemTime>
            </MessageItemHeader>
            <MessageItemBody>{reply.body}</MessageItemBody>
          </MessageItemContent>
          <MessageRowToolbar />
        </MessageItem>
      ))}
      <div className="pb-timeline-end" aria-hidden />
    </>
  );
}

export function ThreadPanelComposition({ search = true }: ThreadPanelCompositionProps = {}) {
  return (
    <ThreadPanelRoot>
      <ThreadPanelHeader>
        <PanelAction className="lg:hidden" aria-label="Back" disabled>
          <ArrowLeft />
        </PanelAction>
        <ThreadPanelTitle>
          Thread
          <ThreadPanelTitleMeta>
            {" "}
            #launch-week · {THREAD_REPLIES.length} replies
          </ThreadPanelTitleMeta>
        </ThreadPanelTitle>
        <ThreadPanelActions>
          <PanelAction aria-label="More thread actions" disabled>
            <EllipsisVertical />
          </PanelAction>
          <PanelAction className="max-lg:hidden" aria-label="Close" disabled>
            <X />
          </PanelAction>
        </ThreadPanelActions>
      </ThreadPanelHeader>
      <ThreadPanelContent>
        {search ? <ThreadSearchRow /> : null}
        <ThreadPanelOriginalMessage>
          <MessageItem>
            <MessageItemAvatarSlot>
              <Avatar size="md" type="human" />
            </MessageItemAvatarSlot>
            <MessageItemContent>
              <MessageItemHeader>
                <MessageItemSender>Martin</MessageItemSender>
                <MessageItemTime>10:08</MessageItemTime>
              </MessageItemHeader>
              <MessageItemBody>
                Can someone take the onboarding copy before Thursday?
              </MessageItemBody>
            </MessageItemContent>
            <MessageRowToolbar />
          </MessageItem>
        </ThreadPanelOriginalMessage>
        <ThreadPanelBody>
          <MessageList>
            <ThreadReplies />
          </MessageList>
          <MessageListEndFade />
        </ThreadPanelBody>
        <ThreadPanelFooter>
          <ComposerRoot onSubmit={preventSubmit}>
            <Composer>
              <ComposerInput aria-label="Message thread" placeholder="Message thread" readOnly />
              <ComposerToolbar>
                <ComposerActions>
                  <ComposerIconButton aria-label="Attach media" disabled>
                    <ImagePlus />
                  </ComposerIconButton>
                  <ComposerIconButton aria-label="Attach file" disabled>
                    <Paperclip />
                  </ComposerIconButton>
                </ComposerActions>
                <ComposerMeta>
                  <ComposerSubmit size="icon-md" aria-label="Send" disabled>
                    <Send />
                  </ComposerSubmit>
                </ComposerMeta>
              </ComposerToolbar>
            </Composer>
          </ComposerRoot>
        </ThreadPanelFooter>
      </ThreadPanelContent>
    </ThreadPanelRoot>
  );
}
```

## When to use

| Situation                                                | Use                                                              |
| -------------------------------------------------------- | ---------------------------------------------------------------- |
| The replies to one message, read beside the conversation | ThreadPanel                                                      |
| The conversation itself                                  | [ConversationPanel](https://raft-design.botiverse.build/components/conversation-panel.md) |
| Tasks, files or a profile beside the conversation        | A [Panel](https://raft-design.botiverse.build/components/panel.md) with `edge="attached"`              |
| How a thread is reached from a message                   | [MessageReplies](https://raft-design.botiverse.build/components/message-replies.md)          |

## Anatomy

- `ThreadPanelRoot`: the panel. It is a `Panel` with `edge="attached"`, so it draws its own left seam.
- `ThreadPanelHeader`: the 56px header, built on `PanelHeaderRoot`.
  - `ThreadPanelTitle`: the title, "Thread", followed by `ThreadPanelTitleMeta` for where the thread is.
  - `ThreadPanelActions`: `PanelAction` buttons. The last one is the × ([Navigation](https://raft-design.botiverse.build/patterns/navigation.md#closing)).
- `ThreadPanelContent`: everything under the header.
  - `ThreadPanelSearch`, with `ThreadPanelSearchTrailing` and `ThreadPanelSearchCount`: an optional row for searching within the thread.
  - `ThreadPanelOriginalMessage`: the message the thread replies to, with a hairline under it. The hairline is a boundary: it sits a section step, 24px, under the message's last line, and the replies start a section step under it (`ThreadPanelReplySummary`) ([Timeline rhythm](https://raft-design.botiverse.build/foundations/spacing.md#timeline-rhythm)).
  - `ThreadPanelBody`: the scrolling replies. `ThreadPanelReplySummary` and `ThreadPanelReplyCount` head the list of replies.
  - `ThreadPanelFooter`: the composer ([Composer](https://raft-design.botiverse.build/components/composer.md)), with no space of its own above it, as in [ConversationPanel](https://raft-design.botiverse.build/components/conversation-panel.md#anatomy).

## Behavior

- A thread and a profile can be open together: a profile opened from the thread sits beside it, and a profile opened from the conversation replaces it ([Page structure](https://raft-design.botiverse.build/patterns/page-structure.md#sidebar-and-side-panel)).
- Its width is in [Spacing](https://raft-design.botiverse.build/foundations/spacing.md#layout-widths). When it sits beside the conversation and when it covers it is the product's rule ([Raft Chat](https://raft-design.botiverse.build/products/raft-chat.md)).
- On a phone the thread is a pushed page: the same panel covers the conversation, with Back instead of the ×, and the tab bar stays hidden, as under the conversation ([Responsive layout](https://raft-design.botiverse.build/patterns/responsive.md)). On desktop it covers the conversation the same way when there isn't room beside it.

## Content

- Title the panel "Thread".
- Say in the header where the thread is and how many replies it has: "#launch-week · 3 replies".

## Accessibility

- `ThreadPanelRoot` renders a `section`. Name it with `aria-label="Thread"`.
- `ThreadPanelTitle` renders an `h2`.
- Give every icon-only action an `aria-label`: "Search in Thread", "Close Thread".

## Rules

- Don't add tabs to a thread. It holds the original message, the replies and the composer, nothing else.
- Leave 20px above the original message. The hover toolbar rides on a message's top edge ([MessageItem](https://raft-design.botiverse.build/components/message-item.md)); the original message is the first item in the scroll area, so with less room the toolbar is cut off and looks pinned under the header.
- Separate the original message from the replies with a `line-hairline`, not a seam: both are content of the same panel.

## API

### ThreadPanelActions

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelBody

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelFooter

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelOriginalMessage

Also takes the attributes of its element.

### ThreadPanelReplyCount

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelReplySummary

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelRoot

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelSearch

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelSearchCount

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelSearchTrailing

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### ThreadPanelTitleMeta

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

## Changes

- 0.5.1 (Fixed): Removed the duplicate inset outline from Elegant dark `ThreadPanelContent` on large screens, leaving the header/content seam with a single visual owner.
