Skip to main content
Split-panel chat interface with a collapsible secondary panel for artifacts, previews, or tools.

Overview

A responsive two-panel layout where the main chat occupies the primary space and a secondary panel (for artifacts, code previews, etc.) slides in when needed. On mobile, the secondary panel replaces the main panel. Composable shadcn-style primitives.

How it works

The layout uses react-resizable-panels wrapped in composable components:
ComponentPurpose
ChatLayoutRoot container with sidebar-aware max-width
ChatLayoutMainPrimary panel, hides on mobile when secondary is visible
ChatLayoutHandleDrag handle between panels
ChatLayoutSecondaryConditional panel, renders only when visible
Visibility is controlled via the isSecondaryPanelVisible prop on ChatLayout. This value is shared to child components through React context, so they automatically show/hide based on the state.

Code

Components

components/chat/chat-layout.tsx
"use client";

import type { ComponentProps } from "react";
import { createContext, useContext } from "react";
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";

type ChatLayoutContextValue = {
  isSecondaryPanelVisible: boolean;
};

const ChatLayoutContext = createContext<ChatLayoutContextValue | null>(null);

function useChatLayoutContext() {
  const context = useContext(ChatLayoutContext);
  if (!context) {
    throw new Error("ChatLayout components must be used within <ChatLayout />");
  }
  return context;
}

type ChatLayoutProps = Omit<
  ComponentProps<typeof ResizablePanelGroup>,
  "direction"
> & {
  isSecondaryPanelVisible?: boolean;
};

export const ChatLayout = ({
  className,
  children,
  isSecondaryPanelVisible = false,
  ...props
}: ChatLayoutProps) => {
  const { state: sidebarState } = useSidebar();

  return (
    <ChatLayoutContext.Provider value={{ isSecondaryPanelVisible }}>
      <ResizablePanelGroup
        className={cn(
          "@container flex h-dvh w-full min-w-0 max-w-screen flex-col bg-background md:max-w-[calc(100vw-var(--sidebar-width))]",
          sidebarState === "collapsed" && "md:max-w-screen",
          className
        )}
        direction="horizontal"
        {...props}
      >
        {children}
      </ResizablePanelGroup>
    </ChatLayoutContext.Provider>
  );
};

type ChatLayoutMainProps = ComponentProps<typeof ResizablePanel>;

export const ChatLayoutMain = ({
  className,
  defaultSize = 65,
  minSize = 40,
  ...props
}: ChatLayoutMainProps) => {
  const { isSecondaryPanelVisible } = useChatLayoutContext();

  return (
    <ResizablePanel
      className={cn(isSecondaryPanelVisible && "hidden md:block", className)}
      defaultSize={defaultSize}
      minSize={minSize}
      {...props}
    />
  );
};

type ChatLayoutSecondaryProps = ComponentProps<typeof ResizablePanel>;

export const ChatLayoutSecondary = ({
  defaultSize = 35,
  minSize = 25,
  ...props
}: ChatLayoutSecondaryProps) => {
  const { isSecondaryPanelVisible } = useChatLayoutContext();

  if (!isSecondaryPanelVisible) {
    return null;
  }

  return (
    <ResizablePanel defaultSize={defaultSize} minSize={minSize} {...props} />
  );
};

type ChatLayoutHandleProps = ComponentProps<typeof ResizableHandle>;

export const ChatLayoutHandle = ({
  className,
  withHandle = true,
  ...props
}: ChatLayoutHandleProps) => {
  const { isSecondaryPanelVisible } = useChatLayoutContext();

  if (!isSecondaryPanelVisible) {
    return null;
  }

  return (
    <ResizableHandle
      className={cn("hidden md:flex", className)}
      withHandle={withHandle}
      {...props}
    />
  );
};

Usage

components/chat.tsx
"use client";

import {
  ChatLayout,
  ChatLayoutHandle,
  ChatLayoutMain,
  ChatLayoutSecondary,
} from "@/components/chat/chat-layout";
import { MainChatPanel } from "@/components/chat/main-chat-panel";
import { SecondaryChatPanel } from "@/components/chat/secondary-chat-panel";
import { useArtifactSelector } from "@/hooks/use-artifact";

export function Chat({ id, isReadonly, projectId }) {
  const isSecondaryPanelVisible = useArtifactSelector(
    (state) => state.isVisible
  );

  return (
    <ChatLayout isSecondaryPanelVisible={isSecondaryPanelVisible}>
      <ChatLayoutMain>
        <MainChatPanel
          chatId={id}
          className="flex h-full min-w-0 flex-1 flex-col"
          isReadonly={isReadonly}
          projectId={projectId}
        />
      </ChatLayoutMain>

      <ChatLayoutHandle />

      <ChatLayoutSecondary>
        <SecondaryChatPanel
          className="flex h-full min-w-0 flex-1 flex-col"
          isReadonly={isReadonly}
        />
      </ChatLayoutSecondary>
    </ChatLayout>
  );
}

Customization

Override default sizes per-instance:
<ChatLayoutMain defaultSize={70} minSize={50}>
  {/* ... */}
</ChatLayoutMain>

<ChatLayoutSecondary defaultSize={30} minSize={20}>
  {/* ... */}
</ChatLayoutSecondary>