AI Chat

Complete responsive AI chat template.

Installation

npx shadcn@latest add https://boardcn.dev/r/ai-chat.json
npx shadcn@latest add https://boardcn.dev/r/ai-chat.json

npm packages

  • @remixicon/react
  • react-aria-components
  • shiki

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

What it composes

10 BoardCN components, installed automatically.

Source

The 6 files the CLI copies into your project.

components/templates/ai-chat/ai-chat.tsx
"use client";

import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
  RiCloseLine,
  RiCodeSSlashLine,
  RiDeleteBinLine,
  RiFileCopyLine,
  RiFolderLine,
  RiMenuLine,
  RiMoreFill,
  RiPencilLine,
  RiShareForwardLine,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { Dropdown, DropdownDivider, DropdownItem, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown";
import { Composer, StatusBar } from "@/components/blocks/composer/composer";
import { AgentSidebar } from "./agent-sidebar";
import { ChatThread, type ChatMessage } from "./chat-thread";
import { AiChatSidePanel, type CodePanelChangeSummary, type SidePanelView } from "./code-panel";
import { cx } from "@/utils/cx";

const DEFAULT_PANEL_WIDTH = 410;
const MIN_PANEL_WIDTH = 330;
const MAX_PANEL_WIDTH = 620;
const PANEL_KEYBOARD_STEP = 10;

function HeaderIcon({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
  return <button type="button" aria-label={label} onClick={onClick} className="group cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><span className="block size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover">{children}</span></button>;
}

export interface AiChatTemplateProps {
  className?: string;
  initiallyWorking?: boolean;
  /** Progress steps passed through to the chat thread while working. */
  steps: string[];
  messages: readonly ChatMessage[];
  codeSource: readonly string[];
  changeSummary: CodePanelChangeSummary;
  terminalLines: readonly string[];
  onShare?: () => void | Promise<void>;
  onOpenProject?: () => void;
  onRename?: () => void;
  onDuplicate?: () => void;
  onDelete?: () => void;
  onTerminalOpenChange?: (open: boolean) => void;
  onPanelExpandedChange?: (expanded: boolean) => void;
}

export function AiChatTemplate({
  className,
  initiallyWorking = false,
  steps,
  messages,
  codeSource,
  changeSummary,
  terminalLines,
  onShare,
  onOpenProject,
  onRename,
  onDuplicate,
  onDelete,
  onTerminalOpenChange,
  onPanelExpandedChange,
}: AiChatTemplateProps) {
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
  const [mobileNavigation, setMobileNavigation] = useState(false);
  const [mobileCode, setMobileCode] = useState(false);
  const [panelView, setPanelView] = useState<SidePanelView>("changes");
  const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
  const [working, setWorking] = useState(initiallyWorking);
  const [moreMenuOpen, setMoreMenuOpen] = useState(false);
  const [terminalOpen, setTerminalOpen] = useState(false);
  const [panelExpanded, setPanelExpanded] = useState(false);
  const dragStart = useRef<{ x: number; width: number } | null>(null);
  const navigationTriggerRef = useRef<HTMLButtonElement>(null);
  const codeTriggerRef = useRef<HTMLButtonElement>(null);
  const navigationWasOpen = useRef(false);
  const codeWasOpen = useRef(false);

  useEffect(() => {
    if (navigationWasOpen.current && !mobileNavigation) navigationTriggerRef.current?.focus();
    navigationWasOpen.current = mobileNavigation;
  }, [mobileNavigation]);

  useEffect(() => {
    if (codeWasOpen.current && !mobileCode) codeTriggerRef.current?.focus();
    codeWasOpen.current = mobileCode;
  }, [mobileCode]);

  const setBoundedPanelWidth = useCallback((width: number) => {
    setPanelWidth(Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width)));
  }, []);

  const beginResize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
    dragStart.current = { x: event.clientX, width: panelWidth };
    event.currentTarget.setPointerCapture(event.pointerId);
  }, [panelWidth]);
  const resize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
    if (!dragStart.current) return;
    setBoundedPanelWidth(dragStart.current.width + dragStart.current.x - event.clientX);
  }, [setBoundedPanelWidth]);
  const resizeWithKeyboard = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
    let nextWidth: number | undefined;

    switch (event.key) {
      case "ArrowLeft":
        nextWidth = panelWidth + (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
        break;
      case "ArrowRight":
        nextWidth = panelWidth - (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
        break;
      case "Home":
        nextWidth = MIN_PANEL_WIDTH;
        break;
      case "End":
        nextWidth = MAX_PANEL_WIDTH;
        break;
      default:
        return;
    }

    event.preventDefault();
    setBoundedPanelWidth(nextWidth);
  }, [panelWidth, setBoundedPanelWidth]);

  const submit = useCallback(async () => {
    setWorking(true);
    await new Promise((resolve) => window.setTimeout(resolve, 3200));
    setWorking(false);
  }, []);

  const shareChat = useCallback(async () => {
    if (onShare) {
      await onShare();
      return;
    }

    const shareData = { title: "Agentic chat", url: window.location.href };
    if (navigator.share) {
      await navigator.share(shareData);
    } else {
      await navigator.clipboard?.writeText(shareData.url);
    }
  }, [onShare]);

  const selectMoreAction = useCallback((action?: () => void) => {
    setMoreMenuOpen(false);
    action?.();
  }, []);

  const changeTerminalOpen = useCallback((open: boolean) => {
    setTerminalOpen(open);
    onTerminalOpenChange?.(open);
  }, [onTerminalOpenChange]);

  const changePanelExpanded = useCallback((expanded: boolean) => {
    setPanelExpanded(expanded);
    onPanelExpandedChange?.(expanded);
  }, [onPanelExpandedChange]);

  return (
    <div className={cx("relative flex h-dvh w-full gap-4 overflow-hidden bg-background-full p-3 text-text-primary", className)}>
      <ModalOverlay isOpen={mobileNavigation} onOpenChange={setMobileNavigation} isDismissable className="fixed inset-0 z-50 flex bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 lg:hidden">
        <Modal className="h-full w-[272px] py-3 pl-[6px] outline-none transition-transform duration-300 ease-in-out data-[entering]:-translate-x-[110%] data-[exiting]:-translate-x-[110%]">
          <Dialog aria-label="Navigation" className="h-full outline-none">
            <AgentSidebar className="w-[260px]" />
          </Dialog>
        </Modal>
      </ModalOverlay>

      <AgentSidebar collapsed={sidebarCollapsed} onCollapsedChange={setSidebarCollapsed} className="relative z-10 hidden lg:flex" />

      <div className="relative z-20 flex min-w-0 flex-1 flex-col overflow-hidden bg-background-full lg:z-0">
        <div className="flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden">
          <div className={cx("relative flex min-w-0 flex-1 basis-0", panelExpanded && "xl:hidden")}>
            <section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-3xl bg-background-secondary-default">
              <header className="flex h-12 shrink-0 items-center justify-between px-3 pt-[11px] xl:hidden">
                <div className="flex min-w-0 items-center gap-2"><Button ref={navigationTriggerRef} variant="secondary" iconOnly leadingIcon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavigation(true)} className="rounded-full lg:hidden" /><span className="truncate px-1 text-headline-medium text-text-primary">Agentic chat</span></div>
                <Button ref={codeTriggerRef} variant="secondary" iconOnly leadingIcon={RiCodeSSlashLine} aria-label="Open code" onClick={() => setMobileCode(true)} className="rounded-full" />
              </header>
              <header className="flex w-full items-center justify-between gap-2 px-4 pt-4">
                <nav aria-label="Chat location" className="flex min-w-0 flex-1 items-center overflow-x-auto [scrollbar-width:none]"><ol className="flex items-center gap-2.5 px-1"><li><button type="button" aria-label="Open project: vibl coding project" onClick={onOpenProject} className="-mx-1 flex items-center gap-1.5 rounded-md px-1 py-0.5 text-caption-1-medium whitespace-nowrap text-text-tertiary hover:bg-background-primary-hover hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><RiFolderLine className="size-4" aria-hidden />vibl coding project</button></li><li aria-hidden className="text-text-tertiary">›</li><li aria-current="page" className="text-caption-1-medium whitespace-nowrap text-text-secondary">coding scenario</li></ol></nav>
                <div className="flex shrink-0 items-center gap-2">
                  <HeaderIcon label="Share chat" onClick={() => { void shareChat(); }}><RiShareForwardLine className="size-4" /></HeaderIcon>
                  <Dropdown isOpen={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
                    <DropdownTrigger aria-label="More options" className="group rounded-sm"><RiMoreFill className="size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover" aria-hidden /></DropdownTrigger>
                    <DropdownPopover aria-label="Chat options" placement="bottom end" className="w-44 p-2">
                      <DropdownItem onSelect={() => selectMoreAction(onRename)}><RiPencilLine className="size-4" aria-hidden />Rename</DropdownItem>
                      <DropdownItem onSelect={() => selectMoreAction(onDuplicate)}><RiFileCopyLine className="size-4" aria-hidden />Duplicate</DropdownItem>
                      <DropdownDivider />
                      <DropdownItem onSelect={() => selectMoreAction(onDelete)} className="text-red-600"><RiDeleteBinLine className="size-4" aria-hidden />Delete</DropdownItem>
                    </DropdownPopover>
                  </Dropdown>
                </div>
              </header>
              <ChatThread working={working} steps={steps} messages={messages} />
              <div className="flex w-full flex-col gap-2.5 px-2.5 pt-3 pb-2.5">
                <Composer working={working} onSubmit={submit} className="[&_input[type=file]]:hidden" />
                <StatusBar />
              </div>
            </section>
            <div role="separator" aria-orientation="vertical" aria-label="Resize code panel" aria-valuemin={MIN_PANEL_WIDTH} aria-valuemax={MAX_PANEL_WIDTH} aria-valuenow={panelWidth} aria-valuetext={`${panelWidth} pixels`} tabIndex={0} onKeyDown={resizeWithKeyboard} onPointerDown={beginResize} onPointerMove={resize} onPointerUp={(event) => { dragStart.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragStart.current = null; }} className="group/drag absolute inset-y-0 -right-2.5 z-10 hidden w-5 cursor-col-resize touch-none justify-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring xl:flex">
              <span className="absolute top-1/2 flex h-[25px] w-[15px] -translate-y-1/2 items-center justify-center gap-0.5 rounded-sm border border-border-button-default bg-background-primary-default opacity-0 shadow-xs transition-opacity group-hover/drag:opacity-100 group-focus-visible/drag:opacity-100"><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /></span>
            </div>
          </div>
          <AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} className={cx("hidden xl:flex", panelExpanded ? "min-w-0 flex-1" : "shrink-0")} style={panelExpanded ? undefined : { width: panelWidth } as CSSProperties} />
        </div>
      </div>

      <ModalOverlay isOpen={mobileCode} onOpenChange={setMobileCode} isDismissable className="fixed inset-0 z-50 flex justify-end bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 xl:hidden">
        <Modal className="h-full w-[min(410px,calc(100%_-_12px))] bg-background-full p-3 shadow-sidebar outline-none transition-transform duration-300 ease-in-out data-[entering]:translate-x-[110%] data-[exiting]:translate-x-[110%]">
          <Dialog aria-label="Code" className="flex h-full min-h-0 flex-col outline-none">
            <div className="flex h-10 shrink-0 items-center justify-between px-1"><span className="text-headline-medium text-text-primary">Code</span><Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close code" onClick={() => setMobileCode(false)} /></div>
            <AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} onClose={() => setMobileCode(false)} className="min-h-0 flex-1" />
          </Dialog>
        </Modal>
      </ModalOverlay>
    </div>
  );
}

export const AiChatShell = AiChatTemplate;
"use client";

import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
  RiCloseLine,
  RiCodeSSlashLine,
  RiDeleteBinLine,
  RiFileCopyLine,
  RiFolderLine,
  RiMenuLine,
  RiMoreFill,
  RiPencilLine,
  RiShareForwardLine,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { Dropdown, DropdownDivider, DropdownItem, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown";
import { Composer, StatusBar } from "@/components/blocks/composer/composer";
import { AgentSidebar } from "./agent-sidebar";
import { ChatThread, type ChatMessage } from "./chat-thread";
import { AiChatSidePanel, type CodePanelChangeSummary, type SidePanelView } from "./code-panel";
import { cx } from "@/utils/cx";

const DEFAULT_PANEL_WIDTH = 410;
const MIN_PANEL_WIDTH = 330;
const MAX_PANEL_WIDTH = 620;
const PANEL_KEYBOARD_STEP = 10;

function HeaderIcon({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
  return <button type="button" aria-label={label} onClick={onClick} className="group cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><span className="block size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover">{children}</span></button>;
}

export interface AiChatTemplateProps {
  className?: string;
  initiallyWorking?: boolean;
  /** Progress steps passed through to the chat thread while working. */
  steps: string[];
  messages: readonly ChatMessage[];
  codeSource: readonly string[];
  changeSummary: CodePanelChangeSummary;
  terminalLines: readonly string[];
  onShare?: () => void | Promise<void>;
  onOpenProject?: () => void;
  onRename?: () => void;
  onDuplicate?: () => void;
  onDelete?: () => void;
  onTerminalOpenChange?: (open: boolean) => void;
  onPanelExpandedChange?: (expanded: boolean) => void;
}

export function AiChatTemplate({
  className,
  initiallyWorking = false,
  steps,
  messages,
  codeSource,
  changeSummary,
  terminalLines,
  onShare,
  onOpenProject,
  onRename,
  onDuplicate,
  onDelete,
  onTerminalOpenChange,
  onPanelExpandedChange,
}: AiChatTemplateProps) {
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
  const [mobileNavigation, setMobileNavigation] = useState(false);
  const [mobileCode, setMobileCode] = useState(false);
  const [panelView, setPanelView] = useState<SidePanelView>("changes");
  const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
  const [working, setWorking] = useState(initiallyWorking);
  const [moreMenuOpen, setMoreMenuOpen] = useState(false);
  const [terminalOpen, setTerminalOpen] = useState(false);
  const [panelExpanded, setPanelExpanded] = useState(false);
  const dragStart = useRef<{ x: number; width: number } | null>(null);
  const navigationTriggerRef = useRef<HTMLButtonElement>(null);
  const codeTriggerRef = useRef<HTMLButtonElement>(null);
  const navigationWasOpen = useRef(false);
  const codeWasOpen = useRef(false);

  useEffect(() => {
    if (navigationWasOpen.current && !mobileNavigation) navigationTriggerRef.current?.focus();
    navigationWasOpen.current = mobileNavigation;
  }, [mobileNavigation]);

  useEffect(() => {
    if (codeWasOpen.current && !mobileCode) codeTriggerRef.current?.focus();
    codeWasOpen.current = mobileCode;
  }, [mobileCode]);

  const setBoundedPanelWidth = useCallback((width: number) => {
    setPanelWidth(Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width)));
  }, []);

  const beginResize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
    dragStart.current = { x: event.clientX, width: panelWidth };
    event.currentTarget.setPointerCapture(event.pointerId);
  }, [panelWidth]);
  const resize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
    if (!dragStart.current) return;
    setBoundedPanelWidth(dragStart.current.width + dragStart.current.x - event.clientX);
  }, [setBoundedPanelWidth]);
  const resizeWithKeyboard = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
    let nextWidth: number | undefined;

    switch (event.key) {
      case "ArrowLeft":
        nextWidth = panelWidth + (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
        break;
      case "ArrowRight":
        nextWidth = panelWidth - (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
        break;
      case "Home":
        nextWidth = MIN_PANEL_WIDTH;
        break;
      case "End":
        nextWidth = MAX_PANEL_WIDTH;
        break;
      default:
        return;
    }

    event.preventDefault();
    setBoundedPanelWidth(nextWidth);
  }, [panelWidth, setBoundedPanelWidth]);

  const submit = useCallback(async () => {
    setWorking(true);
    await new Promise((resolve) => window.setTimeout(resolve, 3200));
    setWorking(false);
  }, []);

  const shareChat = useCallback(async () => {
    if (onShare) {
      await onShare();
      return;
    }

    const shareData = { title: "Agentic chat", url: window.location.href };
    if (navigator.share) {
      await navigator.share(shareData);
    } else {
      await navigator.clipboard?.writeText(shareData.url);
    }
  }, [onShare]);

  const selectMoreAction = useCallback((action?: () => void) => {
    setMoreMenuOpen(false);
    action?.();
  }, []);

  const changeTerminalOpen = useCallback((open: boolean) => {
    setTerminalOpen(open);
    onTerminalOpenChange?.(open);
  }, [onTerminalOpenChange]);

  const changePanelExpanded = useCallback((expanded: boolean) => {
    setPanelExpanded(expanded);
    onPanelExpandedChange?.(expanded);
  }, [onPanelExpandedChange]);

  return (
    <div className={cx("relative flex h-dvh w-full gap-4 overflow-hidden bg-background-full p-3 text-text-primary", className)}>
      <ModalOverlay isOpen={mobileNavigation} onOpenChange={setMobileNavigation} isDismissable className="fixed inset-0 z-50 flex bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 lg:hidden">
        <Modal className="h-full w-[272px] py-3 pl-[6px] outline-none transition-transform duration-300 ease-in-out data-[entering]:-translate-x-[110%] data-[exiting]:-translate-x-[110%]">
          <Dialog aria-label="Navigation" className="h-full outline-none">
            <AgentSidebar className="w-[260px]" />
          </Dialog>
        </Modal>
      </ModalOverlay>

      <AgentSidebar collapsed={sidebarCollapsed} onCollapsedChange={setSidebarCollapsed} className="relative z-10 hidden lg:flex" />

      <div className="relative z-20 flex min-w-0 flex-1 flex-col overflow-hidden bg-background-full lg:z-0">
        <div className="flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden">
          <div className={cx("relative flex min-w-0 flex-1 basis-0", panelExpanded && "xl:hidden")}>
            <section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-3xl bg-background-secondary-default">
              <header className="flex h-12 shrink-0 items-center justify-between px-3 pt-[11px] xl:hidden">
                <div className="flex min-w-0 items-center gap-2"><Button ref={navigationTriggerRef} variant="secondary" iconOnly leadingIcon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavigation(true)} className="rounded-full lg:hidden" /><span className="truncate px-1 text-headline-medium text-text-primary">Agentic chat</span></div>
                <Button ref={codeTriggerRef} variant="secondary" iconOnly leadingIcon={RiCodeSSlashLine} aria-label="Open code" onClick={() => setMobileCode(true)} className="rounded-full" />
              </header>
              <header className="flex w-full items-center justify-between gap-2 px-4 pt-4">
                <nav aria-label="Chat location" className="flex min-w-0 flex-1 items-center overflow-x-auto [scrollbar-width:none]"><ol className="flex items-center gap-2.5 px-1"><li><button type="button" aria-label="Open project: vibl coding project" onClick={onOpenProject} className="-mx-1 flex items-center gap-1.5 rounded-md px-1 py-0.5 text-caption-1-medium whitespace-nowrap text-text-tertiary hover:bg-background-primary-hover hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><RiFolderLine className="size-4" aria-hidden />vibl coding project</button></li><li aria-hidden className="text-text-tertiary">›</li><li aria-current="page" className="text-caption-1-medium whitespace-nowrap text-text-secondary">coding scenario</li></ol></nav>
                <div className="flex shrink-0 items-center gap-2">
                  <HeaderIcon label="Share chat" onClick={() => { void shareChat(); }}><RiShareForwardLine className="size-4" /></HeaderIcon>
                  <Dropdown isOpen={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
                    <DropdownTrigger aria-label="More options" className="group rounded-sm"><RiMoreFill className="size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover" aria-hidden /></DropdownTrigger>
                    <DropdownPopover aria-label="Chat options" placement="bottom end" className="w-44 p-2">
                      <DropdownItem onSelect={() => selectMoreAction(onRename)}><RiPencilLine className="size-4" aria-hidden />Rename</DropdownItem>
                      <DropdownItem onSelect={() => selectMoreAction(onDuplicate)}><RiFileCopyLine className="size-4" aria-hidden />Duplicate</DropdownItem>
                      <DropdownDivider />
                      <DropdownItem onSelect={() => selectMoreAction(onDelete)} className="text-red-600"><RiDeleteBinLine className="size-4" aria-hidden />Delete</DropdownItem>
                    </DropdownPopover>
                  </Dropdown>
                </div>
              </header>
              <ChatThread working={working} steps={steps} messages={messages} />
              <div className="flex w-full flex-col gap-2.5 px-2.5 pt-3 pb-2.5">
                <Composer working={working} onSubmit={submit} className="[&_input[type=file]]:hidden" />
                <StatusBar />
              </div>
            </section>
            <div role="separator" aria-orientation="vertical" aria-label="Resize code panel" aria-valuemin={MIN_PANEL_WIDTH} aria-valuemax={MAX_PANEL_WIDTH} aria-valuenow={panelWidth} aria-valuetext={`${panelWidth} pixels`} tabIndex={0} onKeyDown={resizeWithKeyboard} onPointerDown={beginResize} onPointerMove={resize} onPointerUp={(event) => { dragStart.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragStart.current = null; }} className="group/drag absolute inset-y-0 -right-2.5 z-10 hidden w-5 cursor-col-resize touch-none justify-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring xl:flex">
              <span className="absolute top-1/2 flex h-[25px] w-[15px] -translate-y-1/2 items-center justify-center gap-0.5 rounded-sm border border-border-button-default bg-background-primary-default opacity-0 shadow-xs transition-opacity group-hover/drag:opacity-100 group-focus-visible/drag:opacity-100"><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /></span>
            </div>
          </div>
          <AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} className={cx("hidden xl:flex", panelExpanded ? "min-w-0 flex-1" : "shrink-0")} style={panelExpanded ? undefined : { width: panelWidth } as CSSProperties} />
        </div>
      </div>

      <ModalOverlay isOpen={mobileCode} onOpenChange={setMobileCode} isDismissable className="fixed inset-0 z-50 flex justify-end bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 xl:hidden">
        <Modal className="h-full w-[min(410px,calc(100%_-_12px))] bg-background-full p-3 shadow-sidebar outline-none transition-transform duration-300 ease-in-out data-[entering]:translate-x-[110%] data-[exiting]:translate-x-[110%]">
          <Dialog aria-label="Code" className="flex h-full min-h-0 flex-col outline-none">
            <div className="flex h-10 shrink-0 items-center justify-between px-1"><span className="text-headline-medium text-text-primary">Code</span><Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close code" onClick={() => setMobileCode(false)} /></div>
            <AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} onClose={() => setMobileCode(false)} className="min-h-0 flex-1" />
          </Dialog>
        </Modal>
      </ModalOverlay>
    </div>
  );
}

export const AiChatShell = AiChatTemplate;
View this template on GitHub