diff --git a/.changeset/chat-layout.md b/.changeset/chat-layout.md
new file mode 100644
index 0000000000..f10defa9d8
--- /dev/null
+++ b/.changeset/chat-layout.md
@@ -0,0 +1,9 @@
+---
+'@lg-chat/chat-layout': minor
+---
+
+Initial release with `ChatLayout`, `ChatMain`, and `ChatSideNav`
+- `ChatSideNav` is a compound component with the following subcomponents:
+ - `ChatSideNav.Header`
+ - `ChatSideNav.Content`
+ - `ChatSideNav.SideNavItem`
diff --git a/.changeset/gold-goats-visit.md b/.changeset/gold-goats-visit.md
new file mode 100644
index 0000000000..aa2b68cd2b
--- /dev/null
+++ b/.changeset/gold-goats-visit.md
@@ -0,0 +1,5 @@
+---
+'@lg-chat/input-bar': patch
+---
+
+Remove redundant `z-index: 2;` in `InputBar` content wrapping node.
diff --git a/README.md b/README.md
index bc12657858..16b89cfcdb 100644
--- a/README.md
+++ b/README.md
@@ -149,6 +149,7 @@ import Button from '@leafygreen-ui/button';
| [@lg-charts/legend](./charts/legend) | [](https://www.npmjs.com/package/@lg-charts/legend) |  | [Live Example](http://mongodb.design/component/legend/live-example) |
| [@lg-charts/series-provider](./charts/series-provider) | [](https://www.npmjs.com/package/@lg-charts/series-provider) |  | [Live Example](http://mongodb.design/component/series-provider/live-example) |
| [@lg-chat/avatar](./chat/avatar) | [](https://www.npmjs.com/package/@lg-chat/avatar) |  | [Live Example](http://mongodb.design/component/avatar/live-example) |
+| [@lg-chat/chat-layout](./chat/chat-layout) | [](https://www.npmjs.com/package/@lg-chat/chat-layout) |  | [Live Example](http://mongodb.design/component/chat-layout/live-example) |
| [@lg-chat/chat-window](./chat/chat-window) | [](https://www.npmjs.com/package/@lg-chat/chat-window) |  | [Live Example](http://mongodb.design/component/chat-window/live-example) |
| [@lg-chat/fixed-chat-window](./chat/fixed-chat-window) | [](https://www.npmjs.com/package/@lg-chat/fixed-chat-window) |  | [Live Example](http://mongodb.design/component/fixed-chat-window/live-example) |
| [@lg-chat/input-bar](./chat/input-bar) | [](https://www.npmjs.com/package/@lg-chat/input-bar) |  | [Live Example](http://mongodb.design/component/input-bar/live-example) |
diff --git a/chat/chat-layout/README.md b/chat/chat-layout/README.md
new file mode 100644
index 0000000000..5bb1b1cbe8
--- /dev/null
+++ b/chat/chat-layout/README.md
@@ -0,0 +1,202 @@
+# Chat Layout
+
+
+
+#### [View on MongoDB.design](https://www.mongodb.design/component/chat-layout/live-example/)
+
+## Installation
+
+### PNPM
+
+```shell
+pnpm add @lg-chat/chat-layout
+```
+
+### Yarn
+
+```shell
+yarn add @lg-chat/chat-layout
+```
+
+### NPM
+
+```shell
+npm install @lg-chat/chat-layout
+```
+
+## Overview
+
+`@lg-chat/chat-layout` provides a CSS Grid-based layout system for building full-screen chat interfaces with a side nav that can be collapsed or pinned.
+
+This package exports:
+
+- `ChatLayout`: The grid container and context provider
+- `ChatMain`: The primary content area of the chat interface, automatically positioned within the grid layout.
+- `ChatSideNav`: A compound component representing the side navigation, exposing subcomponents such as `ChatSideNav.Header`, `ChatSideNav.Content`, and `ChatSideNav.SideNavItem` for flexible composition.
+- `useChatLayoutContext`: Hook for accessing layout state
+
+## Examples
+
+### Basic
+
+```tsx
+import { useState } from 'react';
+import { ChatLayout, ChatMain, ChatSideNav } from '@lg-chat/chat-layout';
+
+function MyChatApp() {
+ const [activeChatId, setActiveChatId] = useState('1');
+
+ const chatItems = [
+ { id: '1', name: 'MongoDB Atlas Setup', href: '/chat/1' },
+ { id: '2', name: 'Database Query Help', href: '/chat/2' },
+ { id: '3', name: 'Schema Design Discussion', href: '/chat/3' },
+ ];
+
+ const handleNewChat = () => {
+ console.log('Start new chat');
+ };
+
+ return (
+
+
+
+
+ {chatItems.map(({ href, id, item, name }) => (
+ {
+ e.preventDefault();
+ setActiveChatId(id);
+ }}
+ >
+ {name}
+
+ ))}
+
+
+ {/* Main chat content here */}
+
+ );
+}
+```
+
+### With Initial State and Toggle Pinned Callback
+
+```tsx
+import { ChatLayout, ChatMain, ChatSideNav } from '@lg-chat/chat-layout';
+
+function MyChatApp() {
+ const handleTogglePinned = (isPinned: boolean) => {
+ console.log('Side nav is now:', isPinned ? 'pinned' : 'collapsed');
+ };
+
+ return (
+
+ {/* Side nav subcomponents */}
+ {/* Main chat content */}
+
+ );
+}
+```
+
+## Properties
+
+### ChatLayout
+
+| Prop | Type | Description | Default |
+| ------------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------- | ------- |
+| `children` | `ReactNode` | The content to render inside the grid layout (`ChatSideNav` and `ChatMain` components) | - |
+| `className` _(optional)_ | `string` | Custom CSS class to apply to the grid container | - |
+| `initialIsPinned` _(optional)_ | `boolean` | Initial state for whether the side nav is pinned (expanded) | `true` |
+| `onTogglePinned` _(optional)_ | `(isPinned: boolean) => void` | Callback fired when the side nav is toggled. Receives the new `isPinned` state as an argument | - |
+
+All other props are passed through to the underlying `
` element.
+
+### ChatMain
+
+| Prop | Type | Description | Default |
+| ---------- | ----------- | -------------------------- | ------- |
+| `children` | `ReactNode` | The main content to render | - |
+
+All other props are passed through to the underlying `
` element.
+
+**Note:** `ChatMain` must be used as a direct child of `ChatLayout` to work correctly within the grid system.
+
+### ChatSideNav
+
+| Prop | Type | Description | Default |
+| ------------------------ | ------------------------- | -------------------------------------------------------------- | ------- |
+| `children` | `ReactNode` | Should include `ChatSideNav.Header` and `ChatSideNav.Content`. | - |
+| `className` _(optional)_ | `string` | Root class name | - |
+| `...` | `HTMLElementProps<'nav'>` | Props spread on the root `
` element | - |
+
+### ChatSideNav.Header
+
+| Prop | Type | Description | Default |
+| ----------------------------- | -------------------------------------- | ------------------------------------------- | ------- |
+| `onClickNewChat` _(optional)_ | `MouseEventHandler` | Fired when the "New Chat" button is clicked | - |
+| `className` _(optional)_ | `string` | Header class name | - |
+| `...` | `HTMLElementProps<'div'>` | Props spread on the header container | - |
+
+### ChatSideNav.Content
+
+| Prop | Type | Description | Default |
+| ------------------------ | ------------------------- | ------------------------------------- | ------- |
+| `className` _(optional)_ | `string` | Content class name | - |
+| `...` | `HTMLElementProps<'div'>` | Props spread on the content container | - |
+
+### ChatSideNav.SideNavItem
+
+| Prop | Type | Description | Default |
+| ------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `active` _(optional)_ | `boolean` | Whether or not the component should be rendered in an active state. When active, applies active styling and sets `aria-current="page"` | `false` |
+| `as` _(optional)_ | `React.ElementType` | When provided, the component will be rendered as the component or html tag indicated by this prop. Other additional props will be spread on the element. For example, `Link` or `a` tags can be supplied. Defaults to `'a'` | - |
+| `children` | `ReactNode` | Content that will be rendered inside the root-level element (typically the chat name) | - |
+| `className` _(optional)_ | `string` | Class name that will be applied to the root-level element | - |
+| `href` _(optional)_ | `string` | The URL that the hyperlink points to. When provided, the component will be rendered as an anchor element | - |
+| `onClick` _(optional)_ | `MouseEventHandler` | The event handler function for the 'onclick' event. Receives the associated `event` object as the first argument | - |
+
+## Context API
+
+### useChatLayoutContext
+
+Hook that returns the current chat layout context:
+
+```tsx
+const {
+ isPinned,
+ togglePin,
+ isSideNavHovered,
+ setIsSideNavHovered,
+ shouldRenderExpanded,
+} = useChatLayoutContext();
+```
+
+**Returns:**
+
+| Property | Type | Description |
+| ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
+| `isPinned` | `boolean` | Whether the side nav is currently pinned |
+| `togglePin` | `() => void` | Function to toggle the pinned state |
+| `isSideNavHovered` | `boolean` | Whether the side nav is currently being hovered |
+| `setIsSideNavHovered` | `(isHovered: boolean) => void` | Function to set the hover state of the side nav |
+| `shouldRenderExpanded` | `boolean` | Whether the side nav should render in expanded state. This is `true` when the nav is pinned OR hovered. |
+
+## Behavior
+
+### State Management
+
+- `ChatLayout` manages the `isPinned` and `isSideNavHovered` state internally and provides it to all descendants via `ChatLayoutContext`
+- `shouldRenderExpanded` is computed as `isPinned || isSideNavHovered` and provided in the context for convenience
+- When `togglePin` is called:
+ 1. The `isPinned` state updates
+ 2. Grid columns resize smoothly via CSS transition
+ 3. The `onTogglePinned` callback fires (if provided) with the new state value
+- Descendant components can consume the context to:
+ - Read the current `isPinned` state
+ - Call `togglePin()` to toggle the sidebar
+ - Read the current `isSideNavHovered` state
+ - Call `setIsSideNavHovered()` to update the hover state
+ - Use `shouldRenderExpanded` to determine if the side nav should render in expanded state
diff --git a/chat/chat-layout/package.json b/chat/chat-layout/package.json
new file mode 100644
index 0000000000..d0c461f4ea
--- /dev/null
+++ b/chat/chat-layout/package.json
@@ -0,0 +1,62 @@
+
+{
+ "name": "@lg-chat/chat-layout",
+ "version": "0.0.1",
+ "description": "LeafyGreen UI Kit Chat Layout",
+ "main": "./dist/umd/index.js",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts",
+ "license": "Apache-2.0",
+ "exports": {
+ ".": {
+ "require": "./dist/umd/index.js",
+ "import": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts"
+ },
+ "./testing": {
+ "require": "./dist/umd/testing/index.js",
+ "import": "./dist/esm/testing/index.js",
+ "types": "./dist/types/testing/index.d.ts"
+ }
+ },
+ "scripts": {
+ "build": "lg-build bundle",
+ "tsc": "lg-build tsc",
+ "docs": "lg-build docs"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@leafygreen-ui/avatar": "workspace:^",
+ "@leafygreen-ui/button": "workspace:^",
+ "@leafygreen-ui/compound-component": "workspace:^",
+ "@leafygreen-ui/emotion": "workspace:^",
+ "@leafygreen-ui/icon": "workspace:^",
+ "@leafygreen-ui/lib": "workspace:^",
+ "@leafygreen-ui/palette": "workspace:^",
+ "@leafygreen-ui/polymorphic": "workspace:^",
+ "@leafygreen-ui/tokens": "workspace:^",
+ "@leafygreen-ui/typography": "workspace:^",
+ "@lg-tools/test-harnesses": "workspace:^"
+ },
+ "peerDependencies": {
+ "@leafygreen-ui/leafygreen-provider": "workspace:^3.2.0 || workspace:^4.0.0 || workspace:^5.0.0",
+ "@lg-chat/leafygreen-chat-provider": "workspace:^"
+ },
+ "devDependencies": {
+ "@lg-chat/chat-window": "workspace:^",
+ "@lg-chat/input-bar": "workspace:^",
+ "@lg-chat/message": "workspace:^",
+ "@lg-chat/message-feed": "workspace:^",
+ "@lg-chat/title-bar": "workspace:^"
+ },
+ "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/chat/chat-layout",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/mongodb/leafygreen-ui"
+ },
+ "bugs": {
+ "url": "https://jira.mongodb.org/projects/LG/summary"
+ }
+}
diff --git a/chat/chat-layout/src/ChatLayout.stories.tsx b/chat/chat-layout/src/ChatLayout.stories.tsx
new file mode 100644
index 0000000000..42b1e62ba8
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout.stories.tsx
@@ -0,0 +1,213 @@
+import React, { useState } from 'react';
+import { ChatWindow } from '@lg-chat/chat-window';
+import { InputBar } from '@lg-chat/input-bar';
+import {
+ LeafyGreenChatProvider,
+ Variant,
+} from '@lg-chat/leafygreen-chat-provider';
+import { Message } from '@lg-chat/message';
+import { MessageFeed } from '@lg-chat/message-feed';
+import { TitleBar } from '@lg-chat/title-bar';
+import { StoryMetaType } from '@lg-tools/storybook-utils';
+import { StoryFn, StoryObj } from '@storybook/react';
+import { userEvent, within } from '@storybook/test';
+
+import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
+
+import { ChatLayout, type ChatLayoutProps, ChatMain, ChatSideNav } from '.';
+
+const testMessages = [
+ {
+ id: '1',
+ messageBody: 'Hello! How can I help you today?',
+ isSender: false,
+ },
+ {
+ id: '2',
+ messageBody: 'I need help with ',
+ },
+ {
+ id: '3',
+ messageBody:
+ 'Sure! I can help with that. What specific issue are you encountering?',
+ isSender: false,
+ },
+];
+
+const meta: StoryMetaType = {
+ title: 'Composition/Chat/ChatLayout',
+ component: ChatLayout,
+ parameters: {
+ default: 'LiveExample',
+ },
+ decorators: [
+ (Story, context) => (
+
+
+
+
+
+ ),
+ ],
+};
+export default meta;
+
+const chatItems = [
+ { id: '1', name: 'MongoDB Atlas Setup', href: '/chat/1' },
+ { id: '2', name: 'Writing a Database Query', href: '/chat/2' },
+ { id: '3', name: 'Schema Design Discussion', href: '/chat/3' },
+ { id: '4', name: 'Performance Optimization', href: '/chat/4' },
+ { id: '5', name: 'Migration Planning', href: '/chat/5' },
+];
+
+const Template: StoryFn = props => {
+ const [activeId, setActiveId] = useState('1');
+
+ const handleClick = (id: string) => {
+ // eslint-disable-next-line no-console
+ console.log('Clicked', id);
+ setActiveId(id);
+ };
+
+ return (
+
+
+
+ console.log('Clicked new chat')}
+ />
+
+ {chatItems.map(item => (
+ ) => {
+ e.preventDefault();
+
+ handleClick(item.id);
+ }}
+ >
+ {item.name}
+
+ ))}
+
+
+
+
+
+
+ {testMessages.map(msg => (
+ item.id === activeId)
+ ?.name.toLowerCase()}`
+ : msg.messageBody
+ }
+ sourceType="markdown"
+ />
+ ))}
+
+ {}} />
+
+
+
+
+ );
+};
+
+export const LiveExample: StoryObj = {
+ render: Template,
+ parameters: {
+ chromatic: {
+ disableSnapshot: true,
+ },
+ },
+};
+
+export const PinnedLight: StoryObj = {
+ render: Template,
+ args: {
+ darkMode: false,
+ initialIsPinned: true,
+ },
+};
+
+export const PinnedDark: StoryObj = {
+ render: Template,
+ args: {
+ darkMode: true,
+ initialIsPinned: true,
+ },
+};
+
+export const UnpinnedLight: StoryObj = {
+ render: Template,
+ args: {
+ darkMode: false,
+ initialIsPinned: false,
+ },
+};
+
+export const UnpinnedDark: StoryObj = {
+ render: Template,
+ args: {
+ darkMode: true,
+ initialIsPinned: false,
+ },
+};
+
+export const UnpinnedAndHoveredLight: StoryObj = {
+ render: Template,
+ args: {
+ darkMode: false,
+ initialIsPinned: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // Find the side nav
+ const sideNav = canvas.getByLabelText('Side navigation');
+
+ // Hover over the side nav
+ await userEvent.hover(sideNav);
+ },
+ parameters: {
+ chromatic: {
+ delay: 350,
+ },
+ },
+};
+
+export const UnpinnedAndHoveredDark: StoryObj = {
+ render: Template,
+ args: {
+ darkMode: true,
+ initialIsPinned: false,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // Find the side nav
+ const sideNav = canvas.getByLabelText('Side navigation');
+
+ // Hover over the side nav
+ await userEvent.hover(sideNav);
+ },
+ parameters: {
+ chromatic: {
+ delay: 350,
+ },
+ },
+};
diff --git a/chat/chat-layout/src/ChatLayout/ChatLayout.spec.tsx b/chat/chat-layout/src/ChatLayout/ChatLayout.spec.tsx
new file mode 100644
index 0000000000..1ebfdce926
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout/ChatLayout.spec.tsx
@@ -0,0 +1,217 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ChatLayout, useChatLayoutContext } from '.';
+
+describe('packages/chat-layout', () => {
+ describe('ChatLayout', () => {
+ test('renders children', () => {
+ render(
+
+ Test Content
+ ,
+ );
+ expect(screen.getByText('Test Content')).toBeInTheDocument();
+ });
+
+ test('provides isPinned context with default value', () => {
+ const TestConsumer = () => {
+ const { isPinned } = useChatLayoutContext();
+ return isPinned: {isPinned.toString()}
;
+ };
+
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('isPinned: true')).toBeInTheDocument();
+ });
+
+ test('accepts initialIsPinned prop', () => {
+ const TestConsumer = () => {
+ const { isPinned } = useChatLayoutContext();
+ return isPinned: {isPinned.toString()}
;
+ };
+
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('isPinned: false')).toBeInTheDocument();
+ });
+
+ test('togglePin function updates isPinned state', async () => {
+ const TestConsumer = () => {
+ const { isPinned, togglePin } = useChatLayoutContext();
+ return (
+ <>
+ isPinned: {isPinned.toString()}
+ Toggle
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('isPinned: true')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+
+ expect(screen.getByText('isPinned: false')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+
+ expect(screen.getByText('isPinned: true')).toBeInTheDocument();
+ });
+
+ test('forwards HTML attributes to the div wrapper', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByTestId('chat-layout')).toBeInTheDocument();
+ });
+
+ test('calls onTogglePinned callback when togglePin is called', async () => {
+ const onTogglePinned = jest.fn();
+
+ const TestConsumer = () => {
+ const { togglePin } = useChatLayoutContext();
+ return Toggle ;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ const toggleButton = screen.getByRole('button', { name: 'Toggle' });
+
+ await userEvent.click(toggleButton);
+
+ expect(onTogglePinned).toHaveBeenCalledTimes(1);
+ expect(onTogglePinned).toHaveBeenCalledWith(false);
+
+ await userEvent.click(toggleButton);
+
+ expect(onTogglePinned).toHaveBeenCalledTimes(2);
+ expect(onTogglePinned).toHaveBeenCalledWith(true);
+ });
+
+ test('onTogglePinned receives correct isPinned value based on initialIsPinned', async () => {
+ const onTogglePinned = jest.fn();
+
+ const TestConsumer = () => {
+ const { togglePin } = useChatLayoutContext();
+ return Toggle ;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+
+ expect(onTogglePinned).toHaveBeenCalledWith(true);
+ });
+
+ test('applies custom className', () => {
+ render(
+
+ Content
+ ,
+ );
+ const element = screen.getByTestId('chat-layout');
+ expect(element).toHaveClass('custom-class');
+ });
+
+ test('provides isSideNavHovered context with default value', () => {
+ const TestConsumer = () => {
+ const { isSideNavHovered } = useChatLayoutContext();
+ return isSideNavHovered: {isSideNavHovered.toString()}
;
+ };
+
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
+ });
+
+ test('setIsSideNavHovered function updates isSideNavHovered state', async () => {
+ const TestConsumer = () => {
+ const { isSideNavHovered, setIsSideNavHovered } =
+ useChatLayoutContext();
+ return (
+ <>
+ isSideNavHovered: {isSideNavHovered.toString()}
+ setIsSideNavHovered(true)}>
+ Set Hovered
+
+ setIsSideNavHovered(false)}>
+ Set Not Hovered
+
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Set Hovered' }),
+ );
+ expect(screen.getByText('isSideNavHovered: true')).toBeInTheDocument();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Set Not Hovered' }),
+ );
+ expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
+ });
+
+ test('togglePin updates isPinned state correctly when hovered', async () => {
+ const TestConsumer = () => {
+ const { isPinned, togglePin, isSideNavHovered } =
+ useChatLayoutContext();
+ return (
+ <>
+ isPinned: {isPinned.toString()}
+ isSideNavHovered: {isSideNavHovered.toString()}
+ Toggle
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('isPinned: false')).toBeInTheDocument();
+ expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle' }));
+
+ expect(screen.getByText('isPinned: true')).toBeInTheDocument();
+ expect(screen.getByText('isSideNavHovered: false')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/chat/chat-layout/src/ChatLayout/ChatLayout.styles.ts b/chat/chat-layout/src/ChatLayout/ChatLayout.styles.ts
new file mode 100644
index 0000000000..93d7c10866
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout/ChatLayout.styles.ts
@@ -0,0 +1,40 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+
+import {
+ COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER,
+ gridAreas,
+ PINNED_SIDE_NAV_WIDTH_WITH_BORDER,
+ SIDE_NAV_TRANSITION_DURATION,
+} from '../constants';
+
+const baseContainerStyles = css`
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+ max-height: 100vh;
+ max-width: 100vw;
+ display: grid;
+ grid-template-areas: '${gridAreas.sideNav} ${gridAreas.main}';
+ grid-template-columns: ${PINNED_SIDE_NAV_WIDTH_WITH_BORDER}px auto;
+ transition: grid-template-columns ${SIDE_NAV_TRANSITION_DURATION}ms
+ ease-in-out;
+`;
+
+const collapsedContainerStyles = css`
+ grid-template-columns: ${COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER}px auto;
+`;
+
+export const getContainerStyles = ({
+ className,
+ isPinned,
+}: {
+ className?: string;
+ isPinned: boolean;
+}) =>
+ cx(
+ baseContainerStyles,
+ {
+ [collapsedContainerStyles]: !isPinned,
+ },
+ className,
+ );
diff --git a/chat/chat-layout/src/ChatLayout/ChatLayout.tsx b/chat/chat-layout/src/ChatLayout/ChatLayout.tsx
new file mode 100644
index 0000000000..9bc9602d55
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout/ChatLayout.tsx
@@ -0,0 +1,56 @@
+import React, { useCallback, useMemo, useState } from 'react';
+
+import { getContainerStyles } from './ChatLayout.styles';
+import { ChatLayoutProps } from './ChatLayout.types';
+import { ChatLayoutContext } from './ChatLayoutContext';
+
+/**
+ * ChatLayout is a context provider that manages the pinned state of the side nav
+ * and provides it to all child components. It uses CSS Grid to control the layout
+ * and positioning the side nav and main content.
+ */
+export function ChatLayout({
+ children,
+ className,
+ initialIsPinned = true,
+ onTogglePinned,
+ ...rest
+}: ChatLayoutProps) {
+ const [isPinned, setIsPinned] = useState(initialIsPinned);
+ const [isSideNavHovered, setIsSideNavHovered] = useState(false);
+
+ const togglePin = useCallback(() => {
+ const newValue = !isPinned;
+ setIsPinned(newValue);
+ onTogglePinned?.(newValue);
+ }, [isPinned, onTogglePinned]);
+
+ const shouldRenderExpanded = isPinned || isSideNavHovered;
+
+ const value = useMemo(
+ () => ({
+ isPinned,
+ togglePin,
+ isSideNavHovered,
+ setIsSideNavHovered,
+ shouldRenderExpanded,
+ }),
+ [
+ isPinned,
+ togglePin,
+ isSideNavHovered,
+ setIsSideNavHovered,
+ shouldRenderExpanded,
+ ],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+ChatLayout.displayName = 'ChatLayout';
diff --git a/chat/chat-layout/src/ChatLayout/ChatLayout.types.ts b/chat/chat-layout/src/ChatLayout/ChatLayout.types.ts
new file mode 100644
index 0000000000..f2d86c2bb4
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout/ChatLayout.types.ts
@@ -0,0 +1,19 @@
+import { ComponentPropsWithRef } from 'react';
+
+import { DarkModeProps } from '@leafygreen-ui/lib';
+
+export interface ChatLayoutProps
+ extends ComponentPropsWithRef<'div'>,
+ DarkModeProps {
+ /**
+ * Initial state for whether the side nav is pinned (expanded).
+ * @default true
+ */
+ initialIsPinned?: boolean;
+
+ /**
+ * Callback fired when the side nav is toggled (pinned/unpinned).
+ * Receives the new `isPinned` state as an argument.
+ */
+ onTogglePinned?: (isPinned: boolean) => void;
+}
diff --git a/chat/chat-layout/src/ChatLayout/ChatLayoutContext.ts b/chat/chat-layout/src/ChatLayout/ChatLayoutContext.ts
new file mode 100644
index 0000000000..57e935eb35
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout/ChatLayoutContext.ts
@@ -0,0 +1,39 @@
+import { createContext, useContext } from 'react';
+
+export interface ChatLayoutContextProps {
+ /**
+ * Whether the side nav is pinned (expanded) or not
+ */
+ isPinned: boolean;
+
+ /**
+ * Function to toggle the pinned state of the side nav
+ */
+ togglePin: () => void;
+
+ /**
+ * Whether the side nav is currently being hovered
+ */
+ isSideNavHovered: boolean;
+
+ /**
+ * Function to set the hover state of the side nav
+ */
+ setIsSideNavHovered: (isHovered: boolean) => void;
+
+ /**
+ * Whether the side nav should render in expanded state.
+ * This is true when the nav is pinned OR when it's being hovered.
+ */
+ shouldRenderExpanded: boolean;
+}
+
+export const ChatLayoutContext = createContext({
+ isPinned: true,
+ togglePin: () => {},
+ isSideNavHovered: false,
+ setIsSideNavHovered: () => {},
+ shouldRenderExpanded: true,
+});
+
+export const useChatLayoutContext = () => useContext(ChatLayoutContext);
diff --git a/chat/chat-layout/src/ChatLayout/index.ts b/chat/chat-layout/src/ChatLayout/index.ts
new file mode 100644
index 0000000000..66fa95a5b4
--- /dev/null
+++ b/chat/chat-layout/src/ChatLayout/index.ts
@@ -0,0 +1,7 @@
+export { ChatLayout } from './ChatLayout';
+export { type ChatLayoutProps } from './ChatLayout.types';
+export {
+ ChatLayoutContext,
+ type ChatLayoutContextProps,
+ useChatLayoutContext,
+} from './ChatLayoutContext';
diff --git a/chat/chat-layout/src/ChatMain/ChatMain.spec.tsx b/chat/chat-layout/src/ChatMain/ChatMain.spec.tsx
new file mode 100644
index 0000000000..2f0376ad1e
--- /dev/null
+++ b/chat/chat-layout/src/ChatMain/ChatMain.spec.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { ChatLayout } from '../ChatLayout';
+
+import { ChatMain } from '.';
+
+describe('packages/chat-layout/ChatMain', () => {
+ describe('ChatMain', () => {
+ test('renders children', () => {
+ render(
+
+
+ Main Content
+
+ ,
+ );
+ expect(screen.getByText('Main Content')).toBeInTheDocument();
+ });
+
+ test('forwards HTML attributes to the div element', () => {
+ render(
+
+
+ Content
+
+ ,
+ );
+ const element = screen.getByTestId('chat-main');
+ expect(element).toHaveAttribute('aria-label', 'Chat content');
+ });
+
+ test('forwards ref to the div element', () => {
+ const ref = React.createRef();
+ render(
+
+ Content
+ ,
+ );
+ expect(ref.current).toBeInstanceOf(HTMLDivElement);
+ expect(ref.current?.tagName).toBe('DIV');
+ });
+
+ test('applies custom className', () => {
+ render(
+
+
+ Content
+
+ ,
+ );
+ const element = screen.getByTestId('chat-main');
+ expect(element).toHaveClass('custom-class');
+ });
+ });
+});
diff --git a/chat/chat-layout/src/ChatMain/ChatMain.styles.ts b/chat/chat-layout/src/ChatMain/ChatMain.styles.ts
new file mode 100644
index 0000000000..853f5571c5
--- /dev/null
+++ b/chat/chat-layout/src/ChatMain/ChatMain.styles.ts
@@ -0,0 +1,26 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { spacing } from '@leafygreen-ui/tokens';
+
+import { gridAreas } from '../constants';
+
+const CHAT_WINDOW_MAX_WIDTH = 800;
+
+const baseContainerStyles = css`
+ grid-area: ${gridAreas.main};
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ height: 100%;
+`;
+
+export const getContainerStyles = ({ className }: { className?: string }) =>
+ cx(baseContainerStyles, className);
+
+export const chatWindowWrapperStyles = css`
+ height: 100%;
+ width: 100%;
+ max-width: ${CHAT_WINDOW_MAX_WIDTH}px;
+ padding: 0 ${spacing[800]}px;
+ display: flex;
+ align-self: center;
+`;
diff --git a/chat/chat-layout/src/ChatMain/ChatMain.tsx b/chat/chat-layout/src/ChatMain/ChatMain.tsx
new file mode 100644
index 0000000000..3323ec700a
--- /dev/null
+++ b/chat/chat-layout/src/ChatMain/ChatMain.tsx
@@ -0,0 +1,35 @@
+import React, { Children, forwardRef } from 'react';
+
+import { isComponentType } from '@leafygreen-ui/lib';
+
+import { chatWindowWrapperStyles, getContainerStyles } from './ChatMain.styles';
+import { ChatMainProps } from './ChatMain.types';
+
+/**
+ * ChatMain represents the main content area of the chat layout.
+ * It automatically positions itself in the second column of the parent
+ * ChatLayout's CSS Grid, allowing the layout to control spacing for the sidebar.
+ */
+export const ChatMain = forwardRef(
+ ({ children, className, ...rest }, ref) => {
+ const renderedChildren = Children.toArray(children).map(child => {
+ if (isComponentType(child, 'ChatWindow')) {
+ return (
+
+ {child}
+
+ );
+ } else {
+ return child;
+ }
+ });
+
+ return (
+
+ {renderedChildren}
+
+ );
+ },
+);
+
+ChatMain.displayName = 'ChatMain';
diff --git a/chat/chat-layout/src/ChatMain/ChatMain.types.ts b/chat/chat-layout/src/ChatMain/ChatMain.types.ts
new file mode 100644
index 0000000000..9070c8ea0e
--- /dev/null
+++ b/chat/chat-layout/src/ChatMain/ChatMain.types.ts
@@ -0,0 +1,7 @@
+import { ComponentPropsWithRef } from 'react';
+
+import { DarkModeProps } from '@leafygreen-ui/lib';
+
+export interface ChatMainProps
+ extends ComponentPropsWithRef<'div'>,
+ DarkModeProps {}
diff --git a/chat/chat-layout/src/ChatMain/index.ts b/chat/chat-layout/src/ChatMain/index.ts
new file mode 100644
index 0000000000..a212ee6fff
--- /dev/null
+++ b/chat/chat-layout/src/ChatMain/index.ts
@@ -0,0 +1,2 @@
+export { ChatMain } from './ChatMain';
+export { type ChatMainProps } from './ChatMain.types';
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNav.spec.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNav.spec.tsx
new file mode 100644
index 0000000000..6c041eac07
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNav.spec.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import {
+ LeafyGreenChatProvider,
+ Variant,
+} from '@lg-chat/leafygreen-chat-provider';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ChatSideNav } from '.';
+
+const Providers = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('ChatSideNav', () => {
+ beforeAll(() => {
+ global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ }));
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Header shows "New Chat" button when onClickNewChat provided', async () => {
+ const onClickNewChat = jest.fn();
+
+ render(
+
+
+
+
+
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: /new chat/i });
+ expect(button).toBeInTheDocument();
+
+ await userEvent.click(button);
+ expect(onClickNewChat).toHaveBeenCalledTimes(1);
+ });
+
+ test('Header does not render "New Chat" button when onClickNewChat is absent', () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.queryByRole('button', { name: /new chat/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ test('does not render children that are not ChatSideNavHeader or ChatSideNavContent', () => {
+ render(
+
+
+
+
+ Should not render
+
+ Also should not render
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId('arbitrary-child')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('another-arbitrary-child'),
+ ).not.toBeInTheDocument();
+ });
+
+ test('Content renders children that are SideNavItems', () => {
+ render(
+
+
+
+
+ Chat 1
+ Chat 2
+
+
+ ,
+ );
+
+ expect(screen.getByText('Chat 1')).toBeInTheDocument();
+ expect(screen.getByText('Chat 2')).toBeInTheDocument();
+ });
+
+ test('Content does not render children that are not SideNavItems', () => {
+ render(
+
+
+
+
+ Chat 1
+ Should not render
+
+ Also should not render
+
+
+
+ ,
+ );
+
+ expect(screen.getByText('Chat 1')).toBeInTheDocument();
+ expect(screen.queryByTestId('arbitrary-child')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('another-arbitrary-child'),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNav.styles.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNav.styles.ts
new file mode 100644
index 0000000000..37cb6df74e
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNav.styles.ts
@@ -0,0 +1,67 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import {
+ addOverflowShadow,
+ color,
+ InteractionState,
+ Side,
+ Variant,
+} from '@leafygreen-ui/tokens';
+
+import {
+ COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER,
+ gridAreas,
+ PINNED_SIDE_NAV_WIDTH_WITH_BORDER,
+ SIDE_NAV_TRANSITION_DURATION,
+} from '../constants';
+
+const getBaseWrapperStyles = (theme: Theme) => css`
+ grid-area: ${gridAreas.sideNav};
+ border-right: 1px solid
+ ${color[theme].border[Variant.Secondary][InteractionState.Default]};
+ height: 100%;
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ z-index: 1;
+`;
+
+export const getWrapperStyles = ({
+ className,
+ theme,
+}: {
+ className?: string;
+ theme: Theme;
+}) => {
+ return cx(getBaseWrapperStyles(theme), className);
+};
+
+const baseContainerStyles = css`
+ position: relative;
+ z-index: 1;
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ transition: max-width ${SIDE_NAV_TRANSITION_DURATION}ms ease-in-out;
+ max-width: ${PINNED_SIDE_NAV_WIDTH_WITH_BORDER}px;
+`;
+
+const collapsedContainerStyles = css`
+ max-width: ${COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER}px;
+`;
+
+export const getContainerStyles = ({
+ shouldRenderExpanded,
+ showOverflowShadow,
+ theme,
+}: {
+ shouldRenderExpanded: boolean;
+ showOverflowShadow: boolean;
+ theme: Theme;
+}) =>
+ cx(baseContainerStyles, {
+ [collapsedContainerStyles]: !shouldRenderExpanded,
+ [addOverflowShadow({ isInside: false, side: Side.Right, theme })]:
+ showOverflowShadow,
+ });
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNav.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNav.tsx
new file mode 100644
index 0000000000..89469aa08b
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNav.tsx
@@ -0,0 +1,86 @@
+import React, { forwardRef } from 'react';
+
+import {
+ CompoundComponent,
+ findChild,
+} from '@leafygreen-ui/compound-component';
+import LeafyGreenProvider, {
+ useDarkMode,
+} from '@leafygreen-ui/leafygreen-provider';
+
+import { useChatLayoutContext } from '../ChatLayout/ChatLayoutContext';
+
+import { getContainerStyles, getWrapperStyles } from './ChatSideNav.styles';
+import {
+ type ChatSideNavProps,
+ ChatSideNavSubcomponentProperty,
+} from './ChatSideNav.types';
+import { ChatSideNavContent } from './ChatSideNavContent';
+import { ChatSideNavFooter } from './ChatSideNavFooter';
+import { ChatSideNavHeader } from './ChatSideNavHeader';
+import { ChatSideNavItem } from './ChatSideNavItem';
+
+export const ChatSideNav = CompoundComponent(
+ // eslint-disable-next-line react/display-name
+ forwardRef(
+ ({ children, className, darkMode: darkModeProp, ...rest }, ref) => {
+ const { darkMode, theme } = useDarkMode(darkModeProp);
+ const { isPinned, setIsSideNavHovered, shouldRenderExpanded } =
+ useChatLayoutContext();
+
+ const handleMouseEnter = () => {
+ setIsSideNavHovered(true);
+ };
+
+ const handleMouseLeave = () => {
+ setIsSideNavHovered(false);
+ };
+
+ // Find subcomponents
+ const header = findChild(
+ children,
+ ChatSideNavSubcomponentProperty.Header,
+ );
+ const content = findChild(
+ children,
+ ChatSideNavSubcomponentProperty.Content,
+ );
+
+ const showOverflowShadow = !isPinned && shouldRenderExpanded;
+
+ return (
+
+
+
+ {header}
+ {content}
+
+
+
+
+ );
+ },
+ ),
+ {
+ displayName: 'ChatSideNav',
+ Header: ChatSideNavHeader,
+ Content: ChatSideNavContent,
+ SideNavItem: ChatSideNavItem,
+ },
+);
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNav.types.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNav.types.ts
new file mode 100644
index 0000000000..f42a2c47cb
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNav.types.ts
@@ -0,0 +1,38 @@
+import { ComponentPropsWithRef, MouseEventHandler } from 'react';
+
+import { DarkModeProps } from '@leafygreen-ui/lib';
+
+export interface ChatSideNavProps
+ extends ComponentPropsWithRef<'nav'>,
+ DarkModeProps {}
+
+export interface ChatSideNavHeaderProps
+ extends ComponentPropsWithRef<'div'>,
+ DarkModeProps {
+ /**
+ * Optional callback fired when the "New Chat" button is clicked.
+ */
+ onClickNewChat?: MouseEventHandler;
+}
+
+export interface ChatSideNavContentProps
+ extends ComponentPropsWithRef<'div'>,
+ DarkModeProps {}
+
+export interface ChatSideNavFooterProps extends ComponentPropsWithRef<'div'> {}
+
+/**
+ * Static property names used to identify ChatSideNav compound components.
+ * These are implementation details for the compound component pattern and should not be exported.
+ */
+export const ChatSideNavSubcomponentProperty = {
+ Header: 'isChatSideNavHeader',
+ Content: 'isChatSideNavContent',
+ SideNavItem: 'isChatSideNavItem',
+} as const;
+
+/**
+ * Type representing the possible static property names for ChatSideNav subcomponents.
+ */
+export type ChatSideNavSubcomponentProperty =
+ (typeof ChatSideNavSubcomponentProperty)[keyof typeof ChatSideNavSubcomponentProperty];
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/ChatSideNavContent.styles.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/ChatSideNavContent.styles.ts
new file mode 100644
index 0000000000..9dc920bc60
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/ChatSideNavContent.styles.ts
@@ -0,0 +1,115 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import {
+ color,
+ InteractionState,
+ spacing,
+ Variant,
+} from '@leafygreen-ui/tokens';
+
+import {
+ COLLAPSED_SIDE_NAV_WIDTH,
+ PINNED_SIDE_NAV_WIDTH,
+ SIDE_NAV_TRANSITION_DURATION,
+} from '../../constants';
+
+/**
+ * Adding 1px to the height due to the border being included in the height
+ * when `box-sizing: border-box` is used
+ */
+const CONTENT_HEADER_HEIGHT = 48 + 1;
+const ICON_SIZE = 16;
+
+const getBaseContentStyles = (theme: Theme) => css`
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Default
+ ]};
+ flex: 1;
+ overflow-x: hidden;
+ overflow-y: auto;
+ min-height: 0;
+ max-width: ${PINNED_SIDE_NAV_WIDTH}px;
+ transition: max-width ${SIDE_NAV_TRANSITION_DURATION}ms ease-in-out;
+`;
+
+const collapsedContentStyles = css`
+ max-width: ${COLLAPSED_SIDE_NAV_WIDTH}px;
+`;
+
+export const getContentStyles = ({
+ className,
+ shouldRenderExpanded,
+ theme,
+}: {
+ className?: string;
+ shouldRenderExpanded: boolean;
+ theme: Theme;
+}) =>
+ cx(
+ getBaseContentStyles(theme),
+ {
+ [collapsedContentStyles]: !shouldRenderExpanded,
+ },
+ className,
+ );
+
+const baseContentHeaderStyles = css`
+ overflow: hidden;
+ height: ${CONTENT_HEADER_HEIGHT}px;
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ padding: 0 ${spacing[400]}px;
+ display: grid;
+ align-items: center;
+ gap: ${spacing[200]}px;
+ transition: grid-template-columns ${SIDE_NAV_TRANSITION_DURATION}ms
+ ease-in-out,
+ border-bottom-color ${SIDE_NAV_TRANSITION_DURATION}ms ease-in-out;
+ grid-template-columns: ${ICON_SIZE}px auto;
+ border-bottom-color: transparent;
+`;
+
+const getCollapsedContentHeaderStyles = (theme: Theme) => css`
+ grid-template-columns: ${ICON_SIZE}px 0fr;
+ border-bottom-color: ${color[theme].border[Variant.Secondary][
+ InteractionState.Default
+ ]};
+`;
+
+export const getContentHeaderStyles = ({
+ shouldRenderExpanded,
+ theme,
+}: {
+ shouldRenderExpanded: boolean;
+ theme: Theme;
+}) =>
+ cx(baseContentHeaderStyles, {
+ [getCollapsedContentHeaderStyles(theme)]: !shouldRenderExpanded,
+ });
+
+export const getIconFill = (theme: Theme) =>
+ color[theme].icon[Variant.Primary][InteractionState.Default];
+
+const getBaseOverlineStyles = (theme: Theme) => css`
+ width: 200px;
+ color: ${color[theme].text[Variant.Secondary][InteractionState.Default]};
+ opacity: 1;
+ transition-property: opacity;
+ transition-duration: ${SIDE_NAV_TRANSITION_DURATION}ms;
+ transition-timing-function: ease-in-out;
+`;
+
+const hiddenOverlineStyles = css`
+ opacity: 0;
+`;
+
+export const getOverlineStyles = ({
+ shouldRender,
+ theme,
+}: {
+ shouldRender: boolean;
+ theme: Theme;
+}) =>
+ cx(getBaseOverlineStyles(theme), {
+ [hiddenOverlineStyles]: !shouldRender,
+ });
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/ChatSideNavContent.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/ChatSideNavContent.tsx
new file mode 100644
index 0000000000..32782ca986
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/ChatSideNavContent.tsx
@@ -0,0 +1,68 @@
+import React, { forwardRef } from 'react';
+
+import {
+ CompoundSubComponent,
+ findChildren,
+} from '@leafygreen-ui/compound-component';
+import ClockWithArrowIcon from '@leafygreen-ui/icon/dist/ClockWithArrow';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+import { Overline } from '@leafygreen-ui/typography';
+
+import { useChatLayoutContext } from '../../ChatLayout/ChatLayoutContext';
+import {
+ ChatSideNavContentProps,
+ ChatSideNavSubcomponentProperty,
+} from '../ChatSideNav.types';
+
+import {
+ getContentHeaderStyles,
+ getContentStyles,
+ getIconFill,
+ getOverlineStyles,
+} from './ChatSideNavContent.styles';
+
+export const ChatSideNavContent = CompoundSubComponent(
+ // eslint-disable-next-line react/display-name
+ forwardRef(
+ ({ children, className, ...rest }, ref) => {
+ const { theme } = useDarkMode();
+ const { shouldRenderExpanded } = useChatLayoutContext();
+
+ const sideNavItems = findChildren(
+ children,
+ ChatSideNavSubcomponentProperty.SideNavItem,
+ );
+
+ return (
+
+
+
+
+ Recent chats
+
+
+ {sideNavItems}
+
+ );
+ },
+ ),
+ {
+ displayName: 'ChatSideNavContent',
+ key: ChatSideNavSubcomponentProperty.Content,
+ },
+);
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/index.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/index.ts
new file mode 100644
index 0000000000..206ad9a62a
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavContent/index.ts
@@ -0,0 +1 @@
+export { ChatSideNavContent } from './ChatSideNavContent';
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/ChatSideNavFooter.styles.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/ChatSideNavFooter.styles.ts
new file mode 100644
index 0000000000..ea541d347a
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/ChatSideNavFooter.styles.ts
@@ -0,0 +1,94 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import {
+ color,
+ InteractionState,
+ spacing,
+ Variant,
+} from '@leafygreen-ui/tokens';
+
+import {
+ COLLAPSED_SIDE_NAV_WIDTH,
+ PINNED_SIDE_NAV_WIDTH,
+ SIDE_NAV_TRANSITION_DURATION,
+} from '../../constants';
+
+const BUTTON_HEIGHT = 48;
+/**
+ * Adding 1px to the height due to the border being included in the height
+ * when `box-sizing: border-box` is used
+ */
+const FOOTER_HEIGHT = 48 + 1;
+
+const getBaseFooterStyles = (theme: Theme) => css`
+ overflow: hidden;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Default
+ ]};
+ width: 100%;
+ max-width: ${PINNED_SIDE_NAV_WIDTH}px;
+ height: ${FOOTER_HEIGHT}px;
+ border-top: 1px solid
+ ${color[theme].border[Variant.Secondary][InteractionState.Default]};
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ transition: max-width ${SIDE_NAV_TRANSITION_DURATION}ms ease-in-out;
+`;
+
+const collapsedFooterStyles = css`
+ max-width: ${COLLAPSED_SIDE_NAV_WIDTH}px;
+`;
+
+export const getFooterStyles = ({
+ className,
+ shouldRenderExpanded,
+ theme,
+}: {
+ className?: string;
+ shouldRenderExpanded: boolean;
+ theme: Theme;
+}) =>
+ cx(
+ getBaseFooterStyles(theme),
+ {
+ [collapsedFooterStyles]: !shouldRenderExpanded,
+ },
+ className,
+ );
+
+export const getButtonStyles = (theme: Theme) => css`
+ border-radius: 0;
+ border: none;
+ height: ${BUTTON_HEIGHT}px;
+ width: 100%;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Default
+ ]};
+
+ &:hover,
+ &:active {
+ box-shadow: none;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Hover
+ ]};
+ }
+
+ &:focus-visible {
+ box-shadow: none;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Focus
+ ]};
+ color: ${color[theme].text[Variant.Secondary][InteractionState.Focus]};
+
+ svg {
+ color: ${color[theme].text[Variant.Secondary][InteractionState.Focus]};
+ }
+ }
+
+ // Override the properties in ButtonContent
+ div:nth-child(2) {
+ padding: ${spacing[400]}px;
+ justify-content: flex-end;
+ }
+`;
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/ChatSideNavFooter.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/ChatSideNavFooter.tsx
new file mode 100644
index 0000000000..8d7931396b
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/ChatSideNavFooter.tsx
@@ -0,0 +1,38 @@
+import React, { forwardRef } from 'react';
+
+import { Button } from '@leafygreen-ui/button';
+import NavCollapseIcon from '@leafygreen-ui/icon/dist/NavCollapse';
+import NavExpandIcon from '@leafygreen-ui/icon/dist/NavExpand';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+
+import { useChatLayoutContext } from '../../ChatLayout/ChatLayoutContext';
+import { type ChatSideNavFooterProps } from '../ChatSideNav.types';
+
+import { getButtonStyles, getFooterStyles } from './ChatSideNavFooter.styles';
+
+/** @internal */
+export const ChatSideNavFooter = forwardRef<
+ HTMLDivElement,
+ ChatSideNavFooterProps
+>(({ className, ...rest }, ref) => {
+ const { theme } = useDarkMode();
+ const { isPinned, togglePin, shouldRenderExpanded } = useChatLayoutContext();
+
+ return (
+
+
+ {isPinned ? : }
+
+
+ );
+});
+
+ChatSideNavFooter.displayName = 'ChatSideNavFooter';
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/index.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/index.ts
new file mode 100644
index 0000000000..c93ad7a6f5
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavFooter/index.ts
@@ -0,0 +1 @@
+export { ChatSideNavFooter } from './ChatSideNavFooter';
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/ChatSideNavHeader.styles.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/ChatSideNavHeader.styles.ts
new file mode 100644
index 0000000000..1980c06a94
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/ChatSideNavHeader.styles.ts
@@ -0,0 +1,183 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import { palette } from '@leafygreen-ui/palette';
+import {
+ color,
+ InteractionState,
+ spacing,
+ Variant,
+} from '@leafygreen-ui/tokens';
+
+import {
+ COLLAPSED_SIDE_NAV_WIDTH,
+ PINNED_SIDE_NAV_WIDTH,
+ SIDE_NAV_TRANSITION_DURATION,
+} from '../../constants';
+
+const ASSISTANT_AVATAR_SIZE = 20;
+const AVATAR_WRAPPER_HORIZONTAL_PADDING = 14;
+/**
+ * Adding 1px to the height due to the border being included in the height
+ * when `box-sizing: border-box` is used
+ */
+const HEADER_SUB_CONTAINER_HEIGHT = 48 + 1;
+
+const getBorderBottomStyle = (theme: Theme) => css`
+ border-bottom: 1px solid
+ ${color[theme].border[Variant.Secondary][InteractionState.Default]};
+`;
+
+const getBaseHeaderStyles = (theme: Theme) => css`
+ overflow: hidden;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Default
+ ]};
+ width: 100%;
+ max-width: ${PINNED_SIDE_NAV_WIDTH}px;
+ transition: max-width ${SIDE_NAV_TRANSITION_DURATION}ms ease-in-out;
+
+ ${getBorderBottomStyle(theme)};
+`;
+
+const collapsedHeaderStyles = css`
+ max-width: ${COLLAPSED_SIDE_NAV_WIDTH}px;
+`;
+
+export const getHeaderStyles = ({
+ className,
+ shouldRenderExpanded,
+ theme,
+}: {
+ className?: string;
+ shouldRenderExpanded: boolean;
+ theme: Theme;
+}) =>
+ cx(
+ getBaseHeaderStyles(theme),
+ {
+ [collapsedHeaderStyles]: !shouldRenderExpanded,
+ },
+ className,
+ );
+
+const baseAvatarContainerStyles = css`
+ height: ${HEADER_SUB_CONTAINER_HEIGHT}px;
+ padding: 0 ${AVATAR_WRAPPER_HORIZONTAL_PADDING}px;
+ display: grid;
+ grid-template-columns: ${ASSISTANT_AVATAR_SIZE}px auto;
+ align-items: center;
+ gap: ${spacing[150]}px;
+ transition: grid-template-columns ${SIDE_NAV_TRANSITION_DURATION}ms
+ ease-in-out;
+`;
+
+const collapsedAvatarContainerStyles = css`
+ grid-template-columns: ${ASSISTANT_AVATAR_SIZE}px 0fr;
+`;
+
+export const getAvatarContainerStyles = ({
+ addBorderBottom,
+ shouldRenderExpanded,
+ theme,
+}: {
+ addBorderBottom: boolean;
+ shouldRenderExpanded: boolean;
+ theme: Theme;
+}) =>
+ cx(baseAvatarContainerStyles, {
+ [collapsedAvatarContainerStyles]: !shouldRenderExpanded,
+ [getBorderBottomStyle(theme)]: addBorderBottom,
+ });
+
+const baseAssistantNameStyles = css`
+ width: 198px;
+ opacity: 1;
+ transition-property: opacity;
+ transition-duration: ${SIDE_NAV_TRANSITION_DURATION}ms;
+ transition-timing-function: ease-in-out;
+`;
+
+const hiddenAssistantNameStyles = css`
+ opacity: 0;
+`;
+
+export const getAssistantNameStyles = ({
+ shouldRender,
+}: {
+ shouldRender: boolean;
+}) =>
+ cx(baseAssistantNameStyles, {
+ [hiddenAssistantNameStyles]: !shouldRender,
+ });
+
+export const getButtonStyles = (theme: Theme) => {
+ const textColor = palette.green[theme === Theme.Dark ? 'light2' : 'dark2'];
+
+ return css`
+ border-radius: 0;
+ border: none;
+ height: ${HEADER_SUB_CONTAINER_HEIGHT}px;
+ // Non-token value used because ButtonContent padding is not customizable
+ padding: ${spacing[300]}px 9px;
+ width: 100%;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Default
+ ]};
+ color: ${textColor};
+
+ &:hover,
+ &:active {
+ box-shadow: none;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Hover
+ ]};
+ color: ${textColor};
+ }
+
+ &:focus-visible {
+ box-shadow: none;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Focus
+ ]};
+ color: ${color[theme].text[Variant.Secondary][InteractionState.Focus]};
+
+ svg {
+ color: ${color[theme].text[Variant.Secondary][InteractionState.Focus]};
+ }
+ }
+
+ // Override the justify-content property in ButtonContent
+ div:nth-child(2) {
+ justify-content: flex-start;
+ }
+ `;
+};
+
+export const buttonChildrenStyles = css`
+ display: grid;
+ grid-template-columns: ${ASSISTANT_AVATAR_SIZE}px auto;
+ align-items: center;
+ gap: ${spacing[200]}px;
+ text-align: left;
+`;
+
+const baseButtonTextStyles = css`
+ width: 200px;
+ opacity: 1;
+ transition-property: opacity;
+ transition-duration: ${SIDE_NAV_TRANSITION_DURATION}ms;
+ transition-timing-function: ease-in-out;
+`;
+
+const hiddenButtonTextStyles = css`
+ opacity: 0;
+`;
+
+export const getButtonTextStyles = ({
+ shouldRender,
+}: {
+ shouldRender: boolean;
+}) =>
+ cx(baseButtonTextStyles, {
+ [hiddenButtonTextStyles]: !shouldRender,
+ });
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/ChatSideNavHeader.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/ChatSideNavHeader.tsx
new file mode 100644
index 0000000000..534749aef3
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/ChatSideNavHeader.tsx
@@ -0,0 +1,95 @@
+import React, { forwardRef } from 'react';
+import { useLeafyGreenChatContext } from '@lg-chat/leafygreen-chat-provider';
+
+import { AssistantAvatar } from '@leafygreen-ui/avatar';
+import {
+ Button,
+ Size as ButtonSize,
+ Variant as ButtonVariant,
+} from '@leafygreen-ui/button';
+import { CompoundSubComponent } from '@leafygreen-ui/compound-component';
+import PlusIcon from '@leafygreen-ui/icon/dist/Plus';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+import { FontWeight } from '@leafygreen-ui/tokens';
+import { Body } from '@leafygreen-ui/typography';
+
+import { useChatLayoutContext } from '../../ChatLayout/ChatLayoutContext';
+import {
+ ChatSideNavHeaderProps,
+ ChatSideNavSubcomponentProperty,
+} from '../ChatSideNav.types';
+
+import {
+ buttonChildrenStyles,
+ getAssistantNameStyles,
+ getAvatarContainerStyles,
+ getButtonStyles,
+ getButtonTextStyles,
+} from './ChatSideNavHeader.styles';
+import { getHeaderStyles } from './ChatSideNavHeader.styles';
+
+export const ChatSideNavHeader = CompoundSubComponent(
+ // eslint-disable-next-line react/display-name
+ forwardRef(
+ ({ children, className, onClickNewChat, ...rest }, ref) => {
+ const { darkMode, theme } = useDarkMode();
+ const { assistantName } = useLeafyGreenChatContext();
+ const { shouldRenderExpanded } = useChatLayoutContext();
+ const showNewChatButton = !!onClickNewChat;
+
+ return (
+
+
+
+
+ {assistantName}
+
+
+ {showNewChatButton && (
+
+
+
+ )}
+ {children}
+
+ );
+ },
+ ),
+ {
+ displayName: 'ChatSideNavHeader',
+ key: ChatSideNavSubcomponentProperty.Header,
+ },
+);
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/index.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/index.ts
new file mode 100644
index 0000000000..90903be24a
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavHeader/index.ts
@@ -0,0 +1 @@
+export { ChatSideNavHeader } from './ChatSideNavHeader';
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.spec.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.spec.tsx
new file mode 100644
index 0000000000..41ec8182a1
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.spec.tsx
@@ -0,0 +1,133 @@
+import React from 'react';
+import {
+ LeafyGreenChatProvider,
+ Variant,
+} from '@lg-chat/leafygreen-chat-provider';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { AriaCurrentValue } from '@leafygreen-ui/lib';
+
+import { ChatSideNav } from '../..';
+
+const Providers = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('ChatSideNavItem', () => {
+ beforeAll(() => {
+ global.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ }));
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('renders with children', () => {
+ render(
+
+
+
+ Chat Name
+
+
+ ,
+ );
+
+ expect(screen.getByText('Chat Name')).toBeInTheDocument();
+ });
+
+ test('renders as anchor when href is provided', () => {
+ render(
+
+
+
+
+ Chat Name
+
+
+
+ ,
+ );
+
+ const link = screen.getByRole('link');
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', '/chat/123');
+ });
+
+ test('applies expected aria-current value when active', () => {
+ render(
+
+
+
+
+ Active Chat
+
+
+
+ ,
+ );
+
+ const item = screen.getByText('Active Chat');
+ expect(item).toHaveAttribute('aria-current', AriaCurrentValue.Page);
+ });
+
+ test('applies expected aria-current value when inactive', () => {
+ render(
+
+
+
+ Inactive Chat
+
+
+ ,
+ );
+
+ const item = screen.getByText('Inactive Chat');
+ expect(item).toHaveAttribute('aria-current', AriaCurrentValue.Unset);
+ });
+
+ test('calls onClick prop when clicked', async () => {
+ const onClick = jest.fn();
+
+ render(
+
+
+
+
+ Clickable Chat
+
+
+
+ ,
+ );
+
+ const item = screen.getByText('Clickable Chat');
+ await userEvent.click(item);
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('applies className prop', () => {
+ render(
+
+
+
+
+ Custom Chat
+
+
+
+ ,
+ );
+
+ const item = screen.getByText('Custom Chat');
+ expect(item).toHaveClass('custom-class');
+ });
+});
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.styles.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.styles.ts
new file mode 100644
index 0000000000..eb6c66e69d
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.styles.ts
@@ -0,0 +1,127 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import { palette } from '@leafygreen-ui/palette';
+import {
+ borderRadius,
+ color,
+ fontFamilies,
+ fontWeights,
+ InteractionState,
+ spacing,
+ transitionDuration,
+ typeScales,
+ Variant,
+} from '@leafygreen-ui/tokens';
+
+import {
+ PINNED_SIDE_NAV_WIDTH,
+ SIDE_NAV_TRANSITION_DURATION,
+} from '../../constants';
+
+const CHAT_SIDE_NAV_ITEM_HEIGHT = 32;
+const WEDGE_HEIGHT_BOUND = 6;
+const WEDGE_WIDTH = 4;
+
+const getBaseStyles = (theme: Theme) => css`
+ // Layout
+ position: relative;
+ width: ${PINNED_SIDE_NAV_WIDTH}px;
+ min-height: ${CHAT_SIDE_NAV_ITEM_HEIGHT}px;
+ padding: ${spacing[150]}px ${spacing[400]}px;
+ display: flex;
+ align-items: center;
+
+ // Typography
+ font-family: ${fontFamilies.default};
+ font-weight: ${fontWeights.regular};
+ font-size: ${typeScales.body1.fontSize}px;
+ line-height: ${typeScales.body1.lineHeight}px;
+ text-align: left;
+ text-decoration: none;
+ color: ${color[theme].text[Variant.Primary][InteractionState.Default]};
+
+ // Stateful transitions
+ transition: background-color ${transitionDuration.faster}ms ease-in-out,
+ opacity ${SIDE_NAV_TRANSITION_DURATION}ms ease-in-out;
+
+ &::before {
+ content: '';
+ position: absolute;
+ background-color: transparent;
+ left: 0;
+ top: ${WEDGE_HEIGHT_BOUND}px;
+ bottom: ${WEDGE_HEIGHT_BOUND}px;
+ width: ${WEDGE_WIDTH}px;
+ border-radius: 0 ${borderRadius[150]}px ${borderRadius[150]}px 0;
+ transition: transform ${transitionDuration.default}ms ease-in-out;
+ transform: scaleY(0.3);
+ }
+
+ &:hover {
+ text-decoration: none;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Hover
+ ]};
+ }
+
+ &:focus-visible {
+ outline: none;
+ text-decoration: none;
+ background-color: ${color[theme].background[Variant.Secondary][
+ InteractionState.Focus
+ ]};
+ color: ${color[theme].text[Variant.Primary][InteractionState.Focus]};
+
+ &::before {
+ transform: scaleY(1);
+ background-color: ${color[theme].icon[Variant.Info][
+ InteractionState.Focus
+ ]};
+ }
+ }
+`;
+
+const getActiveStyles = (theme: Theme) => css`
+ cursor: default;
+ font-weight: ${fontWeights.semiBold};
+ text-decoration: none;
+ color: ${theme === Theme.Light ? palette.green.dark2 : palette.white};
+
+ &,
+ &:hover {
+ background-color: ${color[theme].background[Variant.Success][
+ InteractionState.Hover
+ ]};
+ }
+
+ &::before {
+ transform: scaleY(1);
+ background-color: ${color[theme].icon[Variant.Success][
+ InteractionState.Default
+ ]};
+ }
+`;
+
+const collapsedItemStyles = css`
+ opacity: 0;
+`;
+
+export const getItemStyles = ({
+ active = false,
+ className,
+ shouldRenderExpanded,
+ theme,
+}: {
+ active?: boolean;
+ className?: string;
+ shouldRenderExpanded: boolean;
+ theme: Theme;
+}) =>
+ cx(
+ getBaseStyles(theme),
+ {
+ [getActiveStyles(theme)]: active,
+ [collapsedItemStyles]: !shouldRenderExpanded,
+ },
+ className,
+ );
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.tsx b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.tsx
new file mode 100644
index 0000000000..d3b6facde4
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.tsx
@@ -0,0 +1,52 @@
+import React, { ComponentType } from 'react';
+
+import { CompoundSubComponent } from '@leafygreen-ui/compound-component';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+import { AriaCurrentValue } from '@leafygreen-ui/lib';
+import {
+ InferredPolymorphic,
+ useInferredPolymorphic,
+} from '@leafygreen-ui/polymorphic';
+
+import { useChatLayoutContext } from '../../ChatLayout';
+import { ChatSideNavSubcomponentProperty } from '../ChatSideNav.types';
+
+import { getItemStyles } from './ChatSideNavItem.styles';
+import type {
+ BaseChatSideNavItemProps,
+ ChatSideNavItemProps,
+} from './ChatSideNavItem.types';
+
+export const ChatSideNavItem = CompoundSubComponent(
+ InferredPolymorphic(
+ (
+ { as, active = false, className, children, onClick, ...restProps },
+ ref,
+ ) => {
+ const { Component, rest } = useInferredPolymorphic(as, restProps, 'div');
+ const { theme } = useDarkMode();
+ const { shouldRenderExpanded } = useChatLayoutContext();
+
+ return (
+
+ {children}
+
+ );
+ },
+ ) as ComponentType,
+ {
+ displayName: 'ChatSideNavItem',
+ key: ChatSideNavSubcomponentProperty.SideNavItem,
+ },
+);
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.types.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.types.ts
new file mode 100644
index 0000000000..b1f89dc1a9
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/ChatSideNavItem.types.ts
@@ -0,0 +1,17 @@
+import {
+ InferredPolymorphicPropsWithRef,
+ PolymorphicAs,
+} from '@leafygreen-ui/polymorphic';
+
+export interface BaseChatSideNavItemProps {
+ /**
+ * Whether or not the component should be rendered in an active state.
+ *
+ * @default false
+ */
+ active?: boolean;
+}
+
+// External only
+export type ChatSideNavItemProps =
+ InferredPolymorphicPropsWithRef;
diff --git a/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/index.ts b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/index.ts
new file mode 100644
index 0000000000..fefee99d5a
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/ChatSideNavItem/index.ts
@@ -0,0 +1,5 @@
+export { ChatSideNavItem } from './ChatSideNavItem';
+export type {
+ BaseChatSideNavItemProps,
+ ChatSideNavItemProps,
+} from './ChatSideNavItem.types';
diff --git a/chat/chat-layout/src/ChatSideNav/index.ts b/chat/chat-layout/src/ChatSideNav/index.ts
new file mode 100644
index 0000000000..43a10653ce
--- /dev/null
+++ b/chat/chat-layout/src/ChatSideNav/index.ts
@@ -0,0 +1,10 @@
+export { ChatSideNav } from './ChatSideNav';
+export type {
+ ChatSideNavContentProps,
+ ChatSideNavHeaderProps,
+ ChatSideNavProps,
+} from './ChatSideNav.types';
+export type {
+ BaseChatSideNavItemProps,
+ ChatSideNavItemProps,
+} from './ChatSideNavItem';
diff --git a/chat/chat-layout/src/constants.ts b/chat/chat-layout/src/constants.ts
new file mode 100644
index 0000000000..615423b44b
--- /dev/null
+++ b/chat/chat-layout/src/constants.ts
@@ -0,0 +1,13 @@
+import { transitionDuration } from '@leafygreen-ui/tokens';
+
+export const gridAreas = {
+ sideNav: 'side-nav',
+ main: 'main',
+} as const;
+
+export const COLLAPSED_SIDE_NAV_WIDTH = 48;
+export const COLLAPSED_SIDE_NAV_WIDTH_WITH_BORDER =
+ COLLAPSED_SIDE_NAV_WIDTH + 1;
+export const PINNED_SIDE_NAV_WIDTH = 256;
+export const PINNED_SIDE_NAV_WIDTH_WITH_BORDER = PINNED_SIDE_NAV_WIDTH + 1;
+export const SIDE_NAV_TRANSITION_DURATION = transitionDuration.slower;
diff --git a/chat/chat-layout/src/index.ts b/chat/chat-layout/src/index.ts
new file mode 100644
index 0000000000..d0c0f097e8
--- /dev/null
+++ b/chat/chat-layout/src/index.ts
@@ -0,0 +1,15 @@
+export {
+ ChatLayout,
+ type ChatLayoutContextProps,
+ type ChatLayoutProps,
+ useChatLayoutContext,
+} from './ChatLayout';
+export { ChatMain, type ChatMainProps } from './ChatMain';
+export {
+ type BaseChatSideNavItemProps,
+ ChatSideNav,
+ type ChatSideNavContentProps,
+ type ChatSideNavHeaderProps,
+ type ChatSideNavItemProps,
+ type ChatSideNavProps,
+} from './ChatSideNav';
diff --git a/chat/chat-layout/tsconfig.json b/chat/chat-layout/tsconfig.json
new file mode 100644
index 0000000000..867dc2e293
--- /dev/null
+++ b/chat/chat-layout/tsconfig.json
@@ -0,0 +1,52 @@
+{
+ "extends": "@lg-tools/build/config/package.tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@leafygreen-ui/icon/dist/*": ["../../packages/icon/src/generated/*"],
+ "@leafygreen-ui/*": ["../../packages/*/src"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["**/*.stories.*"],
+ "references": [
+ {
+ "path": "../leafygreen-chat-provider"
+ },
+ {
+ "path": "../../packages/avatar"
+ },
+ {
+ "path": "../../packages/button"
+ },
+ {
+ "path": "../../packages/compound-component"
+ },
+ {
+ "path": "../../packages/emotion"
+ },
+ {
+ "path": "../../packages/icon"
+ },
+ {
+ "path": "../../packages/leafygreen-provider"
+ },
+ {
+ "path": "../../packages/lib"
+ },
+ {
+ "path": "../../packages/palette"
+ },
+ {
+ "path": "../../packages/polymorphic"
+ },
+ {
+ "path": "../../packages/tokens"
+ },
+ {
+ "path": "../../packages/typography"
+ },
+ {
+ "path": "../../tools/test-harnesses"
+ }
+ ]
+}
diff --git a/chat/input-bar/src/InputBar/InputBar.styles.ts b/chat/input-bar/src/InputBar/InputBar.styles.ts
index 04b1458d5c..c1dfdea906 100644
--- a/chat/input-bar/src/InputBar/InputBar.styles.ts
+++ b/chat/input-bar/src/InputBar/InputBar.styles.ts
@@ -135,7 +135,6 @@ const getBaseContentWrapperStyles = ({
position: relative;
border-radius: ${borderRadius[200]}px;
border: 1px solid ${palette.gray.base};
- z-index: 2;
background-color: ${color[theme].background[Variant.Primary][
InteractionState.Default
]};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f99c9c4a47..7b285cbc95 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -338,6 +338,64 @@ importers:
specifier: workspace:^
version: link:../../tools/build
+ chat/chat-layout:
+ dependencies:
+ '@leafygreen-ui/avatar':
+ specifier: workspace:^
+ version: link:../../packages/avatar
+ '@leafygreen-ui/button':
+ specifier: workspace:^
+ version: link:../../packages/button
+ '@leafygreen-ui/compound-component':
+ specifier: workspace:^
+ version: link:../../packages/compound-component
+ '@leafygreen-ui/emotion':
+ specifier: workspace:^
+ version: link:../../packages/emotion
+ '@leafygreen-ui/icon':
+ specifier: workspace:^
+ version: link:../../packages/icon
+ '@leafygreen-ui/leafygreen-provider':
+ specifier: workspace:^3.2.0 || workspace:^4.0.0 || workspace:^5.0.0
+ version: link:../../packages/leafygreen-provider
+ '@leafygreen-ui/lib':
+ specifier: workspace:^
+ version: link:../../packages/lib
+ '@leafygreen-ui/palette':
+ specifier: workspace:^
+ version: link:../../packages/palette
+ '@leafygreen-ui/polymorphic':
+ specifier: workspace:^
+ version: link:../../packages/polymorphic
+ '@leafygreen-ui/tokens':
+ specifier: workspace:^
+ version: link:../../packages/tokens
+ '@leafygreen-ui/typography':
+ specifier: workspace:^
+ version: link:../../packages/typography
+ '@lg-chat/leafygreen-chat-provider':
+ specifier: workspace:^
+ version: link:../leafygreen-chat-provider
+ '@lg-tools/test-harnesses':
+ specifier: workspace:^
+ version: link:../../tools/test-harnesses
+ devDependencies:
+ '@lg-chat/chat-window':
+ specifier: workspace:^
+ version: link:../chat-window
+ '@lg-chat/input-bar':
+ specifier: workspace:^
+ version: link:../input-bar
+ '@lg-chat/message':
+ specifier: workspace:^
+ version: link:../message
+ '@lg-chat/message-feed':
+ specifier: workspace:^
+ version: link:../message-feed
+ '@lg-chat/title-bar':
+ specifier: workspace:^
+ version: link:../title-bar
+
chat/chat-window:
dependencies:
'@leafygreen-ui/emotion':
diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts
index d0f42be39c..238a0402e6 100644
--- a/tools/install/src/ALL_PACKAGES.ts
+++ b/tools/install/src/ALL_PACKAGES.ts
@@ -85,6 +85,7 @@ export const ALL_PACKAGES = [
'@lg-charts/legend',
'@lg-charts/series-provider',
'@lg-chat/avatar',
+ '@lg-chat/chat-layout',
'@lg-chat/chat-window',
'@lg-chat/fixed-chat-window',
'@lg-chat/input-bar',