Skip to content

AI chat

The layout shell of an AI chat app: a sidebar that folds to an icon rail, the chat as an inset card, and a side panel that docks beside it for sources or the thinking trace.

Uses
motion

Code

import {
  createContext,
  useCallback,
  useContext,
  useMemo,
  useState,
  type ComponentProps,
} from "react";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@manikrana/ui/components/ui/button";
import { cn } from "@manikrana/ui/lib/utils";

const PANEL_WIDTH = 320; // matches the w-80 classes below
const PANEL_GAP = 8;
const SLIDE = { duration: 0.2, ease: [0, 0, 0.2, 1] } as const;
const SWAP = { duration: 0.16, ease: [0, 0, 0.2, 1] } as const;

// The inset edge shadow sits on an overlay; on the card itself, its children would cover it.
const INSET_CARD =
  "relative overflow-hidden rounded-2xl bg-stage text-foreground after:pointer-events-none after:absolute after:inset-0 after:z-30 after:rounded-[inherit] after:shadow-stage";

function useControllable<T>(value: T | undefined, initial: T, onChange?: (next: T) => void) {
  const [local, setLocal] = useState(initial);
  const controlled = value !== undefined;
  const set = useCallback(
    (next: T) => {
      if (!controlled) setLocal(next);
      onChange?.(next);
    },
    [controlled, onChange],
  );
  return [controlled ? value : local, set] as const;
}

type AiChatContextValue = {
  sidebarOpen: boolean;
  setSidebarOpen: (open: boolean) => void;
  panel: string | null;
  setPanel: (panel: string | null) => void;
};

const AiChatContext = createContext<AiChatContextValue | null>(null);

export function useAiChat() {
  const context = useContext(AiChatContext);
  if (!context) throw new Error("useAiChat must be used inside <AiChat>");
  return context;
}

export type AiChatProps = ComponentProps<"div"> & {
  sidebarOpen?: boolean;
  defaultSidebarOpen?: boolean;
  onSidebarOpenChange?: (open: boolean) => void;
  panel?: string | null;
  defaultPanel?: string | null;
  onPanelChange?: (panel: string | null) => void;
};

export function AiChat({
  sidebarOpen: sidebarOpenProp,
  defaultSidebarOpen = true,
  onSidebarOpenChange,
  panel: panelProp,
  defaultPanel = null,
  onPanelChange,
  className,
  ...props
}: AiChatProps) {
  const [sidebarOpen, setSidebarOpen] = useControllable(
    sidebarOpenProp,
    defaultSidebarOpen,
    onSidebarOpenChange,
  );
  const [panel, setPanel] = useControllable<string | null>(panelProp, defaultPanel, onPanelChange);
  const context = useMemo(
    () => ({ sidebarOpen, setSidebarOpen, panel, setPanel }),
    [sidebarOpen, setSidebarOpen, panel, setPanel],
  );

  return (
    <AiChatContext value={context}>
      <div
        data-slot="ai-chat"
        className={cn("flex size-full min-h-0 overflow-hidden bg-card text-foreground", className)}
        {...props}
      />
    </AiChatContext>
  );
}

export function AiChatSidebar({ className, ...props }: ComponentProps<"div">) {
  const { sidebarOpen } = useAiChat();

  return (
    <div
      data-slot="ai-chat-sidebar"
      data-state={sidebarOpen ? "open" : "collapsed"}
      className={cn(
        "group/sidebar h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out motion-reduce:transition-none",
        sidebarOpen ? "w-56" : "w-12",
        className,
      )}
      {...props}
    />
  );
}

// Pass its open max-width in `className`, such as `max-w-32`, so the width can animate.
export function AiChatSidebarLabel({ className, ...props }: ComponentProps<"span">) {
  return (
    <span
      data-slot="ai-chat-sidebar-label"
      className={cn(
        "min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,opacity] duration-150 ease-out group-data-[state=collapsed]/sidebar:max-w-0 group-data-[state=collapsed]/sidebar:opacity-0 motion-reduce:transition-none",
        className,
      )}
      {...props}
    />
  );
}

export function AiChatSidebarTrigger({ onClick, ...props }: ComponentProps<typeof Button>) {
  const { sidebarOpen, setSidebarOpen } = useAiChat();

  return (
    <Button
      data-slot="ai-chat-sidebar-trigger"
      size="icon"
      variant="ghost"
      aria-label="Toggle sidebar"
      onClick={(event) => {
        onClick?.(event);
        setSidebarOpen(!sidebarOpen);
      }}
      {...props}
    />
  );
}

export function AiChatBody({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-chat-body"
      className={cn("flex min-w-0 flex-1 py-3 pr-3", className)}
      {...props}
    />
  );
}

export function AiChatContent({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-chat-content"
      className={cn(INSET_CARD, "flex min-h-0 min-w-0 flex-1 flex-col", className)}
      {...props}
    />
  );
}

export function AiChatHeader({ className, ...props }: ComponentProps<"header">) {
  return (
    <header
      data-slot="ai-chat-header"
      className={cn("absolute inset-x-0 top-0 z-10 flex h-12 items-center px-2", className)}
      {...props}
    />
  );
}

export function AiChatViewport({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-chat-viewport"
      className={cn("min-h-0 flex-1 overflow-hidden", className)}
      {...props}
    />
  );
}

export function AiChatFooter({ className, ...props }: ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-chat-footer"
      className={cn(
        "absolute inset-x-0 bottom-0 bg-linear-to-t from-stage via-stage to-transparent",
        className,
      )}
      {...props}
    />
  );
}

// Choose the children from the panel id in the parent, so a leaving card keeps its content.
export function AiChatPanel({ className, children, ...props }: ComponentProps<"div">) {
  const { panel } = useAiChat();

  return (
    <AnimatePresence initial={false}>
      {panel ? (
        <motion.aside
          key="slot"
          initial={{ width: 0, marginLeft: 0, opacity: 0 }}
          animate={{ width: PANEL_WIDTH, marginLeft: PANEL_GAP, opacity: 1 }}
          exit={{ width: 0, marginLeft: 0, opacity: 0 }}
          transition={SLIDE}
          className="h-full shrink-0 overflow-hidden"
        >
          <div className="relative h-full w-80 overflow-hidden">
            <AnimatePresence initial={false}>
              <motion.div
                key={panel}
                initial={{ x: PANEL_WIDTH }}
                animate={{ x: 0 }}
                exit={{ x: -PANEL_WIDTH }}
                transition={SWAP}
                className="absolute inset-0"
              >
                <div
                  data-slot="ai-chat-panel"
                  className={cn(INSET_CARD, "flex h-full w-80 flex-col", className)}
                  {...props}
                >
                  {children}
                </div>
              </motion.div>
            </AnimatePresence>
          </div>
        </motion.aside>
      ) : null}
    </AnimatePresence>
  );
}

export function AiChatPanelClose({ onClick, ...props }: ComponentProps<typeof Button>) {
  const { setPanel } = useAiChat();

  return (
    <Button
      data-slot="ai-chat-panel-close"
      variant="ghost"
      size="icon-sm"
      aria-label="Close panel"
      onClick={(event) => {
        onClick?.(event);
        setPanel(null);
      }}
      {...props}
    />
  );
}