Sidekick

An AI chat sidebar component with conversation history, message bubbles, and integrated prompt input.

components/ui/sidekick.demo.tsx
"use client";

import {
  Conversation,
  ConversationContent,
} from "@ai-elements/conversation";
import {
  Message,
  MessageContent,
} from "@ai-elements/message";
import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar";
import { GlobeIcon } from "lucide-react";
import React from "react";
import {
  PromptInput,
  PromptInputActionAddAttachments,
  PromptInputActionMenu,
  PromptInputActionMenuContent,
  PromptInputActionMenuTrigger,
  PromptInputAttachment,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputButton,
  PromptInputFooter,
  PromptInputSpeechButton,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
} from "@/components/ui/prompt-input";
import {
  Sidekick,
  SidekickContent,
  SidekickFooter,
  SidekickHeader,
  SidekickInset,
  SidekickProvider,
  SidekickTrigger,
} from "@/components/ui/sidekick";

export function SidekickDemo() {
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);

  // Example: Custom implementation
  const [messages, setMessages] = React.useState([
    { id: "1", from: "user", content: "Hello, how can you help me?" },
    {
      id: "2",
      from: "assistant",
      content:
        "I am your AI Sidekick. I can help you with coding, writing, and more.",
    },
  ]);

  const handleCustomSubmit = (message: { text: string }) => {
    // Add user message
    setMessages((prev) => [
      ...prev,
      {
        id: Date.now().toString(),
        from: "user",
        content: message.text,
      },
    ]);

    // Simulate assistant response (replace with your own logic)
    setTimeout(() => {
      setMessages((prev) => [
        ...prev,
        {
          id: (Date.now() + 1).toString(),
          from: "assistant",
          content: `You said: ${message.text}`,
        },
      ]);
    }, 1000);
  };

  return (
    <div className="flex h-full w-full overflow-hidden rounded-xl border bg-background shadow-lg">
      <SidekickProvider
        className="min-h-0"
        defaultOpen={true}
        style={
          {
            "--sidekick-width": "320px",
          } as React.CSSProperties
        }
      >
        <SidekickInset>
          <div className="flex h-full flex-col items-center justify-center bg-muted/20 p-6 text-center">
            <SidekickTrigger className="mb-4 md:hidden" />
            <div className="max-w-xs space-y-2">
              <h3 className="font-semibold text-lg">Main Content</h3>
              <p className="text-muted-foreground text-sm">
                Use <kbd className="font-mono text-xs">⌘I</kbd> to toggle panel.
              </p>
            </div>
          </div>
        </SidekickInset>

        <Sidekick>
          <SidekickHeader>
            <span className="font-semibold">AI Assistant</span>
            <div className="ml-auto">
              <SidekickTrigger />
            </div>
          </SidekickHeader>
          <SidekickContent>
            <Conversation>
              <ConversationContent>
                {messages.map((msg) => (
                  <Message
                    className={
                      msg.from === "user" ? "flex-row-reverse" : "flex-row"
                    }
                    from={msg.from as "user" | "assistant"}
                    key={msg.id}
                  >
                    <Avatar className="h-6 w-6 rounded-full">
                      <AvatarImage
                        alt={msg.from === "user" ? "@montekkundan" : "shadcn"}
                        src={
                          msg.from === "user"
                            ? "https://github.com/montekkundan.png"
                            : "https://github.com/shadcn.png"
                        }
                      />
                      <AvatarFallback>
                        {msg.from === "user" ? "MK" : "CN"}
                      </AvatarFallback>
                    </Avatar>
                    <MessageContent>{msg.content}</MessageContent>
                  </Message>
                ))}
              </ConversationContent>
            </Conversation>
          </SidekickContent>
          <SidekickFooter>
            <PromptInput className="w-full" onSubmit={handleCustomSubmit}>
              <PromptInputAttachments>
                {(attachment) => <PromptInputAttachment data={attachment} />}
              </PromptInputAttachments>
              <PromptInputBody>
                <PromptInputTextarea
                  placeholder="Ask me anything..."
                  ref={textareaRef}
                />
              </PromptInputBody>
              <PromptInputFooter>
                <PromptInputTools>
                  <PromptInputActionMenu>
                    <PromptInputActionMenuTrigger />
                    <PromptInputActionMenuContent>
                      <PromptInputActionAddAttachments />
                    </PromptInputActionMenuContent>
                  </PromptInputActionMenu>
                  <PromptInputSpeechButton textareaRef={textareaRef} />
                  <PromptInputButton>
                    <GlobeIcon size={16} />
                    <span>Search</span>
                  </PromptInputButton>
                </PromptInputTools>
                <PromptInputSubmit />
              </PromptInputFooter>
            </PromptInput>
          </SidekickFooter>
        </Sidekick>
      </SidekickProvider>
    </div>
  );
}

Overview

Sidekick is a composable chat layout system. It ships as a sidebar with optional toggle behavior, a conversation container with auto-scroll, and a set of message primitives you can style or extend.

Use it standalone, or wrap it in SidekickProvider to enable keyboard shortcuts (⌘I / Ctrl+I), responsive mobile behavior, and persisted open/closed state.

Sidekick composes with AI SDK Elements for the chat primitives:

Installation

bunx --bun shadcn@latest add https://sidekick.montek.dev/r/sidekick.json

Usage

import {
  Sidekick,
  SidekickContent,
  SidekickFooter,
  SidekickHeader,
  SidekickInset,
  SidekickProvider,
  SidekickTrigger,
} from "@/components/ui/sidekick";
import {
  Conversation,
  ConversationContent,
  ConversationEmptyState,
  ConversationScrollButton,
} from "@ai-elements/conversation";
import {
  Message,
  MessageActions,
  MessageAvatar,
  MessageContent,
  MessageTimestamp,
} from "@ai-elements/message";

Alternatively, use the compound component pattern:

import { Sidekick } from "@/components/ui/sidekick";
// Access subcomponents via Sidekick.Header, Sidekick.Content, etc.

Provider + Toggleable Panel

Use SidekickProvider to enable toggling, keyboard shortcuts, and responsive layouts.

<SidekickProvider defaultOpen>
  <SidekickInset>
    {/* Main content */}
    <SidekickTrigger />
  </SidekickInset>
 
  <Sidekick>
    <SidekickHeader className="justify-between">
      <span className="font-semibold">AI Assistant</span>
      <SidekickTrigger />
    </SidekickHeader>
 
    <SidekickContent>
      <Conversation>
        <ConversationContent>
          <ConversationEmptyState />
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>
    </SidekickContent>
 
    <SidekickFooter>{/* Add PromptInput here */}</SidekickFooter>
  </Sidekick>
</SidekickProvider>

Standalone Mode

If you render Sidekick without a provider, it behaves as a fixed panel (no toggle trigger).

<Sidekick>
  <SidekickHeader>
    <span className="font-semibold">AI Assistant</span>
  </SidekickHeader>
 
  <SidekickContent>
    <Conversation>
      <ConversationContent>
        <Message from="user">
          <MessageContent from="user">Hello!</MessageContent>
        </Message>
        <Message from="assistant">
          <MessageContent from="assistant">Hi there!</MessageContent>
        </Message>
      </ConversationContent>
    </Conversation>
  </SidekickContent>
 
  <SidekickFooter>{/* Add PromptInput here */}</SidekickFooter>
</Sidekick>

If you want to force standalone mode even when a provider is present, pass standalone.

Mobile Behavior

Sidekick supports three mobile behaviors:

  • sheet (default): full-screen sheet on mobile
  • inline: collapsible section within the layout
  • floating: off-canvas panel that slides in
<SidekickProvider defaultOpenMobile>
  <Sidekick mobileBehavior="floating">...</Sidekick>
</SidekickProvider>

Composition Patterns

Prompt Input Toolbars

Compose any PromptInput layout inside SidekickFooter—stack headers, chips, and inline buttons.

<SidekickFooter>
  <PromptInput variant="outline" size="lg" onSubmit={handleSubmit}>
    <PromptInput.Header>
      <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
        <span className="whitespace-nowrap">Tone: Confident</span>
        <span className="whitespace-nowrap">Audience: Execs</span>
      </div>
    </PromptInput.Header>
    <PromptInput.Body>
      <PromptInput.Textarea minRows={4} placeholder="Draft the launch recap..." />
      <PromptInput.Button>
        <MicIcon className="size-4" />
      </PromptInput.Button>
    </PromptInput.Body>
    <PromptInput.Footer className="flex-col gap-3">
      <PromptInput.Tools className="gap-2">
        <PromptInput.Button className="gap-1 px-3">
          <SparklesIcon className="size-4" />
          <span>Rewrite</span>
        </PromptInput.Button>
        <PromptInput.Button className="gap-1 px-3">
          <GlobeIcon className="size-4" />
          <span>Search Docs</span>
        </PromptInput.Button>
      </PromptInput.Tools>
      <PromptInput.Submit status={status} />
    </PromptInput.Footer>
  </PromptInput>
</SidekickFooter>

Add PromptInputProvider around Sidekick when presets, external buttons, or docking menus need the same input state.

Persisting Conversations

Conversation renders whatever list you feed it. Store messages with Drizzle/Prisma and hydrate them when the sidebar mounts.

const messages = await db.query.messages.findMany({
  where: eq(messages.threadId, params.threadId),
  orderBy: asc(messages.createdAt),
})
 
return (
  <Conversation>
    <ConversationContent>
      {messages.map((msg) => (
        <Message className="group" from={msg.from} key={msg.id}>
          <MessageAvatar />
          <div className="space-y-1">
            <MessageContent from={msg.from}>{msg.body}</MessageContent>
            <div className="flex items-center gap-2">
              <MessageTimestamp>{formatTime(msg.createdAt)}</MessageTimestamp>
              <MessageActions>
                {/* Copy / react / pin */}
              </MessageActions>
            </div>
          </div>
        </Message>
      ))}
    </ConversationContent>
  </Conversation>
)

Features

  • Collapsible Panel: Toggle the sidebar with keyboard shortcut (⌘I / Ctrl+I)
  • Mobile Modes: sheet, inline, or floating
  • Conversation History: Auto-scrolling message list with scroll-to-bottom button
  • Message Bubbles: Styled user/assistant bubbles with optional avatars
  • Empty State: Customizable empty state when no messages exist
  • Keyboard Navigation: Full keyboard accessibility

Components

SidekickProvider

Wraps your app and provides the sidebar state context.

PropTypeDefaultDescription
defaultOpenbooleantrueInitial open state
defaultOpenMobilebooleanfalseInitial mobile open state
openboolean-Controlled open state
onOpenChange(open: boolean) => void-Callback when open state changes

Sidekick

The sidebar panel component.

PropTypeDefaultDescription
side"left" | "right""right"Which side the panel appears on
mobileBehavior"sheet" | "inline" | "floating""sheet"Mobile rendering mode
standalonebooleanfalseWhen true, renders as a fixed panel without toggle behavior

SidekickTrigger

A button to toggle the sidebar open/closed. Returns null when no provider is present.

SidekickHeader

The header section of the sidebar.

SidekickContent

The main content area of the sidebar (typically contains Conversation).

SidekickFooter

The footer section (typically contains PromptInput).

SidekickInset

The main content area that sits beside the sidebar.

SidekickSeparator

A styled separator for toolbars and lists.

SidekickInput

A compact input field for filters or search inside the sidebar.

Conversation

Container for the message list with auto-scroll functionality.

ConversationContent

The scrollable content area inside Conversation.

ConversationScrollButton

A floating scroll-to-bottom button that appears when the user is not at the end.

ConversationEmptyState

Displayed when there are no messages.

PropTypeDefaultDescription
titlestring"No messages yet"Title text
descriptionstring-Description text
iconReactNode-Icon to display

Message

Container for a single message.

PropTypeDefaultDescription
from"user" | "assistant""assistant"Who sent the message

MessageContent

The message bubble with text content.

PropTypeDefaultDescription
from"user" | "assistant""assistant"Styles the bubble based on sender

MessageAvatar

Container for the sender's avatar.

MessageTimestamp

Text-only timestamp styling.

MessageActions

An actions row that fades in on hover. Add className="group" to the parent Message to enable hover behavior.

Hooks

useSidekick

Access the sidebar state and controls.

const { state, open, setOpen, toggleSidekick } = useSidekick();

useConversation

Access conversation scroll controls.

const { scrollToBottom, isAtBottom } = useConversation();

Theming

Sidekick supports theming through CSS classes. Apply a theme by adding the theme class to your provider:

Custom Themes

Create your own theme by targeting the data attributes:

.my-theme [data-slot="sidekick"] {
  background-color: #1a1a1a;
}
 
.my-theme [data-slot="message-content"][data-from="user"] {
  background-color: #0078d4;
}
 
.my-theme [data-slot="message-content"][data-from="assistant"] {
  background-color: #2d2d2d;
}