# Raft Chat — Channel

> A channel — its top bar, the conversation of people and agents, tasks inside the conversation, and threads.

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

A channel is a pushed page where people and agents share one timeline, with the composer at the bottom. Its chat, tasks and files are views of the same place; how people switch between them is on [Channel views](https://raft-design.botiverse.build/products/raft-chat/channel-views.md).

## Structure

From top to bottom on a phone:

1. **The top bar**: Back, the location tile, the channel name, and a subtitle with who is working here first (the avatar stack and "2 agents working"), then the member count. The overflow button opens the channel details: a drawer on the right on desktop, a whole page on a phone.
2. **Chat / Tasks / Files**, by default as sticker tabs ([Channel views](https://raft-design.botiverse.build/products/raft-chat/channel-views.md)).
3. **The timeline**, newest at the bottom: a date line, messages (the one that became a task carries its task marker and reactions in the row under it), claim and hand-off event lines, and the working agent's status line with a still yellow light.
4. **The composer**: tools, As Task and the pink send button ([Composer](https://raft-design.botiverse.build/components/composer.md)). Its edge is at 16px, like the messages, and its first tool icon lines up with the typed text.

```tsx
import { useCallback, useRef, useState } from "react";
import type { FormEvent } from "react";
import {
  ArrowDown,
  Bookmark,
  ImagePlus,
  ListTodo,
  MessageSquare,
  Paperclip,
  Send,
  SmilePlus,
} from "lucide-react";
import {
  Avatar,
  Banner,
  BannerDescription,
  Button,
  Checkbox,
  ConversationPanelBody,
  ConversationPanelContent,
  ConversationPanelFooter,
  ConversationPanelRoot,
  ConversationPanelTabs,
  SortableTabsList,
  SortableTabsTab,
  Tabs,
  TabsLabel,
  ComposerActions,
  ComposerIconButton,
  ComposerMeta,
  ComposerRoot,
  ComposerSubmit,
  ComposerToolbarLabel,
  MessageItem,
  MessageItemAvatarSlot,
  MessageItemBadge,
  MessageItemBadgeSeparator,
  MessageItemBody,
  MessageItemContent,
  MessageItemContinuationTime,
  MessageItemFooter,
  MessageItemGutter,
  MessageItemHeader,
  MessageItemSender,
  MessageItemTime,
  MessageItemToolbar,
  MessageItemToolbarButton,
} from "@botiverse/raft-ui";
import type { PanelEdge } from "@botiverse/raft-ui";
import { MessageList, MessageListDateDivider, MessageListNotice } from "@botiverse/raft-ui";
import { Composer, ComposerInput, ComposerToolbar } from "@botiverse/raft-ui";
import { ConversationPanelHeaderComposition } from "./conversation-panel-header.composition.tsx";

export type ConversationPanelCompositionProps = {
  conversation?: "channel" | "dm";
  edge?: PanelEdge;
  muted?: boolean;
  notificationError?: string | null;
  newMessageCount?: number;
};

const tabOrder = ["chat", "tasks", "files"];

function handleTabReorder() {}

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

export function ConversationPanelComposition({
  conversation = "channel",
  edge = "inset",
  muted: mutedProp = false,
  notificationError = null,
  newMessageCount = 0,
}: ConversationPanelCompositionProps = {}) {
  const timelineRef = useRef<HTMLDivElement>(null);
  const [muted, setMuted] = useState(mutedProp);
  const [lastMutedProp, setLastMutedProp] = useState(mutedProp);
  // Sync the local mute demo state when the controlled prop (playground control) changes.
  if (mutedProp !== lastMutedProp) {
    setLastMutedProp(mutedProp);
    setMuted(mutedProp);
  }

  const handleJump = useCallback(() => {
    const el = timelineRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
  }, []);

  return (
    <ConversationPanelRoot edge={edge}>
      <ConversationPanelHeaderComposition
        conversation={conversation}
        muted={muted}
        onMutedChange={setMuted}
      />
      {notificationError ? (
        <Banner
          status="warning"
          size="sm"
          className="shrink-0 rounded-none border-x-0 border-t-0 px-5 py-2"
        >
          <BannerDescription>{notificationError}</BannerDescription>
        </Banner>
      ) : null}
      <ConversationPanelContent>
        <ConversationPanelTabs render={<Tabs value="chat" />}>
          <SortableTabsList value={tabOrder} onReorder={handleTabReorder} variant="underline">
            <SortableTabsTab value="chat">
              <MessageSquare />
              <TabsLabel>Chat</TabsLabel>
            </SortableTabsTab>
            <SortableTabsTab value="tasks">
              <ListTodo />
              <TabsLabel>Tasks</TabsLabel>
            </SortableTabsTab>
            <SortableTabsTab value="files">
              <Paperclip />
              <TabsLabel>Files</TabsLabel>
            </SortableTabsTab>
          </SortableTabsList>
        </ConversationPanelTabs>
        <ConversationPanelBody>
          <MessageList ref={timelineRef}>
            <div className="px-3 pt-3">
              <MessageListNotice>Beginning of messages</MessageListNotice>
            </div>
            <div className="h-px" aria-hidden />
            <div className="flex-1" aria-hidden />
            <MessageListDateDivider>Today</MessageListDateDivider>
            <div data-index="0" data-message-id="preview-cindy">
              <div>
                <MessageItem>
                  <MessageItemAvatarSlot>
                    <Avatar size="md" type="human" />
                  </MessageItemAvatarSlot>
                  <MessageItemContent>
                    <MessageItemHeader>
                      <MessageItemSender>Cindy</MessageItemSender>
                      <MessageItemTime>10:42</MessageItemTime>
                    </MessageItemHeader>
                    <MessageItemBody>
                      Rail and sidebar own navigation. The conversation panel owns the active
                      conversation chrome.
                    </MessageItemBody>
                    <MessageItemFooter>
                      <MessageItemBadge status="unread">
                        2 replies
                        <MessageItemBadgeSeparator>·</MessageItemBadgeSeparator>1 new
                      </MessageItemBadge>
                    </MessageItemFooter>
                  </MessageItemContent>
                  <MessageItemToolbar>
                    <MessageItemToolbarButton aria-label="Reply in thread">
                      <MessageSquare />
                    </MessageItemToolbarButton>
                    <MessageItemToolbarButton aria-label="Add reaction">
                      <SmilePlus />
                    </MessageItemToolbarButton>
                    <MessageItemToolbarButton aria-label="Save message">
                      <Bookmark />
                    </MessageItemToolbarButton>
                  </MessageItemToolbar>
                </MessageItem>
              </div>
            </div>
            <div data-index="1" data-message-id="preview-atlas">
              <div>
                <MessageItem>
                  <MessageItemAvatarSlot>
                    <Avatar size="md" type="agent" />
                  </MessageItemAvatarSlot>
                  <MessageItemContent>
                    <MessageItemHeader>
                      <MessageItemSender>Atlas</MessageItemSender>
                      <MessageItemTime>10:44</MessageItemTime>
                    </MessageItemHeader>
                    <MessageItemBody>
                      Message rows, composer behavior, route sync, and realtime data stay app-owned.
                    </MessageItemBody>
                  </MessageItemContent>
                  <MessageItemToolbar>
                    <MessageItemToolbarButton aria-label="Reply in thread">
                      <MessageSquare />
                    </MessageItemToolbarButton>
                    <MessageItemToolbarButton aria-label="Add reaction">
                      <SmilePlus />
                    </MessageItemToolbarButton>
                    <MessageItemToolbarButton aria-label="Save message">
                      <Bookmark />
                    </MessageItemToolbarButton>
                  </MessageItemToolbar>
                </MessageItem>
              </div>
            </div>
            <div data-index="2" data-message-id="preview-atlas-cont">
              <div>
                <MessageItem compact>
                  <MessageItemGutter>
                    <MessageItemContinuationTime>10:45</MessageItemContinuationTime>
                  </MessageItemGutter>
                  <MessageItemContent>
                    <MessageItemBody>
                      Continuation lines stack under the sender with no repeated avatar or header.
                    </MessageItemBody>
                  </MessageItemContent>
                </MessageItem>
              </div>
            </div>
            <div className="h-px" aria-hidden />
            <div className="px-3 pb-3">
              <div className="h-3" />
            </div>
          </MessageList>
          {newMessageCount > 0 ? (
            <Button
              type="button"
              variant="outline"
              size="sm"
              className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2"
              onClick={handleJump}
            >
              <ArrowDown />
              {newMessageCount} new messages
            </Button>
          ) : null}
        </ConversationPanelBody>
        <ConversationPanelFooter>
          <ComposerRoot onSubmit={handleComposerSubmit}>
            <Composer>
              <ComposerInput
                aria-label="Message #proj-uiux"
                placeholder="Message #proj-uiux"
                readOnly
              />
              <ComposerToolbar>
                <ComposerActions>
                  <ComposerIconButton aria-label="Attach image" disabled>
                    <ImagePlus />
                  </ComposerIconButton>
                  <ComposerIconButton aria-label="Attach file" disabled>
                    <Paperclip />
                  </ComposerIconButton>
                </ComposerActions>
                <ComposerMeta>
                  <ComposerToolbarLabel>
                    <Checkbox aria-label="Send as task" />
                    As Task
                  </ComposerToolbarLabel>
                  <ComposerSubmit aria-label="Send" disabled>
                    <span>Send</span>
                    <Send />
                  </ComposerSubmit>
                </ComposerMeta>
              </ComposerToolbar>
            </Composer>
          </ComposerRoot>
        </ConversationPanelFooter>
      </ConversationPanelContent>
    </ConversationPanelRoot>
  );
}
```

## Rules

- Open every channel's timeline with a date line.
- Show the channel's tile as the yellow location tile (27px) in its top bar and panel header, and as a neutral 35px [IconTile](https://raft-design.botiverse.build/components/icon-tile.md) in lists. A private channel uses `lock` instead of `hash`; the tile is otherwise the same.
- Title pushed pages by what they show: a channel with its location tile, a direct message with the other person's avatar, a thread with no icon and the channel as its subtitle.
- Make the send button the screen's only pink button.
- Keep the composer the only ink-bordered box on the screen.
- Write agent actions (claim, hand off, done) as event lines, not messages.
- Keep every yellow on the screen about "you": the location tile, the selected Chat tab, mentions of the person. On the yellow top bar version, the top bar is the frame. The busy presence light belongs to the fixed presence colors, and an avatar's background is part of its drawing.
- Open the same menu on press and hold as on desktop right-click, with the quick reactions first ([Conversation](https://raft-design.botiverse.build/patterns/conversation.md#actions)).

### The composer in a channel

- On desktop, show a row of @ delegation chips above the input: the 2 to 3 agents that have worked in this channel most recently. Each chip is a button that puts the agent's mention into the input; it sits on `fill-muted` with no ink border.
- Group the @ suggestions into agents in this channel, then agents not in it.
- As Task is a checkbox in the composer toolbar: when it is checked, the message sent is a task.

### Tasks in the conversation

- The message that was posted is the task. It carries a task marker in its footer: the status pixel, `#25` and who claimed it ([TaskChip](https://raft-design.botiverse.build/components/tasks.md)).
- Selecting the marker opens the task. Status isn't changed from the marker.
- Create a task with As Task in the composer (⌘ or Ctrl + Shift + Enter), Convert to Task in the message menu, or the ink-bordered Create Task at the top right of the channel's task view.
- Let people mark a task done straight from the message menu.
- The Tasks destination has no create action, because a task always belongs to a channel ([Tasks](https://raft-design.botiverse.build/products/raft-chat/tasks.md)).

### Threads

- On a phone, open a thread as a pushed page titled Thread, with the channel as its subtitle.
- On desktop, open it in the side panel; when it sits beside the conversation is in [Raft Chat](https://raft-design.botiverse.build/products/raft-chat.md#wide-and-narrow).
- Share the side panel between the thread, tasks, files and profiles, one at a time.
- Show only the conversation and the composer in a thread: no tabs, no Join.
- A task's thread is inside the task details.

```tsx
import {
  Bookmark,
  ChevronDown,
  ChevronLeft,
  ChevronUp,
  ExternalLink,
  ImagePlus,
  MessageSquare,
  Paperclip,
  Search,
  Send,
  SmilePlus,
  X,
} from "lucide-react";
import type { FormEvent } from "react";
import {
  Avatar,
  Button,
  Checkbox,
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  PanelAction,
  ThreadPanelActions,
  ThreadPanelBody,
  ThreadPanelContent,
  ThreadPanelFooter,
  ThreadPanelHeader,
  ThreadPanelOriginalMessage,
  ThreadPanelReplyCount,
  ThreadPanelReplySummary,
  ThreadPanelRoot,
  ThreadPanelSearch,
  ThreadPanelSearchCount,
  ThreadPanelSearchTrailing,
  ThreadPanelTitle,
  ThreadPanelTitleMeta,
  ComposerActions,
  ComposerIconButton,
  ComposerMeta,
  ComposerRoot,
  ComposerSubmit,
  ComposerToolbarLabel,
  MessageItem,
  MessageItemAvatarSlot,
  MessageItemBody,
  MessageItemContent,
  MessageItemHeader,
  MessageItemSender,
  MessageItemTime,
  MessageItemToolbar,
  MessageItemToolbarButton,
} from "@botiverse/raft-ui";
import { MessageList, MessageListDateDivider, 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="Reply in thread" disabled>
        <MessageSquare />
      </MessageItemToolbarButton>
      <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="chrome"
        />
      </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: "Cindy",
    type: "human" as const,
    time: "10:09",
    body: "SideThreadColumn owns persisted width and desktop resize chrome.",
  },
  {
    sender: "Atlas",
    type: "agent" as const,
    time: "10:11",
    body: "ThreadPanel now owns the right-panel header, body, action, and footer chrome.",
  },
  {
    sender: "Noah",
    type: "human" as const,
    time: "10:14",
    body: "Keep the original message pinned above the scroll body so context stays visible.",
  },
  {
    sender: "Atlas",
    type: "agent" as const,
    time: "10:16",
    body: "Agreed. Replies should scroll independently from the header search and composer footer.",
  },
  {
    sender: "Mia",
    type: "human" as const,
    time: "10:19",
    body: "Does the scrollbar still fade out once the reply stack gets long?",
  },
  {
    sender: "Cindy",
    type: "human" as const,
    time: "10:21",
    body: "Yes, it fades as soon as scrolling settles. Short threads never show it at all.",
  },
  {
    sender: "Atlas",
    type: "agent" as const,
    time: "10:23",
    body: "Desktop uses ScrollArea and mobile keeps the native PanelScrollViewport, so both behave the same.",
  },
  {
    sender: "Noah",
    type: "human" as const,
    time: "10:26",
    body: "One more thing: the last reply should never sit behind the composer.",
  },
  {
    sender: "Mia",
    type: "human" as const,
    time: "10:28",
    body: "Scrolling to the end should leave a comfortable gap above the footer.",
  },
  {
    sender: "Atlas",
    type: "agent" as const,
    time: "10:30",
    body: "That clearance comes from the message-list content recipe.",
  },
] 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="h-6" aria-hidden />
    </>
  );
}

export function ThreadPanelComposition({ search = true }: ThreadPanelCompositionProps = {}) {
  return (
    <ThreadPanelRoot>
      <ThreadPanelHeader>
        <Button
          className="mr-1 lg:hidden"
          variant="outline"
          size="icon-sm"
          aria-label="Back to channel"
          disabled
        >
          <ChevronLeft aria-hidden />
        </Button>
        <ThreadPanelTitle>
          Thread
          <ThreadPanelTitleMeta> — #acme-design</ThreadPanelTitleMeta>
        </ThreadPanelTitle>
        <ThreadPanelActions>
          <PanelAction aria-label="Search in thread" disabled>
            <Search />
          </PanelAction>
          <PanelAction aria-label="View in channel" disabled>
            <ExternalLink />
            <span className="hidden whitespace-nowrap font-medium leading-none md:inline">
              View in channel
            </span>
          </PanelAction>
          <PanelAction className="max-lg:hidden" aria-label="Close thread" disabled>
            <X />
          </PanelAction>
        </ThreadPanelActions>
      </ThreadPanelHeader>
      <ThreadPanelContent>
        {search ? <ThreadSearchRow /> : null}
        <ThreadPanelOriginalMessage>
          <MessageItem>
            <MessageItemAvatarSlot>
              <Avatar size="md" type="human" />
            </MessageItemAvatarSlot>
            <MessageItemContent>
              <MessageItemHeader>
                <MessageItemSender>Cindy</MessageItemSender>
                <MessageItemTime>10:08</MessageItemTime>
              </MessageItemHeader>
              <MessageItemBody>Should ThreadPanel own the right-panel chrome?</MessageItemBody>
            </MessageItemContent>
            <MessageRowToolbar />
          </MessageItem>
        </ThreadPanelOriginalMessage>
        <ThreadPanelBody>
          <MessageList>
            <ThreadReplies />
          </MessageList>
        </ThreadPanelBody>
        <ThreadPanelFooter>
          <ComposerRoot onSubmit={preventSubmit}>
            <Composer>
              <ComposerInput aria-label="Message thread" placeholder="Message thread" readOnly />
              <ComposerToolbar>
                <ComposerActions>
                  <ComposerIconButton aria-label="Attach image" disabled>
                    <ImagePlus />
                  </ComposerIconButton>
                  <ComposerIconButton aria-label="Attach file" disabled>
                    <Paperclip />
                  </ComposerIconButton>
                </ComposerActions>
                <ComposerMeta>
                  <ComposerToolbarLabel>
                    <Checkbox aria-label="Send as task" disabled />
                    As Task
                  </ComposerToolbarLabel>
                  <ComposerSubmit aria-label="Send" disabled>
                    <span>Send</span>
                    <Send />
                  </ComposerSubmit>
                </ComposerMeta>
              </ComposerToolbar>
            </Composer>
          </ComposerRoot>
        </ThreadPanelFooter>
      </ThreadPanelContent>
    </ThreadPanelRoot>
  );
}
```

## Differences from the shared patterns

- Whether the top bar of this pushed page is white or yellow is [still open](https://raft-design.botiverse.build/decisions/open-questions.md). On the white version, the location tile is a yellow sticker; on the yellow version it is a white sticker in light mode and yellow in dark mode.
